WARCH3/WCHECK1/WEXT1 完成受控上线后续自动化

Issue: WARCH3, WCHECK1, WEXT1
这个提交包含在:
2026-05-20 13:58:40 +08:00
父节点 512a58d40e
当前提交 56b720b8ab
共修改 28 个文件,包含 2909 行新增和 72 行删除
@@ -31,12 +31,37 @@ function hashPayload(value) {
.digest("hex");
}
function maskIdentifier(value) {
const raw = toSafeString(value, 128);
if (!raw) return "";
const visibleLength = raw.length <= 4 ? 1 : raw.length <= 8 ? 2 : 4;
return `${raw.slice(0, visibleLength)}***`;
}
function maskDelimitedIdentifiers(value) {
const raw = toSafeString(value, 1000);
if (!raw) return raw;
return raw
.split(/[|,;\s]+/)
.filter(Boolean)
.map(maskIdentifier)
.join("|");
}
function sanitizeCheckinApiResponse(payload) {
if (!payload || typeof payload !== "object") return null;
const result = sanitizeWecomResponse(payload) ?? {};
for (const key of ["invaliduser", "invalidparty", "invalidtag"]) {
if (typeof result[key] === "string") {
result[key] = maskDelimitedIdentifiers(result[key]);
}
}
if (Array.isArray(payload.checkindata)) {
result.checkindataCount = payload.checkindata.length;
}
if (Array.isArray(payload.info)) {
result.ruleInfoCount = payload.info.length;
}
return Object.keys(result).length ? result : null;
}
@@ -182,9 +207,121 @@ async function fetchWecomCheckinData({
};
}
async function fetchWecomCheckinRules({
config,
date,
externalUserIds,
requireSyncEnabled = false,
fetchImpl = fetch,
}) {
const userIds = Array.from(new Set((externalUserIds ?? []).map((id) => toSafeString(id, 128)).filter(Boolean)));
if (requireSyncEnabled && !config?.wecomCheckinSyncEnabled) {
return {
ok: false,
skipped: true,
retryable: false,
code: "wecom_checkin_sync_disabled",
message: "WECOM_CHECKIN_SYNC_ENABLED is not true",
rules: [],
response: null,
};
}
const checkinSecret = resolveCheckinSecret(config);
if (!checkinSecret) {
return {
ok: false,
skipped: true,
retryable: false,
code: "wecom_checkin_secret_missing",
message: "Missing WECOM_CHECKIN_SECRET or WECOM_AGENT_SECRET",
rules: [],
response: null,
};
}
if (!userIds.length) {
return {
ok: true,
skipped: false,
retryable: false,
code: "wecom_checkin_no_bound_users",
message: null,
rules: [],
response: { userCount: 0 },
};
}
const tokenResult = await getWecomAppAccessToken({
config: {
...config,
wecomAgentSecret: checkinSecret,
},
timeoutMs: config.wecomRequestTimeoutMs ?? 5000,
fetchImpl,
});
if (!tokenResult.ok) {
return {
ok: false,
skipped: false,
retryable: tokenResult.retryable,
code: tokenResult.code,
message: tokenResult.message,
rules: [],
response: tokenResult.response,
};
}
const dateValue = date instanceof Date ? date : buildShanghaiDateTime(date, false);
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/checkin/getcheckinoption");
url.searchParams.set("access_token", tokenResult.accessToken);
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
datetime: toUnixSeconds(dateValue),
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_rule_http_5xx" : "wecom_checkin_rule_http_error",
message: `WeCom checkin rule HTTP ${response.status}`,
rules: [],
response: { status: response.status, body: sanitized },
};
}
if (!payload || Number(payload.errcode ?? 0) !== 0) {
return {
ok: false,
skipped: false,
retryable: false,
code: "wecom_checkin_rule_api_error",
message: `WeCom checkin rule errcode ${payload?.errcode ?? "missing_payload"}`,
rules: [],
response: sanitized,
};
}
return {
ok: true,
skipped: false,
retryable: false,
code: "wecom_checkin_rule_fetched",
message: null,
rules: Array.isArray(payload.info) ? payload.info : [],
response: sanitized,
};
}
module.exports = {
fetchWecomCheckinRules,
fetchWecomCheckinData,
formatDateInTimezone,
maskDelimitedIdentifiers,
normalizeCheckinRecord,
resolveCheckinSecret,
sanitizeCheckinApiResponse,
@@ -0,0 +1,435 @@
const crypto = require("node:crypto");
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function sha256(value) {
return crypto.createHash("sha256").update(String(value ?? "")).digest("hex");
}
function stableJson(value) {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function digestObject(value) {
return sha256(stableJson(value));
}
function maskExternalUserId(value) {
const raw = toSafeString(value, 128);
if (!raw) return null;
const visibleLength = raw.length <= 4 ? 1 : raw.length <= 8 ? 2 : 4;
return `${raw.slice(0, visibleLength)}***`;
}
function formatDateOnly(value) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toISOString().slice(0, 10);
}
function monthOfDate(value) {
return formatDateOnly(value).slice(0, 7);
}
function toDateOnlyUtc(value) {
const text = formatDateOnly(value);
return text ? new Date(`${text}T00:00:00.000Z`) : null;
}
function normalizeRecord(record) {
const employeeId = toSafeString(record?.employee_id ?? record?.employeeId, 64);
const externalUserId = toSafeString(record?.external_user_id ?? record?.externalUserId, 128);
const checkinTime = record?.checkin_time ?? record?.checkinTime;
const checkinDate = record?.checkin_date ?? record?.checkinDate ?? checkinTime;
const checkinDateValue = toDateOnlyUtc(checkinDate);
const checkinTimeValue = checkinTime instanceof Date ? checkinTime : new Date(checkinTime);
if (!employeeId || !checkinDateValue || Number.isNaN(checkinTimeValue.getTime())) return null;
return {
employeeId,
externalUserId,
externalUserIdPrefix: maskExternalUserId(externalUserId),
checkinDate: checkinDateValue,
checkinDateString: formatDateOnly(checkinDateValue),
checkinTime: checkinTimeValue,
checkinType: toSafeString(record?.checkin_type ?? record?.checkinType ?? "unknown", 40) || "unknown",
exceptionType: toSafeString(record?.exception_type ?? record?.exceptionType, 80),
sourceType: toSafeString(record?.source_type ?? record?.sourceType ?? "unknown", 40) || "unknown",
result: toSafeString(record?.result ?? "unknown", 80) || "unknown",
rawHash: toSafeString(record?.raw_hash ?? record?.rawHash, 64),
syncRunId: toSafeString(record?.sync_run_id ?? record?.syncRunId, 64),
};
}
function increment(map, key, amount = 1) {
const normalized = toSafeString(key || "unknown", 80) || "unknown";
map[normalized] = (map[normalized] || 0) + amount;
}
function buildDailySummaries(records) {
const groups = new Map();
for (const raw of records || []) {
const record = normalizeRecord(raw);
if (!record) continue;
const key = `${record.employeeId}|${record.checkinDateString}`;
if (!groups.has(key)) {
groups.set(key, {
employee_id: record.employeeId,
external_user_id_prefix: record.externalUserIdPrefix,
checkin_date: record.checkinDate,
record_count: 0,
first_checkin_at: null,
last_checkin_at: null,
exception_count: 0,
abnormal_count: 0,
checkin_type_summary: {},
result_summary: {},
source_type_summary: {},
rawHashes: [],
source_record_count: 0,
last_sync_run_id: record.syncRunId || null,
});
}
const summary = groups.get(key);
summary.record_count += 1;
summary.source_record_count += 1;
if (!summary.first_checkin_at || record.checkinTime < summary.first_checkin_at) {
summary.first_checkin_at = record.checkinTime;
}
if (!summary.last_checkin_at || record.checkinTime > summary.last_checkin_at) {
summary.last_checkin_at = record.checkinTime;
}
if (record.exceptionType) summary.exception_count += 1;
if (record.exceptionType || !["normal", "regular", "success", "ok", "unknown", ""].includes(record.result.toLowerCase())) {
summary.abnormal_count += 1;
}
increment(summary.checkin_type_summary, record.checkinType);
increment(summary.result_summary, record.result);
increment(summary.source_type_summary, record.sourceType);
if (record.rawHash) summary.rawHashes.push(record.rawHash);
if (record.syncRunId) summary.last_sync_run_id = record.syncRunId;
}
return Array.from(groups.values()).map((summary) => ({
...summary,
raw_hash_digest: summary.rawHashes.length ? digestObject(summary.rawHashes.sort()) : null,
rawHashes: undefined,
}));
}
function buildMonthlySummaries(dailySummaries, { expectedWorkdaysByMonth = {} } = {}) {
const groups = new Map();
for (const day of dailySummaries || []) {
const month = monthOfDate(day.checkin_date);
if (!month) continue;
const key = `${day.employee_id}|${month}`;
if (!groups.has(key)) {
groups.set(key, {
employee_id: day.employee_id,
month,
start_date: toDateOnlyUtc(`${month}-01`),
end_date: toDateOnlyUtc(day.checkin_date),
summary_day_count: 0,
checkin_day_count: 0,
exception_day_count: 0,
abnormal_record_count: 0,
missing_day_candidate_count: 0,
first_checkin_at: null,
last_checkin_at: null,
result_summary: {},
rawHashes: [],
});
}
const summary = groups.get(key);
summary.summary_day_count += 1;
if (day.record_count > 0) summary.checkin_day_count += 1;
if (day.exception_count > 0 || day.abnormal_count > 0) summary.exception_day_count += 1;
summary.abnormal_record_count += Number(day.abnormal_count || 0);
if (!summary.first_checkin_at || (day.first_checkin_at && day.first_checkin_at < summary.first_checkin_at)) {
summary.first_checkin_at = day.first_checkin_at;
}
if (!summary.last_checkin_at || (day.last_checkin_at && day.last_checkin_at > summary.last_checkin_at)) {
summary.last_checkin_at = day.last_checkin_at;
}
if (day.checkin_date > summary.end_date) summary.end_date = day.checkin_date;
for (const [result, count] of Object.entries(day.result_summary || {})) {
increment(summary.result_summary, result, count);
}
if (day.raw_hash_digest) summary.rawHashes.push(day.raw_hash_digest);
}
return Array.from(groups.values()).map((summary) => {
const expectedWorkdays = Number(expectedWorkdaysByMonth[summary.month] || 0);
const missing = expectedWorkdays > 0 ? Math.max(0, expectedWorkdays - summary.checkin_day_count) : 0;
return {
...summary,
missing_day_candidate_count: missing,
raw_hash_digest: summary.rawHashes.length ? digestObject(summary.rawHashes.sort()) : null,
rawHashes: undefined,
};
});
}
async function upsertDailySummaries(prisma, summaries) {
let count = 0;
for (const summary of summaries || []) {
await prisma.wecomCheckinDailySummary.upsert({
where: {
employee_id_checkin_date: {
employee_id: summary.employee_id,
checkin_date: summary.checkin_date,
},
},
create: {
employee_id: summary.employee_id,
external_user_id_prefix: summary.external_user_id_prefix,
checkin_date: summary.checkin_date,
record_count: summary.record_count,
first_checkin_at: summary.first_checkin_at,
last_checkin_at: summary.last_checkin_at,
exception_count: summary.exception_count,
abnormal_count: summary.abnormal_count,
checkin_type_summary: summary.checkin_type_summary,
result_summary: summary.result_summary,
source_type_summary: summary.source_type_summary,
raw_hash_digest: summary.raw_hash_digest,
source_record_count: summary.source_record_count,
last_sync_run_id: summary.last_sync_run_id,
},
update: {
external_user_id_prefix: summary.external_user_id_prefix,
record_count: summary.record_count,
first_checkin_at: summary.first_checkin_at,
last_checkin_at: summary.last_checkin_at,
exception_count: summary.exception_count,
abnormal_count: summary.abnormal_count,
checkin_type_summary: summary.checkin_type_summary,
result_summary: summary.result_summary,
source_type_summary: summary.source_type_summary,
raw_hash_digest: summary.raw_hash_digest,
source_record_count: summary.source_record_count,
last_sync_run_id: summary.last_sync_run_id,
archived_at: new Date(),
},
});
count += 1;
}
return count;
}
async function upsertMonthlySummaries(prisma, summaries) {
let count = 0;
for (const summary of summaries || []) {
await prisma.wecomCheckinMonthlySummary.upsert({
where: {
employee_id_month: {
employee_id: summary.employee_id,
month: summary.month,
},
},
create: {
employee_id: summary.employee_id,
month: summary.month,
start_date: summary.start_date,
end_date: summary.end_date,
summary_day_count: summary.summary_day_count,
checkin_day_count: summary.checkin_day_count,
exception_day_count: summary.exception_day_count,
abnormal_record_count: summary.abnormal_record_count,
missing_day_candidate_count: summary.missing_day_candidate_count,
first_checkin_at: summary.first_checkin_at,
last_checkin_at: summary.last_checkin_at,
result_summary: summary.result_summary,
raw_hash_digest: summary.raw_hash_digest,
},
update: {
start_date: summary.start_date,
end_date: summary.end_date,
summary_day_count: summary.summary_day_count,
checkin_day_count: summary.checkin_day_count,
exception_day_count: summary.exception_day_count,
abnormal_record_count: summary.abnormal_record_count,
missing_day_candidate_count: summary.missing_day_candidate_count,
first_checkin_at: summary.first_checkin_at,
last_checkin_at: summary.last_checkin_at,
result_summary: summary.result_summary,
raw_hash_digest: summary.raw_hash_digest,
archived_at: new Date(),
},
});
count += 1;
}
return count;
}
function summarizeRuleOption(rule) {
const source = rule && typeof rule === "object" ? rule : {};
const ruleKeys = Object.keys(source).filter((key) => !/loc|lat|lng|face|device|mac|wifi|payload/i.test(key)).sort();
const group = source.group || source.groupname || source.group_name || {};
const checkinDate = source.checkin_date || source.date || source.datetime || null;
return {
keyCount: ruleKeys.length,
keySummary: ruleKeys.slice(0, 20),
hasGroup: Boolean(group && Object.keys(group).length),
hasScheduleList: Array.isArray(source.checkin_time) || Array.isArray(source.checkinTime) || Array.isArray(source.sch_checkin_time),
hasSpecialDays: Boolean(source.spe_workdays || source.spe_offdays || source.speWorkdays || source.speOffdays),
datePresent: Boolean(checkinDate),
};
}
function extractRuleUserId(rule) {
return toSafeString(rule?.userid ?? rule?.UserId ?? rule?.user_id ?? rule?.open_userid, 128);
}
function buildRuleSnapshots({ rules, employeeByExternalUserId, snapshotDate }) {
const dateValue = toDateOnlyUtc(snapshotDate || new Date());
return (rules || []).map((rule) => {
const externalUserId = extractRuleUserId(rule);
const employee = employeeByExternalUserId?.get(externalUserId);
const ruleSummary = summarizeRuleOption(rule);
return {
employee_id: employee?.id || null,
external_user_id_prefix: maskExternalUserId(externalUserId),
snapshot_date: dateValue,
rule_hash: digestObject(ruleSummary),
rule_summary: ruleSummary,
};
}).filter((item) => item.employee_id && item.snapshot_date && item.rule_hash);
}
async function upsertRuleSnapshots(prisma, snapshots) {
let count = 0;
for (const snapshot of snapshots || []) {
await prisma.wecomCheckinRuleSnapshot.upsert({
where: {
employee_id_snapshot_date_rule_hash: {
employee_id: snapshot.employee_id,
snapshot_date: snapshot.snapshot_date,
rule_hash: snapshot.rule_hash,
},
},
create: {
employee_id: snapshot.employee_id,
external_user_id_prefix: snapshot.external_user_id_prefix,
snapshot_date: snapshot.snapshot_date,
rule_hash: snapshot.rule_hash,
rule_summary: snapshot.rule_summary,
},
update: {
external_user_id_prefix: snapshot.external_user_id_prefix,
rule_summary: snapshot.rule_summary,
archived_at: new Date(),
},
});
count += 1;
}
return count;
}
async function archiveCheckinSummaries({ prisma, records, ruleSnapshots = [] }) {
const dailySummaries = buildDailySummaries(records);
const monthlySummaries = buildMonthlySummaries(dailySummaries);
const dailyCount = await upsertDailySummaries(prisma, dailySummaries);
const monthlyCount = await upsertMonthlySummaries(prisma, monthlySummaries);
const ruleSnapshotCount = await upsertRuleSnapshots(prisma, ruleSnapshots);
return {
dailyCount,
monthlyCount,
ruleSnapshotCount,
dailySummaries,
monthlySummaries,
};
}
async function readCheckinArchiveSnapshot(prisma, { startDate, endDate, employeeIds = [], limit = 50 } = {}) {
const where = {};
if (startDate || endDate) {
where.checkin_date = {
...(startDate ? { gte: toDateOnlyUtc(startDate) } : {}),
...(endDate ? { lte: toDateOnlyUtc(endDate) } : {}),
};
}
if (employeeIds.length) {
where.employee_id = { in: employeeIds };
}
const [daily, monthly, rules] = await Promise.all([
prisma.wecomCheckinDailySummary.findMany({
where,
orderBy: [{ checkin_date: "desc" }, { updated_at: "desc" }],
take: limit,
}),
prisma.wecomCheckinMonthlySummary.findMany({
where: employeeIds.length ? { employee_id: { in: employeeIds } } : {},
orderBy: [{ month: "desc" }, { updated_at: "desc" }],
take: limit,
}),
prisma.wecomCheckinRuleSnapshot.findMany({
where: employeeIds.length ? { employee_id: { in: employeeIds } } : {},
orderBy: [{ snapshot_date: "desc" }, { updated_at: "desc" }],
take: limit,
}),
]);
return { daily, monthly, rules };
}
function sanitizeDailySummary(row) {
return {
employeeId: row.employee_id,
userIdPrefix: row.external_user_id_prefix,
checkinDate: formatDateOnly(row.checkin_date),
recordCount: row.record_count,
firstCheckinAt: row.first_checkin_at ? new Date(row.first_checkin_at).toISOString() : null,
lastCheckinAt: row.last_checkin_at ? new Date(row.last_checkin_at).toISOString() : null,
exceptionCount: row.exception_count,
abnormalCount: row.abnormal_count,
resultSummary: row.result_summary || {},
rawHashDigestPrefix: row.raw_hash_digest ? `${row.raw_hash_digest.slice(0, 12)}...` : null,
};
}
function sanitizeMonthlySummary(row) {
return {
employeeId: row.employee_id,
month: row.month,
summaryDayCount: row.summary_day_count,
checkinDayCount: row.checkin_day_count,
exceptionDayCount: row.exception_day_count,
abnormalRecordCount: row.abnormal_record_count,
missingDayCandidateCount: row.missing_day_candidate_count,
firstCheckinAt: row.first_checkin_at ? new Date(row.first_checkin_at).toISOString() : null,
lastCheckinAt: row.last_checkin_at ? new Date(row.last_checkin_at).toISOString() : null,
};
}
function sanitizeRuleSnapshot(row) {
return {
employeeId: row.employee_id,
userIdPrefix: row.external_user_id_prefix,
snapshotDate: formatDateOnly(row.snapshot_date),
ruleHashPrefix: row.rule_hash ? `${row.rule_hash.slice(0, 12)}...` : null,
ruleSummary: row.rule_summary || {},
};
}
module.exports = {
archiveCheckinSummaries,
buildDailySummaries,
buildMonthlySummaries,
buildRuleSnapshots,
digestObject,
maskExternalUserId,
readCheckinArchiveSnapshot,
sanitizeDailySummary,
sanitizeMonthlySummary,
sanitizeRuleSnapshot,
summarizeRuleOption,
upsertDailySummaries,
upsertMonthlySummaries,
upsertRuleSnapshots,
};