HRWD-0..9 实现人事生命周期企微通讯录同步
这个提交包含在:
@@ -15,6 +15,7 @@ const FEATURE_FLAG_KEYS = {
|
||||
attendanceUnified: "attendance_unified_v1",
|
||||
weeklyReport: "weekly_report_v1",
|
||||
employeeLifecycle: "employee_lifecycle_v1",
|
||||
hrWecomDirectorySync: "hr_wecom_directory_sync_v1",
|
||||
todoBoard: "todo_board_v1",
|
||||
wecomEntry: "wecom_entry_v1",
|
||||
projectDomain: "project_domain_v1",
|
||||
@@ -140,6 +141,10 @@ function resolveEnvGate(config, flagKey) {
|
||||
return Boolean(config.employeeLifecycleEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.hrWecomDirectorySync) {
|
||||
return Boolean(config.wecomContactSyncEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.todoBoard) {
|
||||
return Boolean(config.todoBoardEnabled);
|
||||
}
|
||||
|
||||
@@ -50,9 +50,10 @@ async function getWecomAppAccessToken({ config, timeoutMs = 5000, fetchImpl = fe
|
||||
const corpId = toSafeString(config?.wecomCorpId, 128);
|
||||
const agentId = toSafeString(config?.wecomAgentId, 64);
|
||||
const agentSecret = toSafeString(config?.wecomAgentSecret, 500);
|
||||
const requireAgentId = config?.wecomRequireAgentId !== false;
|
||||
const missing = [];
|
||||
if (!corpId) missing.push('WECOM_CORP_ID');
|
||||
if (!agentId) missing.push('WECOM_AGENT_ID');
|
||||
if (requireAgentId && !agentId) missing.push('WECOM_AGENT_ID');
|
||||
if (!agentSecret) missing.push('WECOM_AGENT_SECRET');
|
||||
if (missing.length) {
|
||||
return buildMissingConfigResult(missing);
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../featureFlags");
|
||||
const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken");
|
||||
|
||||
const OPERATION_LIST = Object.freeze(["create", "update", "disable"]);
|
||||
|
||||
function toSafeString(value, maxLength = 200) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function maskPhone(value) {
|
||||
const raw = toSafeString(value, 30);
|
||||
if (raw.length <= 7) return raw ? "***" : "";
|
||||
return `${raw.slice(0, 3)}****${raw.slice(-4)}`;
|
||||
}
|
||||
|
||||
function sanitizeErrorMessage(value) {
|
||||
return toSafeString(value, 500).replace(/[A-Za-z0-9_-]{24,}/g, "***");
|
||||
}
|
||||
|
||||
function sanitizeExternalUserId(value) {
|
||||
return toSafeString(value, 128).replace(/[^a-zA-Z0-9._@-]+/g, "_").slice(0, 64);
|
||||
}
|
||||
|
||||
function deriveExternalUserId(employee) {
|
||||
const fromBinding = sanitizeExternalUserId(employee?.wecom_identity_binding?.external_user_id);
|
||||
if (fromBinding) return fromBinding;
|
||||
|
||||
const fromEmployeeNo = sanitizeExternalUserId(employee?.employee_no);
|
||||
if (fromEmployeeNo) return fromEmployeeNo;
|
||||
|
||||
const compactId = toSafeString(employee?.id, 64).replace(/-/g, "");
|
||||
return compactId ? `oa_${compactId}`.slice(0, 64) : "";
|
||||
}
|
||||
|
||||
function normalizeOperation(value) {
|
||||
const operation = toSafeString(value, 32);
|
||||
return OPERATION_LIST.includes(operation) ? operation : "update";
|
||||
}
|
||||
|
||||
function mapWecomDirectorySyncRun(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employee_id,
|
||||
externalUserId: row.external_user_id ?? null,
|
||||
operation: row.operation,
|
||||
status: row.status,
|
||||
attempts: Number(row.attempts ?? 1),
|
||||
errorCode: row.error_code ?? null,
|
||||
errorMessage: row.error_message ?? null,
|
||||
responseSummary: row.response_summary ?? null,
|
||||
triggeredBy: row.triggered_by ?? null,
|
||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapWecomDirectoryState(input = {}) {
|
||||
const binding = input.binding ?? input.wecom_identity_binding ?? null;
|
||||
const runs = input.runs ?? input.wecom_directory_sync_runs ?? [];
|
||||
const latestRun = Array.isArray(runs) && runs.length ? runs[0] : null;
|
||||
const mappedRun = mapWecomDirectorySyncRun(latestRun);
|
||||
const externalUserId = binding?.external_user_id ?? mappedRun?.externalUserId ?? null;
|
||||
|
||||
let status = "unbound";
|
||||
if (binding?.external_user_id) status = "bound";
|
||||
if (mappedRun?.status === "success") status = binding?.external_user_id ? "synced" : "sync_success";
|
||||
if (mappedRun?.status === "skipped") status = "sync_skipped";
|
||||
if (mappedRun?.status === "failed") status = "sync_failed";
|
||||
|
||||
return {
|
||||
status,
|
||||
externalUserId,
|
||||
bound: Boolean(binding?.external_user_id),
|
||||
latestSync: mappedRun,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeDirectoryResponse(payload) {
|
||||
const sanitized = sanitizeWecomResponse(payload) ?? {};
|
||||
return Object.keys(sanitized).length ? sanitized : null;
|
||||
}
|
||||
|
||||
function resolveDepartmentId(config) {
|
||||
const raw = toSafeString(config?.wecomContactDefaultDepartmentId, 32);
|
||||
const numeric = Number(raw);
|
||||
return raw && Number.isInteger(numeric) && numeric > 0 ? numeric : null;
|
||||
}
|
||||
|
||||
function buildDirectoryPayload({ employee, externalUserId, operation, departmentId }) {
|
||||
const base = {
|
||||
userid: externalUserId,
|
||||
name: toSafeString(employee.name, 64) || externalUserId,
|
||||
mobile: toSafeString(employee.phone, 30) || undefined,
|
||||
email: toSafeString(employee.email, 128) || undefined,
|
||||
position: toSafeString(employee.title, 64) || undefined,
|
||||
department: [departmentId],
|
||||
};
|
||||
|
||||
if (operation === "disable") {
|
||||
return {
|
||||
userid: externalUserId,
|
||||
enable: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === "update") {
|
||||
return {
|
||||
...base,
|
||||
enable: employee.status === "archived" ? 0 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
enable: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDirectoryEndpoint(operation) {
|
||||
return operation === "create" ? "/cgi-bin/user/create" : "/cgi-bin/user/update";
|
||||
}
|
||||
|
||||
async function fetchEmployeeForDirectory(prisma, employeeId) {
|
||||
return prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
title: true,
|
||||
status: true,
|
||||
wecom_identity_binding: {
|
||||
select: {
|
||||
id: true,
|
||||
external_user_id: true,
|
||||
external_open_user_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createSyncRun(prisma, input) {
|
||||
return prisma.wecomDirectorySyncRun.create({
|
||||
data: {
|
||||
employee_id: input.employeeId,
|
||||
external_user_id: input.externalUserId || null,
|
||||
operation: input.operation,
|
||||
status: "running",
|
||||
attempts: input.attempts ?? 1,
|
||||
triggered_by: input.actorId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function finishSyncRun(prisma, runId, data) {
|
||||
return prisma.wecomDirectorySyncRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
status: data.status,
|
||||
error_code: data.errorCode ?? null,
|
||||
error_message: data.errorMessage ? sanitizeErrorMessage(data.errorMessage) : null,
|
||||
response_summary: data.responseSummary ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function syncWecomDirectoryForEmployee({
|
||||
prisma,
|
||||
config,
|
||||
employeeId,
|
||||
actorId = null,
|
||||
operation,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const normalizedOperation = normalizeOperation(operation);
|
||||
const employee = await fetchEmployeeForDirectory(prisma, employeeId);
|
||||
if (!employee) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
status: "failed",
|
||||
code: "employee_not_found",
|
||||
message: "Employee not found",
|
||||
run: null,
|
||||
};
|
||||
}
|
||||
|
||||
const externalUserId = deriveExternalUserId(employee);
|
||||
const run = await createSyncRun(prisma, {
|
||||
employeeId,
|
||||
externalUserId,
|
||||
operation: normalizedOperation,
|
||||
actorId,
|
||||
});
|
||||
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey: FEATURE_FLAG_KEYS.hrWecomDirectorySync,
|
||||
departmentId: null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||
status: "skipped",
|
||||
errorCode: "wecom_contact_sync_disabled",
|
||||
errorMessage: gate.reason,
|
||||
responseSummary: { reason: gate.reason, envEnabled: gate.envEnabled },
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
status: "skipped",
|
||||
code: "wecom_contact_sync_disabled",
|
||||
message: gate.reason,
|
||||
run: mapWecomDirectorySyncRun(updatedRun),
|
||||
};
|
||||
}
|
||||
|
||||
const departmentId = resolveDepartmentId(config);
|
||||
const missing = [];
|
||||
if (!toSafeString(config?.wecomCorpId, 128)) missing.push("WECOM_CORP_ID");
|
||||
if (!toSafeString(config?.wecomContactSecret, 500)) missing.push("WECOM_CONTACT_SECRET");
|
||||
if (!departmentId) missing.push("WECOM_CONTACT_DEFAULT_DEPARTMENT_ID");
|
||||
if (!externalUserId) missing.push("external_user_id");
|
||||
if (missing.length) {
|
||||
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||
status: "skipped",
|
||||
errorCode: "wecom_contact_config_missing",
|
||||
errorMessage: `Missing WeCom contact config: ${missing.join(",")}`,
|
||||
responseSummary: { missing },
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
status: "skipped",
|
||||
code: "wecom_contact_config_missing",
|
||||
message: "WeCom contact config missing",
|
||||
run: mapWecomDirectorySyncRun(updatedRun),
|
||||
};
|
||||
}
|
||||
|
||||
const tokenResult = await getWecomAppAccessToken({
|
||||
config: {
|
||||
...config,
|
||||
wecomAgentId: config.wecomAgentId || "contact",
|
||||
wecomAgentSecret: config.wecomContactSecret,
|
||||
wecomRequireAgentId: false,
|
||||
},
|
||||
timeoutMs: config.wecomRequestTimeoutMs ?? 5000,
|
||||
fetchImpl,
|
||||
});
|
||||
if (!tokenResult.ok) {
|
||||
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||
status: "failed",
|
||||
errorCode: tokenResult.code,
|
||||
errorMessage: tokenResult.message,
|
||||
responseSummary: tokenResult.response,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
status: "failed",
|
||||
code: tokenResult.code,
|
||||
message: tokenResult.message,
|
||||
run: mapWecomDirectorySyncRun(updatedRun),
|
||||
};
|
||||
}
|
||||
|
||||
const endpoint = resolveDirectoryEndpoint(normalizedOperation);
|
||||
const url = new URL(`https://qyapi.weixin.qq.com${endpoint}`);
|
||||
url.searchParams.set("access_token", tokenResult.accessToken);
|
||||
const payload = buildDirectoryPayload({
|
||||
employee,
|
||||
externalUserId,
|
||||
operation: normalizedOperation,
|
||||
departmentId,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseBody = await response.json().catch(() => null);
|
||||
const summary = sanitizeDirectoryResponse(responseBody);
|
||||
const errcode = Number(responseBody?.errcode ?? 0);
|
||||
|
||||
if (!response.ok || errcode !== 0) {
|
||||
const code = !response.ok
|
||||
? response.status >= 500
|
||||
? "wecom_contact_http_5xx"
|
||||
: "wecom_contact_http_error"
|
||||
: "wecom_contact_api_error";
|
||||
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||
status: "failed",
|
||||
errorCode: code,
|
||||
errorMessage: !response.ok
|
||||
? `WeCom contact HTTP ${response.status}`
|
||||
: `WeCom contact errcode ${responseBody?.errcode ?? "missing_payload"}`,
|
||||
responseSummary: !response.ok ? { status: response.status, body: summary } : summary,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
status: "failed",
|
||||
code,
|
||||
message: "WeCom contact sync failed",
|
||||
run: mapWecomDirectorySyncRun(updatedRun),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedOperation !== "disable") {
|
||||
await prisma.wecomIdentityBinding.upsert({
|
||||
where: { employee_id: employeeId },
|
||||
update: {
|
||||
external_user_id: externalUserId,
|
||||
updated_by: actorId,
|
||||
},
|
||||
create: {
|
||||
employee_id: employeeId,
|
||||
external_user_id: externalUserId,
|
||||
created_by: actorId,
|
||||
updated_by: actorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||
status: "success",
|
||||
responseSummary: {
|
||||
...summary,
|
||||
operation: normalizedOperation,
|
||||
externalUserId,
|
||||
phoneMasked: maskPhone(employee.phone),
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
status: "success",
|
||||
code: "wecom_contact_synced",
|
||||
message: null,
|
||||
run: mapWecomDirectorySyncRun(updatedRun),
|
||||
};
|
||||
} catch (error) {
|
||||
const updatedRun = await finishSyncRun(prisma, run.id, {
|
||||
status: "failed",
|
||||
errorCode: error?.name === "AbortError" ? "wecom_contact_timeout" : "wecom_contact_network_error",
|
||||
errorMessage: error?.message ?? "WeCom contact network error",
|
||||
responseSummary: null,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
status: "failed",
|
||||
code: updatedRun.error_code,
|
||||
message: updatedRun.error_message,
|
||||
run: mapWecomDirectorySyncRun(updatedRun),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getLatestWecomDirectorySync(prisma, employeeId) {
|
||||
const [binding, latestRuns] = await Promise.all([
|
||||
prisma.wecomIdentityBinding.findUnique({
|
||||
where: { employee_id: employeeId },
|
||||
select: {
|
||||
id: true,
|
||||
external_user_id: true,
|
||||
external_open_user_id: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
}),
|
||||
prisma.wecomDirectorySyncRun.findMany({
|
||||
where: { employee_id: employeeId },
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
return mapWecomDirectoryState({ binding, runs: latestRuns });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deriveExternalUserId,
|
||||
getLatestWecomDirectorySync,
|
||||
mapWecomDirectoryState,
|
||||
mapWecomDirectorySyncRun,
|
||||
normalizeOperation,
|
||||
syncWecomDirectoryForEmployee,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户