HRWD-0..9 实现人事生命周期企微通讯录同步

这个提交包含在:
2026-05-17 21:01:28 +08:00
父节点 778132760f
当前提交 9ae42dadc1
共修改 24 个文件,包含 1553 行新增和 123 行删除
+3
查看文件
@@ -62,6 +62,9 @@ function getConfig() {
wecomAgentSecret: process.env.WECOM_AGENT_SECRET ?? "",
wecomCheckinSyncEnabled: process.env.WECOM_CHECKIN_SYNC_ENABLED === "true",
wecomCheckinSecret: process.env.WECOM_CHECKIN_SECRET ?? "",
wecomContactSyncEnabled: process.env.WECOM_CONTACT_SYNC_ENABLED === "true",
wecomContactSecret: process.env.WECOM_CONTACT_SECRET ?? "",
wecomContactDefaultDepartmentId: process.env.WECOM_CONTACT_DEFAULT_DEPARTMENT_ID ?? "",
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
+177 -4
查看文件
@@ -12,6 +12,13 @@ const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
getLatestWecomDirectorySync,
mapWecomDirectoryState,
mapWecomDirectorySyncRun,
normalizeOperation,
syncWecomDirectoryForEmployee,
} = require("../services/integrations/wecomDirectory");
const { listOrganizationDepartments } = require("../services/organization");
const { StorageNotFoundError, openReadStream, parseStoragePath, writeBuffer } = require("../services/storage");
@@ -71,6 +78,10 @@ const OFFBOARDING_SCHEMA = z.object({
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
const WECOM_DIRECTORY_RETRY_SCHEMA = z.object({
operation: z.enum(["create", "update", "disable"]).optional(),
}).strict();
const CONTRACT_CREATE_SCHEMA = z.object({
contractNo: z.string().trim().min(1).max(100),
contractType: CONTRACT_TYPE_SCHEMA,
@@ -155,6 +166,50 @@ function canManageLifecycle(fastify, request) {
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*");
}
function canUseWecomDirectorySync(fastify) {
return Boolean(
fastify?.prisma?.wecomDirectorySyncRun
&& fastify?.prisma?.wecomIdentityBinding
&& fastify?.prisma?.featureFlag,
);
}
async function trySyncWecomDirectory(fastify, request, options) {
if (!canUseWecomDirectorySync(fastify)) {
return null;
}
const operation = normalizeOperation(options.operation);
const result = await syncWecomDirectoryForEmployee({
prisma: fastify.prisma,
config: fastify.config,
employeeId: options.employeeId,
actorId: request.employee.id,
operation,
});
if (fastify.prisma.auditLog?.create) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: options.auditAction ?? `WECOM_DIRECTORY_${operation.toUpperCase()}`,
targetType: "employee",
targetId: options.employeeId,
extra: {
operation,
status: result.status,
code: result.code,
skipped: Boolean(result.skipped),
syncRunId: result.run?.id ?? null,
externalUserId: result.run?.externalUserId ?? null,
},
}),
});
}
return result;
}
function canUploadAvatar(fastify, request, employeeId) {
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users");
}
@@ -535,6 +590,13 @@ async function buildEmployeeProfile(prisma, employeeId) {
roles: { select: { role_code: true } },
department: { select: { id: true, name: true } },
direct_manager: { select: { id: true, employee_no: true, name: true, avatar_path: true } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
},
}),
prisma.employeeChecklistTask.findMany({ where: { employee_id: employeeId }, orderBy: [{ created_at: "desc" }] }),
@@ -584,6 +646,7 @@ async function buildEmployeeProfile(prisma, employeeId) {
avatarUrl: buildAvatarUrl(employee.direct_manager.id, employee.direct_manager.avatar_path),
}
: null,
wecomDirectory: mapWecomDirectoryState(employee),
},
lifecycle: {
events: lifecycleEvents.map(mapLifecycleEvent),
@@ -745,6 +808,90 @@ async function employeesRoutes(fastify) {
return sendData(reply, request, stats);
});
fastify.get("/employees/:id/wecom-directory-sync", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
if (!canUseWecomDirectorySync(fastify)) {
throw buildValidationError({ featureFlag: "hr_wecom_directory_sync_v1_unavailable" }, 409);
}
const employeeId = parseId(request.params?.id);
const employee = await fastify.prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
},
});
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const runs = await fastify.prisma.wecomDirectorySyncRun.findMany({
where: { employee_id: employeeId },
orderBy: { created_at: "desc" },
take: 20,
});
return sendData(reply, request, {
employeeId,
wecomDirectory: mapWecomDirectoryState({ binding: employee.wecom_identity_binding, runs }),
items: runs.map(mapWecomDirectorySyncRun),
});
});
fastify.post("/employees/:id/wecom-directory-sync/retry", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
if (!canUseWecomDirectorySync(fastify)) {
throw buildValidationError({ featureFlag: "hr_wecom_directory_sync_v1_unavailable" }, 409);
}
const employeeId = parseId(request.params?.id);
const body = parseBody(WECOM_DIRECTORY_RETRY_SCHEMA, request.body);
const employee = await fastify.prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
status: true,
employment_status: true,
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
},
});
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const operation = body.operation
?? (employee.status === "archived" || employee.employment_status === "inactive"
? "disable"
: employee.wecom_identity_binding?.external_user_id
? "update"
: "create");
const result = await trySyncWecomDirectory(fastify, request, {
employeeId,
operation,
auditAction: "WECOM_DIRECTORY_RETRY",
});
const state = await getLatestWecomDirectorySync(fastify.prisma, employeeId);
return sendData(reply, request, {
employeeId,
operation,
result,
wecomDirectory: state,
});
});
fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
@@ -1176,8 +1323,18 @@ async function employeesRoutes(fastify) {
updated_by: request.employee.id,
},
});
const shouldSyncDirectory =
managerMode
&& (
Object.prototype.hasOwnProperty.call(body, "departmentId")
|| Object.prototype.hasOwnProperty.call(body, "directManagerId")
|| Object.prototype.hasOwnProperty.call(body, "title")
);
const directorySync = shouldSyncDirectory
? await trySyncWecomDirectory(fastify, request, { employeeId, operation: "update" })
: null;
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
return sendData(reply, request, { ...profile, wecomDirectorySync: directorySync });
});
fastify.post("/employees/:id/onboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -1211,8 +1368,12 @@ async function employeesRoutes(fastify) {
const checklist = buildDefaultChecklist("onboarding", employeeId).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null }));
await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false });
});
const directorySync = await trySyncWecomDirectory(fastify, request, {
employeeId,
operation: "create",
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
return sendData(reply, request, { ...profile, wecomDirectorySync: directorySync });
});
fastify.post("/employees/:id/regularize", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -1258,8 +1419,12 @@ async function employeesRoutes(fastify) {
notes: body.notes,
});
});
const directorySync = await trySyncWecomDirectory(fastify, request, {
employeeId,
operation: "update",
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
return sendData(reply, request, { ...profile, wecomDirectorySync: directorySync });
});
fastify.post("/employees/:id/offboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -1341,8 +1506,16 @@ async function employeesRoutes(fastify) {
});
}
const directorySync = result.completedOffboarding
? await trySyncWecomDirectory(fastify, request, { employeeId, operation: "disable" })
: null;
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, { ...profile.lifecycle, autoAdvanced: result.autoChanged });
return sendData(reply, request, {
...profile.lifecycle,
autoAdvanced: result.autoChanged,
wecomDirectory: profile.employee.wecomDirectory,
wecomDirectorySync: directorySync,
});
}
fastify.post("/employees/:id/checklist-tasks/:taskId/complete", { preHandler: fastify.authenticate }, async (request, reply) => {
+9
查看文件
@@ -268,6 +268,15 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({
routes: ["/api/employees", "/api/performance/cycles", "/api/performance/reviews"],
},
],
hr_wecom_directory_sync_v1: [
{
label: "企微通讯录同步",
routes: [
"/api/employees/:id/wecom-directory-sync",
"/api/employees/:id/wecom-directory-sync/retry",
],
},
],
todo_board_v1: [
{
label: "待办工作台",
+158 -2
查看文件
@@ -12,6 +12,10 @@ const {
} = require("../services/employeeDirectoryFilters");
const { applyEmployeeTransfer } = require("../services/employeeLifecycle");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
mapWecomDirectoryState,
syncWecomDirectoryForEmployee,
} = require("../services/integrations/wecomDirectory");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
@@ -53,6 +57,7 @@ const createBodySchema = z.object({
hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
roles: z.array(roleCodeSchema).min(1).optional(),
password: z.string().trim().min(1).optional(),
syncWecomDirectory: z.boolean().optional(),
});
const updateBodySchema = z.object({
@@ -218,6 +223,7 @@ function mapEmployeeSummary(row) {
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
roleCodes: row.roles.map((item) => item.role_code),
wecomDirectory: mapWecomDirectoryState(row),
createdAt: row.created_at?.toISOString?.() ?? null,
updatedAt: row.updated_at?.toISOString?.() ?? null,
};
@@ -445,6 +451,48 @@ async function setEmployeeAccountStatus(tx, request, options) {
return updated;
}
function canUseWecomDirectorySync(fastify) {
return Boolean(
fastify?.prisma?.wecomDirectorySyncRun
&& fastify?.prisma?.wecomIdentityBinding
&& fastify?.prisma?.featureFlag,
);
}
async function trySyncWecomDirectory(fastify, request, options) {
if (!canUseWecomDirectorySync(fastify)) {
return null;
}
const result = await syncWecomDirectoryForEmployee({
prisma: fastify.prisma,
config: fastify.config,
employeeId: options.employeeId,
actorId: request.employee.id,
operation: options.operation,
});
if (fastify.prisma.auditLog?.create) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: `WECOM_DIRECTORY_${String(options.operation ?? "update").toUpperCase()}`,
targetType: "employee",
targetId: options.employeeId,
extra: {
status: result.status,
code: result.code,
skipped: Boolean(result.skipped),
syncRunId: result.run?.id ?? null,
externalUserId: result.run?.externalUserId ?? null,
},
}),
});
}
return result;
}
function generateOneTimePassword() {
const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`;
const errors = validatePassword(password);
@@ -978,6 +1026,13 @@ async function usersRoutes(fastify, opts = {}) {
department: { select: { id: true, name: true } },
direct_manager: { select: { id: true, employee_no: true, name: true } },
roles: { select: { role_code: true } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
},
}),
fastify.prisma.employee.count({ where }),
@@ -1033,6 +1088,13 @@ async function usersRoutes(fastify, opts = {}) {
created_at: true,
updated_at: true,
roles: { select: { role_code: true } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
},
});
@@ -1126,10 +1188,18 @@ async function usersRoutes(fastify, opts = {}) {
where: { employee_id: created.employee.id },
select: { role_code: true },
});
const directorySync =
body.syncWecomDirectory === false
? null
: await trySyncWecomDirectory(fastify, request, {
employeeId: created.employee.id,
operation: "create",
});
return sendData(reply, request, {
...mapEmployeeSummary({ ...created.employee, roles }),
oneTimePassword: created.oneTimePassword,
wecomDirectorySync: directorySync,
});
},
);
@@ -1173,6 +1243,10 @@ async function usersRoutes(fastify, opts = {}) {
});
});
for (const employeeId of body.employeeIds) {
await trySyncWecomDirectory(fastify, request, { employeeId, operation: "update" });
}
return sendData(reply, request, {
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
employeeIds: body.employeeIds,
@@ -1221,6 +1295,10 @@ async function usersRoutes(fastify, opts = {}) {
});
});
for (const employeeId of body.employeeIds) {
await trySyncWecomDirectory(fastify, request, { employeeId, operation: "update" });
}
return sendData(reply, request, {
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
employeeIds: body.employeeIds,
@@ -1268,6 +1346,10 @@ async function usersRoutes(fastify, opts = {}) {
});
});
for (const employeeId of body.employeeIds) {
await trySyncWecomDirectory(fastify, request, { employeeId, operation: "disable" });
}
return sendData(reply, request, {
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
employeeIds: body.employeeIds,
@@ -1312,6 +1394,10 @@ async function usersRoutes(fastify, opts = {}) {
});
});
for (const employeeId of body.employeeIds) {
await trySyncWecomDirectory(fastify, request, { employeeId, operation: "update" });
}
return sendData(reply, request, {
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
employeeIds: body.employeeIds,
@@ -1459,7 +1545,21 @@ async function usersRoutes(fastify, opts = {}) {
throw error;
}
return sendData(reply, request, result);
const wecomDirectorySync = [];
for (const item of result.imported) {
const syncResult = await trySyncWecomDirectory(fastify, request, {
employeeId: item.employeeId,
operation: "create",
});
if (syncResult) {
wecomDirectorySync.push(syncResult);
}
}
return sendData(reply, request, {
...result,
wecomDirectorySync,
});
},
);
}
@@ -1520,6 +1620,13 @@ async function usersRoutes(fastify, opts = {}) {
created_at: true,
updated_at: true,
roles: { select: { role_code: true } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
},
});
if (!current) {
@@ -1589,6 +1696,13 @@ async function usersRoutes(fastify, opts = {}) {
created_at: true,
updated_at: true,
roles: { select: { role_code: true } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
},
});
@@ -1618,10 +1732,23 @@ async function usersRoutes(fastify, opts = {}) {
throw error;
}
const shouldSyncDirectory =
Object.prototype.hasOwnProperty.call(body, "name")
|| Object.prototype.hasOwnProperty.call(body, "phone")
|| Object.prototype.hasOwnProperty.call(body, "email")
|| Object.prototype.hasOwnProperty.call(body, "departmentId")
|| Object.prototype.hasOwnProperty.call(body, "title");
const directorySync = shouldSyncDirectory
? await trySyncWecomDirectory(fastify, request, { employeeId: userId, operation: "update" })
: null;
return sendData(
reply,
request,
mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
{
...mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
wecomDirectorySync: directorySync,
},
);
},
);
@@ -1718,6 +1845,13 @@ async function usersRoutes(fastify, opts = {}) {
created_at: true,
updated_at: true,
roles: { select: { role_code: true } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
},
});
if (!current) {
@@ -1783,6 +1917,13 @@ async function usersRoutes(fastify, opts = {}) {
created_at: true,
updated_at: true,
roles: { select: { role_code: true } },
wecom_identity_binding: {
select: { external_user_id: true, external_open_user_id: true },
},
wecom_directory_sync_runs: {
orderBy: { created_at: "desc" },
take: 1,
},
},
});
@@ -1870,10 +2011,15 @@ async function usersRoutes(fastify, opts = {}) {
return updated;
});
const directorySync = await trySyncWecomDirectory(fastify, request, {
employeeId: userId,
operation: "disable",
});
return sendData(reply, request, {
id: employee.id,
status: employee.status,
wecomDirectorySync: directorySync,
});
},
);
@@ -1903,10 +2049,15 @@ async function usersRoutes(fastify, opts = {}) {
now,
});
});
const directorySync = await trySyncWecomDirectory(fastify, request, {
employeeId: userId,
operation: "disable",
});
return sendData(reply, request, {
id: employee.id,
status: employee.status,
wecomDirectorySync: directorySync,
});
},
);
@@ -1928,10 +2079,15 @@ async function usersRoutes(fastify, opts = {}) {
now,
});
});
const directorySync = await trySyncWecomDirectory(fastify, request, {
employeeId: userId,
operation: "update",
});
return sendData(reply, request, {
id: employee.id,
status: employee.status,
wecomDirectorySync: directorySync,
});
},
);
+14 -11
查看文件
@@ -317,6 +317,13 @@ const DEFAULT_FEATURE_FLAGS = [
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "hr_wecom_directory_sync_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "todo_board_v1",
override_mode: "force_off",
@@ -588,17 +595,8 @@ async function seedFeatureFlags(prisma) {
const now = new Date();
for (const item of DEFAULT_FEATURE_FLAGS) {
await prisma.featureFlag.upsert({
where: { flag_key: item.flag_key },
update: {
override_mode: item.override_mode,
rollout_type: item.rollout_type,
department_ids: item.department_ids,
note: item.note,
updated_at: now,
updated_by: null,
},
create: {
await prisma.featureFlag.create({
data: {
flag_key: item.flag_key,
override_mode: item.override_mode,
rollout_type: item.rollout_type,
@@ -609,6 +607,11 @@ async function seedFeatureFlags(prisma) {
updated_at: now,
updated_by: null,
},
}).catch((error) => {
if (error?.code === "P2002") {
return null;
}
throw error;
});
}
}
@@ -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,
};