HRWD-0..9 实现人事生命周期企微通讯录同步
这个提交包含在:
@@ -55,6 +55,9 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
|
||||
# WECOM_AGENT_SECRET=""
|
||||
# WECOM_CHECKIN_SYNC_ENABLED="false"
|
||||
# WECOM_CHECKIN_SECRET=""
|
||||
# WECOM_CONTACT_SYNC_ENABLED="false"
|
||||
# WECOM_CONTACT_SECRET=""
|
||||
# WECOM_CONTACT_DEFAULT_DEPARTMENT_ID=""
|
||||
|
||||
DELEGATION_REVOKE_CRON="0 * * * *"
|
||||
|
||||
@@ -124,4 +127,5 @@ PRESENCE_BOARD_ENABLED=false
|
||||
ATTENDANCE_UNIFIED_ENABLED=false
|
||||
WEEKLY_REPORT_ENABLED=false
|
||||
EMPLOYEE_LIFECYCLE_ENABLED=false
|
||||
WECOM_CONTACT_SYNC_ENABLED=false
|
||||
TODO_BOARD_ENABLED=false
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
CREATE TABLE "wecom_directory_sync_runs" (
|
||||
"id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"external_user_id" VARCHAR(128),
|
||||
"operation" VARCHAR(32) NOT NULL,
|
||||
"status" VARCHAR(24) NOT NULL DEFAULT 'running',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 1,
|
||||
"error_code" VARCHAR(80),
|
||||
"error_message" TEXT,
|
||||
"response_summary" JSONB,
|
||||
"triggered_by" UUID,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "wecom_directory_sync_runs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "idx_wecom_directory_sync_runs_employee_created"
|
||||
ON "wecom_directory_sync_runs"("employee_id", "created_at");
|
||||
|
||||
CREATE INDEX "idx_wecom_directory_sync_runs_status_created"
|
||||
ON "wecom_directory_sync_runs"("status", "created_at");
|
||||
|
||||
CREATE INDEX "idx_wecom_directory_sync_runs_operation_created"
|
||||
ON "wecom_directory_sync_runs"("operation", "created_at");
|
||||
|
||||
CREATE INDEX "idx_wecom_directory_sync_runs_triggered_by"
|
||||
ON "wecom_directory_sync_runs"("triggered_by");
|
||||
|
||||
ALTER TABLE "wecom_directory_sync_runs"
|
||||
ADD CONSTRAINT "wecom_directory_sync_runs_employee_id_fkey"
|
||||
FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "wecom_directory_sync_runs"
|
||||
ADD CONSTRAINT "wecom_directory_sync_runs_triggered_by_fkey"
|
||||
FOREIGN KEY ("triggered_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "feature_flags" (
|
||||
"id",
|
||||
"flag_key",
|
||||
"override_mode",
|
||||
"rollout_type",
|
||||
"department_ids",
|
||||
"note",
|
||||
"version",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
) VALUES (
|
||||
'19d97df5-40a0-4dd3-a0b4-91a9d7e8e305'::uuid,
|
||||
'hr_wecom_directory_sync_v1',
|
||||
'force_off',
|
||||
'global',
|
||||
'[]'::jsonb,
|
||||
'bootstrap default: force_off',
|
||||
1,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
) ON CONFLICT ("flag_key") DO NOTHING;
|
||||
@@ -620,6 +620,8 @@ model Employee {
|
||||
leave_ledgers LeaveLedger[]
|
||||
wecom_checkin_records WecomCheckinRecord[]
|
||||
wecom_checkin_sync_runs WecomCheckinSyncRun[] @relation("WecomCheckinSyncRunTrigger")
|
||||
wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunEmployee")
|
||||
triggered_wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunTrigger")
|
||||
attendance_corrections AttendanceCorrection[]
|
||||
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
|
||||
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
|
||||
@@ -1991,6 +1993,31 @@ model WecomCheckinRecord {
|
||||
@@map("wecom_checkin_records")
|
||||
}
|
||||
|
||||
model WecomDirectorySyncRun {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
external_user_id String? @db.VarChar(128)
|
||||
operation String @db.VarChar(32)
|
||||
status String @default("running") @db.VarChar(24)
|
||||
attempts Int @default(1)
|
||||
error_code String? @db.VarChar(80)
|
||||
error_message String?
|
||||
response_summary Json?
|
||||
triggered_by String? @db.Uuid
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
employee Employee @relation("WecomDirectorySyncRunEmployee", fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
trigger Employee? @relation("WecomDirectorySyncRunTrigger", fields: [triggered_by], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([employee_id, created_at], map: "idx_wecom_directory_sync_runs_employee_created")
|
||||
@@index([status, created_at], map: "idx_wecom_directory_sync_runs_status_created")
|
||||
@@index([operation, created_at], map: "idx_wecom_directory_sync_runs_operation_created")
|
||||
@@index([triggered_by], map: "idx_wecom_directory_sync_runs_triggered_by")
|
||||
@@map("wecom_directory_sync_runs")
|
||||
}
|
||||
|
||||
model AttendanceCorrection {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
owner_id String @db.Uuid
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,328 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const employeesRoutes = require("../../../src/routes/employees");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const TASK_ID = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
function buildEmployeeRow(overrides = {}) {
|
||||
return {
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "HYD000888",
|
||||
name: "测试员工",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
employment_status: "active",
|
||||
department_id: DEPARTMENT_ID,
|
||||
title: "工程师",
|
||||
direct_manager_id: null,
|
||||
hire_date: new Date("2026-05-01T00:00:00.000Z"),
|
||||
probation_end_date: null,
|
||||
last_working_date: null,
|
||||
work_location: null,
|
||||
emergency_contact_name: null,
|
||||
emergency_contact_phone: null,
|
||||
notes: null,
|
||||
avatar_path: null,
|
||||
archived_at: null,
|
||||
roles: [{ role_code: "employee" }],
|
||||
department: { id: DEPARTMENT_ID, name: "研发部" },
|
||||
direct_manager: null,
|
||||
wecom_identity_binding: null,
|
||||
wecom_directory_sync_runs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFlagRecord(flagKey) {
|
||||
return {
|
||||
id: `${flagKey}-id`,
|
||||
flag_key: flagKey,
|
||||
override_mode: "force_on",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "test",
|
||||
version: 1,
|
||||
created_at: new Date("2026-05-17T00:00:00.000Z"),
|
||||
created_by: null,
|
||||
updated_at: new Date("2026-05-17T00:00:00.000Z"),
|
||||
updated_by: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWecomFetch() {
|
||||
return vi.fn(async (url) => {
|
||||
const target = String(url);
|
||||
if (target.includes("/cgi-bin/gettoken")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, errmsg: "ok", access_token: "mock-access-token", expires_in: 7200 }),
|
||||
};
|
||||
}
|
||||
if (target.includes("/cgi-bin/user/create") || target.includes("/cgi-bin/user/update")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, errmsg: "ok" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ errcode: 404, errmsg: "not found" }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function buildApp({
|
||||
actorPermissions = ["system.users"],
|
||||
config = {},
|
||||
employeeOverrides = {},
|
||||
initialTaskStatus = "pending",
|
||||
} = {}) {
|
||||
const app = Fastify();
|
||||
let employee = buildEmployeeRow(employeeOverrides);
|
||||
let latestRun = null;
|
||||
let taskStatus = initialTaskStatus;
|
||||
|
||||
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
||||
const bindingUpsert = vi.fn(async ({ create, update }) => {
|
||||
employee = {
|
||||
...employee,
|
||||
wecom_identity_binding: {
|
||||
id: "binding-1",
|
||||
external_user_id: update?.external_user_id ?? create.external_user_id,
|
||||
external_open_user_id: null,
|
||||
},
|
||||
};
|
||||
return employee.wecom_identity_binding;
|
||||
});
|
||||
const syncRunCreate = vi.fn(async ({ data }) => {
|
||||
latestRun = {
|
||||
id: "sync-run-1",
|
||||
employee_id: data.employee_id,
|
||||
external_user_id: data.external_user_id ?? null,
|
||||
operation: data.operation,
|
||||
status: data.status,
|
||||
attempts: data.attempts ?? 1,
|
||||
error_code: null,
|
||||
error_message: null,
|
||||
response_summary: null,
|
||||
triggered_by: data.triggered_by ?? null,
|
||||
created_at: new Date("2026-05-17T12:00:00.000Z"),
|
||||
updated_at: new Date("2026-05-17T12:00:00.000Z"),
|
||||
};
|
||||
return latestRun;
|
||||
});
|
||||
const syncRunUpdate = vi.fn(async ({ data }) => {
|
||||
latestRun = {
|
||||
...latestRun,
|
||||
status: data.status,
|
||||
error_code: data.error_code ?? null,
|
||||
error_message: data.error_message ?? null,
|
||||
response_summary: data.response_summary ?? null,
|
||||
updated_at: new Date("2026-05-17T12:01:00.000Z"),
|
||||
};
|
||||
employee = {
|
||||
...employee,
|
||||
wecom_directory_sync_runs: latestRun ? [latestRun] : [],
|
||||
};
|
||||
return latestRun;
|
||||
});
|
||||
|
||||
app.decorate("config", {
|
||||
employeeLifecycleEnabled: true,
|
||||
wecomContactSyncEnabled: true,
|
||||
wecomCorpId: "wx-test-corp",
|
||||
wecomContactSecret: "contact-secret",
|
||||
wecomContactDefaultDepartmentId: "1",
|
||||
...config,
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = {
|
||||
id: ACTOR_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: actorPermissions,
|
||||
};
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = {
|
||||
id: ACTOR_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: actorPermissions,
|
||||
};
|
||||
if (!actorPermissions.includes("*") && !actorPermissions.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("hasPermission", (_employee, permissionCode) => actorPermissions.includes("*") || actorPermissions.includes(permissionCode));
|
||||
app.decorate("prisma", {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn(async ({ where }) => buildFlagRecord(where.flag_key)),
|
||||
},
|
||||
employee: {
|
||||
findUnique: vi.fn(async ({ where }) => (where.id === EMPLOYEE_ID ? employee : null)),
|
||||
update: vi.fn(async ({ data }) => {
|
||||
employee = {
|
||||
...employee,
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.employment_status !== undefined ? { employment_status: data.employment_status } : {}),
|
||||
...(data.archived_at !== undefined ? { archived_at: data.archived_at } : {}),
|
||||
...(data.token_version !== undefined ? { token_version: 1 } : {}),
|
||||
};
|
||||
return employee;
|
||||
}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
employeeChecklistTask: {
|
||||
findFirst: vi.fn(async ({ where }) => (where.id === TASK_ID ? { id: TASK_ID, employee_id: EMPLOYEE_ID, status: taskStatus } : null)),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn(async ({ where }) => {
|
||||
if (where.employee_id === EMPLOYEE_ID && where.scene === "offboarding" && where.status === "pending") {
|
||||
return taskStatus === "pending" ? 0 : 0;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
update: vi.fn(async ({ data }) => {
|
||||
taskStatus = data.status;
|
||||
return { id: TASK_ID, employee_id: EMPLOYEE_ID, status: taskStatus };
|
||||
}),
|
||||
},
|
||||
employeeLifecycleEvent: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
assetCard: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
workflowTask: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
wecomIdentityBinding: {
|
||||
findUnique: vi.fn(async () => employee.wecom_identity_binding),
|
||||
upsert: bindingUpsert,
|
||||
},
|
||||
wecomDirectorySyncRun: {
|
||||
create: syncRunCreate,
|
||||
update: syncRunUpdate,
|
||||
findMany: vi.fn(async () => (latestRun ? [latestRun] : [])),
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => callback(app.prisma)),
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
requestId: request.id,
|
||||
error: { code: error.code, message: error.message, details: error.details ?? {} },
|
||||
});
|
||||
}
|
||||
return reply.status(500).send({
|
||||
requestId: request.id,
|
||||
error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} },
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(employeesRoutes, { prefix: "/api" });
|
||||
return { app, auditCreate, bindingUpsert, syncRunCreate, syncRunUpdate };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("employees WeCom directory sync", () => {
|
||||
it("allows HR admins to retry WeCom member creation and keeps a sanitized sync run", async () => {
|
||||
global.fetch = buildWecomFetch();
|
||||
const { app, bindingUpsert, syncRunUpdate } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(bindingUpsert).toHaveBeenCalledWith(expect.objectContaining({ where: { employee_id: EMPLOYEE_ID } }));
|
||||
expect(syncRunUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: "success",
|
||||
response_summary: expect.objectContaining({ phoneMasked: "138****0000" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(global.fetch.mock.calls.some((call) => String(call[0]).includes("/cgi-bin/user/create"))).toBe(true);
|
||||
expect(response.json().data.result.run.responseSummary.phoneMasked).toBe("138****0000");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("records SKIP when contact secret is missing and does not call WeCom", async () => {
|
||||
global.fetch = buildWecomFetch();
|
||||
const { app } = await buildApp({ config: { wecomContactSecret: "" } });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`,
|
||||
payload: { operation: "create" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.result).toEqual(
|
||||
expect.objectContaining({
|
||||
skipped: true,
|
||||
status: "skipped",
|
||||
code: "wecom_contact_config_missing",
|
||||
}),
|
||||
);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects non-HR users from reading WeCom directory sync state", async () => {
|
||||
const { app } = await buildApp({ actorPermissions: ["expense.view_own"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/employees/${EMPLOYEE_ID}/wecom-directory-sync`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("disables the WeCom member when offboarding checklist completion archives the OA account", async () => {
|
||||
global.fetch = buildWecomFetch();
|
||||
const { app } = await buildApp({
|
||||
employeeOverrides: {
|
||||
employment_status: "offboarding",
|
||||
wecom_identity_binding: { external_user_id: "HYD000888", external_open_user_id: null },
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/employees/${EMPLOYEE_ID}/checklist-tasks/${TASK_ID}/complete`,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(global.fetch.mock.calls.some((call) => String(call[0]).includes("/cgi-bin/user/update"))).toBe(true);
|
||||
const updateCall = global.fetch.mock.calls.find((call) => String(call[0]).includes("/cgi-bin/user/update"));
|
||||
expect(JSON.parse(updateCall[1].body)).toEqual(expect.objectContaining({ userid: "HYD000888", enable: 0 }));
|
||||
expect(response.json().data.wecomDirectorySync).toEqual(expect.objectContaining({ status: "success" }));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户