ATTV1 实现考勤统一入口与企微同步

这个提交包含在:
2026-05-17 16:56:24 +08:00
父节点 d50b7db143
当前提交 916cc3b7f7
共修改 51 个文件,包含 3323 行新增和 1762 行删除
@@ -0,0 +1,181 @@
const crypto = require("node:crypto");
const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken");
function toSafeString(value, maxLength = 200) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function toUnixSeconds(value) {
return Math.floor(value.getTime() / 1000);
}
function buildShanghaiDateTime(dateString, endOfDay = false) {
return new Date(`${dateString}T${endOfDay ? "23:59:59" : "00:00:00"}+08:00`);
}
function formatDateInTimezone(value, timeZone = "Asia/Shanghai") {
return new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(value);
}
function hashPayload(value) {
return crypto
.createHash("sha256")
.update(JSON.stringify(value ?? {}))
.digest("hex");
}
function sanitizeCheckinApiResponse(payload) {
if (!payload || typeof payload !== "object") return null;
const result = sanitizeWecomResponse(payload) ?? {};
if (Array.isArray(payload.checkindata)) {
result.checkindataCount = payload.checkindata.length;
}
return Object.keys(result).length ? result : null;
}
function normalizeCheckinRecord(raw, { employeeByExternalUserId, timeZone }) {
const externalUserId = toSafeString(raw?.userid ?? raw?.UserId, 128);
const employee = employeeByExternalUserId.get(externalUserId);
const rawTime = Number(raw?.checkin_time ?? raw?.checkintime ?? raw?.timestamp);
if (!externalUserId || !employee || !Number.isFinite(rawTime) || rawTime <= 0) {
return null;
}
const checkinTime = new Date(rawTime * 1000);
const checkinDateString = formatDateInTimezone(checkinTime, timeZone);
return {
employeeId: employee.id,
externalUserId,
checkinTime,
checkinDate: new Date(`${checkinDateString}T00:00:00.000Z`),
checkinType: toSafeString(raw?.checkin_type ?? raw?.checkintype ?? "unknown", 40) || "unknown",
exceptionType: toSafeString(raw?.exception_type ?? raw?.exceptiontype, 80) || null,
sourceType: toSafeString(raw?.source_type ?? raw?.sourcetype, 40) || null,
result: toSafeString(raw?.result ?? raw?.checkin_result, 80) || null,
rawHash: hashPayload(raw),
};
}
async function fetchWecomCheckinData({
config,
startDate,
endDate,
externalUserIds,
fetchImpl = fetch,
}) {
const userIds = Array.from(new Set((externalUserIds ?? []).map((id) => toSafeString(id, 128)).filter(Boolean)));
if (!config?.wecomCheckinSyncEnabled) {
return {
ok: false,
skipped: true,
retryable: false,
code: "wecom_checkin_sync_disabled",
message: "WECOM_CHECKIN_SYNC_ENABLED is not true",
records: [],
response: null,
};
}
if (!toSafeString(config?.wecomCheckinSecret, 500)) {
return {
ok: false,
skipped: true,
retryable: false,
code: "wecom_checkin_secret_missing",
message: "Missing WECOM_CHECKIN_SECRET",
records: [],
response: null,
};
}
if (!userIds.length) {
return {
ok: true,
skipped: false,
retryable: false,
code: "wecom_checkin_no_bound_users",
message: null,
records: [],
response: { userCount: 0 },
};
}
const tokenResult = await getWecomAppAccessToken({
config: {
...config,
wecomAgentSecret: config.wecomCheckinSecret,
},
timeoutMs: config.wecomRequestTimeoutMs ?? 5000,
fetchImpl,
});
if (!tokenResult.ok) {
return {
ok: false,
skipped: false,
retryable: tokenResult.retryable,
code: tokenResult.code,
message: tokenResult.message,
records: [],
response: tokenResult.response,
};
}
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/checkin/getcheckindata");
url.searchParams.set("access_token", tokenResult.accessToken);
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
opencheckindatatype: 3,
starttime: toUnixSeconds(buildShanghaiDateTime(startDate, false)),
endtime: toUnixSeconds(buildShanghaiDateTime(endDate, true)),
useridlist: userIds,
}),
});
const payload = await response.json().catch(() => null);
const sanitized = sanitizeCheckinApiResponse(payload);
if (!response.ok) {
return {
ok: false,
skipped: false,
retryable: response.status >= 500 || response.status === 429,
code: response.status >= 500 ? "wecom_checkin_http_5xx" : "wecom_checkin_http_error",
message: `WeCom checkin HTTP ${response.status}`,
records: [],
response: { status: response.status, body: sanitized },
};
}
if (!payload || Number(payload.errcode ?? 0) !== 0) {
return {
ok: false,
skipped: false,
retryable: false,
code: "wecom_checkin_api_error",
message: `WeCom checkin errcode ${payload?.errcode ?? "missing_payload"}`,
records: [],
response: sanitized,
};
}
return {
ok: true,
skipped: false,
retryable: false,
code: "wecom_checkin_fetched",
message: null,
records: Array.isArray(payload.checkindata) ? payload.checkindata : [],
response: sanitized,
};
}
module.exports = {
fetchWecomCheckinData,
formatDateInTimezone,
normalizeCheckinRecord,
sanitizeCheckinApiResponse,
};