WHMS 实现企微HR主数据镜像同步

这个提交包含在:
2026-05-18 19:19:07 +08:00
父节点 3a6bfa0eaa
当前提交 27968fba49
共修改 24 个文件,包含 2123 行新增和 39 行删除
@@ -0,0 +1,86 @@
CREATE TABLE "wecom_hr_master_sync_runs" (
"id" UUID NOT NULL,
"mode" VARCHAR(16) NOT NULL,
"status" VARCHAR(24) NOT NULL DEFAULT 'running',
"token_source" JSONB,
"requested_userids" JSONB,
"limit" INTEGER,
"summary" JSONB,
"error_code" VARCHAR(80),
"error_message" TEXT,
"triggered_by" UUID,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_hr_master_sync_runs_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "wecom_hr_master_sync_items" (
"id" UUID NOT NULL,
"run_id" UUID NOT NULL,
"employee_id" UUID,
"external_user_id_masked" VARCHAR(80) NOT NULL,
"action" VARCHAR(32) NOT NULL,
"status" VARCHAR(24) NOT NULL,
"error_code" VARCHAR(80),
"error_message" TEXT,
"retry_count" INTEGER NOT NULL DEFAULT 0,
"field_changes" JSONB,
"source_summary" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_hr_master_sync_items_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "idx_wecom_hr_master_sync_runs_status_created"
ON "wecom_hr_master_sync_runs"("status", "created_at");
CREATE INDEX "idx_wecom_hr_master_sync_runs_mode_created"
ON "wecom_hr_master_sync_runs"("mode", "created_at");
CREATE INDEX "idx_wecom_hr_master_sync_runs_triggered_by"
ON "wecom_hr_master_sync_runs"("triggered_by");
CREATE INDEX "idx_wecom_hr_master_sync_items_run_status"
ON "wecom_hr_master_sync_items"("run_id", "status");
CREATE INDEX "idx_wecom_hr_master_sync_items_employee_created"
ON "wecom_hr_master_sync_items"("employee_id", "created_at");
CREATE INDEX "idx_wecom_hr_master_sync_items_action_status"
ON "wecom_hr_master_sync_items"("action", "status");
ALTER TABLE "wecom_hr_master_sync_runs"
ADD CONSTRAINT "wecom_hr_master_sync_runs_triggered_by_fkey"
FOREIGN KEY ("triggered_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "wecom_hr_master_sync_items"
ADD CONSTRAINT "wecom_hr_master_sync_items_run_id_fkey"
FOREIGN KEY ("run_id") REFERENCES "wecom_hr_master_sync_runs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "wecom_hr_master_sync_items"
ADD CONSTRAINT "wecom_hr_master_sync_items_employee_id_fkey"
FOREIGN KEY ("employee_id") 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 (
'0e0a66a4-9e1d-4e23-9724-99e4a322b371'::uuid,
'wecom_hr_master_sync_v1',
'force_off',
'global',
'[]'::jsonb,
'bootstrap default: force_off',
1,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
) ON CONFLICT ("flag_key") DO NOTHING;
+53
查看文件
@@ -622,6 +622,8 @@ model Employee {
wecom_checkin_sync_runs WecomCheckinSyncRun[] @relation("WecomCheckinSyncRunTrigger")
wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunEmployee")
triggered_wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunTrigger")
wecom_hr_master_sync_items WecomHrMasterSyncItem[] @relation("WecomHrMasterSyncItemEmployee")
triggered_wecom_hr_master_sync_runs WecomHrMasterSyncRun[] @relation("WecomHrMasterSyncRunTrigger")
attendance_corrections AttendanceCorrection[]
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
@@ -2112,6 +2114,57 @@ model WecomIdentityBinding {
@@map("wecom_identity_bindings")
}
model WecomHrMasterSyncRun {
id String @id @default(uuid()) @db.Uuid
mode String @db.VarChar(16)
status String @default("running") @db.VarChar(24)
token_source Json?
requested_userids Json?
limit Int?
summary Json?
error_code String? @db.VarChar(80)
error_message String?
triggered_by String? @db.Uuid
started_at DateTime @default(now())
finished_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
trigger Employee? @relation("WecomHrMasterSyncRunTrigger", fields: [triggered_by], references: [id], onDelete: SetNull)
items WecomHrMasterSyncItem[]
@@index([status, created_at], map: "idx_wecom_hr_master_sync_runs_status_created")
@@index([mode, created_at], map: "idx_wecom_hr_master_sync_runs_mode_created")
@@index([triggered_by], map: "idx_wecom_hr_master_sync_runs_triggered_by")
@@map("wecom_hr_master_sync_runs")
}
model WecomHrMasterSyncItem {
id String @id @default(uuid()) @db.Uuid
run_id String @db.Uuid
employee_id String? @db.Uuid
external_user_id_masked String @db.VarChar(80)
action String @db.VarChar(32)
status String @db.VarChar(24)
error_code String? @db.VarChar(80)
error_message String?
retry_count Int @default(0)
field_changes Json?
source_summary Json?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
run WecomHrMasterSyncRun @relation(fields: [run_id], references: [id], onDelete: Cascade)
employee Employee? @relation("WecomHrMasterSyncItemEmployee", fields: [employee_id], references: [id], onDelete: SetNull)
@@index([run_id, status], map: "idx_wecom_hr_master_sync_items_run_status")
@@index([employee_id, created_at], map: "idx_wecom_hr_master_sync_items_employee_created")
@@index([action, status], map: "idx_wecom_hr_master_sync_items_action_status")
@@map("wecom_hr_master_sync_items")
}
model Project {
id String @id @default(uuid()) @db.Uuid
project_no String @unique
+2
查看文件
@@ -65,6 +65,8 @@ function getConfig() {
wecomContactSyncEnabled: process.env.WECOM_CONTACT_SYNC_ENABLED === "true",
wecomContactSecret: process.env.WECOM_CONTACT_SECRET ?? "",
wecomContactDefaultDepartmentId: process.env.WECOM_CONTACT_DEFAULT_DEPARTMENT_ID ?? "",
wecomHrMasterSyncEnabled: process.env.WECOM_HR_MASTER_SYNC_ENABLED === "true",
wecomHrAssistantSecret: process.env.WECOM_HR_ASSISTANT_SECRET ?? "",
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
+102
查看文件
@@ -19,6 +19,11 @@ const {
normalizeOperation,
syncWecomDirectoryForEmployee,
} = require("../services/integrations/wecomDirectory");
const {
getWecomHrMasterSyncRun,
listWecomHrMasterSyncRuns,
runWecomHrMasterSync,
} = require("../services/integrations/wecomHrMaster");
const { listOrganizationDepartments } = require("../services/organization");
const { StorageNotFoundError, openReadStream, parseStoragePath, writeBuffer } = require("../services/storage");
@@ -82,6 +87,16 @@ const WECOM_DIRECTORY_RETRY_SCHEMA = z.object({
operation: z.enum(["create", "update", "disable"]).optional(),
}).strict();
const WECOM_HR_MASTER_SYNC_RUN_SCHEMA = z.object({
mode: z.enum(["dry_run", "apply"]).default("dry_run"),
userids: z.array(z.string().trim().min(1).max(128)).max(200).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
}).strict();
const WECOM_HR_MASTER_SYNC_LIST_QUERY_SCHEMA = z.object({
pageSize: z.coerce.number().int().min(1).max(50).optional(),
}).strict();
const CONTRACT_CREATE_SCHEMA = z.object({
contractNo: z.string().trim().min(1).max(100),
contractType: CONTRACT_TYPE_SCHEMA,
@@ -174,6 +189,32 @@ function canUseWecomDirectorySync(fastify) {
);
}
function canUseWecomHrMasterSync(fastify) {
return Boolean(
fastify?.prisma?.wecomHrMasterSyncRun
&& fastify?.prisma?.wecomHrMasterSyncItem
&& fastify?.prisma?.wecomIdentityBinding
&& fastify?.prisma?.integrationBinding
&& fastify?.prisma?.featureFlag,
);
}
async function ensureWecomHrMasterSyncEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.wecomHrMasterSync,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "wecom_hr_master_sync_v1_disabled", reason: gate.reason }, 409);
}
if (!canUseWecomHrMasterSync(fastify)) {
throw buildValidationError({ featureFlag: "wecom_hr_master_sync_v1_unavailable" }, 409);
}
}
async function trySyncWecomDirectory(fastify, request, options) {
if (!canUseWecomDirectorySync(fastify)) {
return null;
@@ -808,6 +849,67 @@ async function employeesRoutes(fastify) {
return sendData(reply, request, stats);
});
fastify.post("/employees/wecom-hr-master-sync/runs", { 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" } });
}
await ensureWecomHrMasterSyncEnabled(fastify, request);
const body = parseBody(WECOM_HR_MASTER_SYNC_RUN_SCHEMA, request.body);
const run = await runWecomHrMasterSync({
prisma: fastify.prisma,
config: fastify.config,
actorId: request.employee.id,
mode: body.mode,
userids: body.userids ?? [],
limit: body.limit,
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: body.mode === "apply" ? "WECOM_HR_MASTER_SYNC_APPLY" : "WECOM_HR_MASTER_SYNC_DRY_RUN",
targetType: "wecom_hr_master_sync_run",
targetId: run.id,
extra: {
mode: run.mode,
status: run.status,
summary: run.summary,
requestedUserids: run.requestedUserids,
tokenSource: run.tokenSource,
},
}),
});
return sendData(reply, request, run);
});
fastify.get("/employees/wecom-hr-master-sync/runs", { 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" } });
}
await ensureWecomHrMasterSyncEnabled(fastify, request);
const query = parseBody(WECOM_HR_MASTER_SYNC_LIST_QUERY_SCHEMA, request.query);
const items = await listWecomHrMasterSyncRuns(fastify.prisma, { pageSize: query.pageSize });
return sendData(reply, request, { items });
});
fastify.get("/employees/wecom-hr-master-sync/runs/:id", { 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" } });
}
await ensureWecomHrMasterSyncEnabled(fastify, request);
const runId = parseId(request.params?.id);
const run = await getWecomHrMasterSyncRun(fastify.prisma, runId);
if (!run) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return sendData(reply, request, run);
});
fastify.get("/employees/:id/wecom-directory-sync", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
+9
查看文件
@@ -277,6 +277,15 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({
],
},
],
wecom_hr_master_sync_v1: [
{
label: "企微 HR 主数据镜像",
routes: [
"/api/employees/wecom-hr-master-sync/runs",
"/api/employees/wecom-hr-master-sync/runs/:id",
],
},
],
todo_board_v1: [
{
label: "待办工作台",
+7
查看文件
@@ -324,6 +324,13 @@ const DEFAULT_FEATURE_FLAGS = [
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "wecom_hr_master_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",
@@ -16,6 +16,7 @@ const FEATURE_FLAG_KEYS = {
weeklyReport: "weekly_report_v1",
employeeLifecycle: "employee_lifecycle_v1",
hrWecomDirectorySync: "hr_wecom_directory_sync_v1",
wecomHrMasterSync: "wecom_hr_master_sync_v1",
todoBoard: "todo_board_v1",
wecomEntry: "wecom_entry_v1",
projectDomain: "project_domain_v1",
@@ -145,6 +146,10 @@ function resolveEnvGate(config, flagKey) {
return Boolean(config.wecomContactSyncEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.wecomHrMasterSync) {
return Boolean(config.wecomHrMasterSyncEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.todoBoard) {
return Boolean(config.todoBoardEnabled);
}
文件差异内容过多而无法显示 加载差异
@@ -0,0 +1,104 @@
const {
maskEmail,
maskExternalUserId,
maskPhone,
normalizeWecomHrProfile,
__testables,
} = require("../../src/services/integrations/wecomHrMaster");
describe("wecom HR master sync helpers", () => {
it("normalizes contact and HR assistant data without leaking full sensitive values in summary", () => {
const departmentMap = new Map([
["1", { externalId: "1", name: "研发部" }],
]);
const profile = normalizeWecomHrProfile({
departmentMap,
contact: {
userid: "oa_smoke002",
name: "测试员工",
mobile: "19912345678",
email: "[email protected]",
position: "测试工程师",
department: [1],
direct_leader: ["manager001"],
enable: 1,
},
staff: {
staff_info: [
{ fieldid: "入职日期", value: "2026-05-01" },
{ fieldid: "在职状态", value: "在职" },
],
},
});
expect(profile.externalUserId).toBe("oa_smoke002");
expect(profile.departmentName).toBe("研发部");
expect(profile.managerExternalUserId).toBe("manager001");
expect(profile.hireDate).toBe("2026-05-01");
expect(profile.sourceStatus).toBe("active");
expect(profile.missingRequiredFields).toEqual([]);
expect(JSON.stringify(profile.sourceSummary)).not.toContain("19912345678");
expect(JSON.stringify(profile.sourceSummary)).not.toContain("[email protected]");
expect(JSON.stringify(profile.sourceSummary)).not.toContain("oa_smoke002");
});
it("skips profiles missing OA-required source fields", () => {
const profile = normalizeWecomHrProfile({
contact: {
userid: "missing-email",
name: "缺邮箱",
mobile: "19900000001",
department: [1],
},
staff: {},
departmentMap: new Map([["1", { externalId: "1", name: "行政部" }]]),
});
expect(profile.missingRequiredFields).toContain("email");
});
it("marks inactive WeCom members as confirmation-required instead of active", () => {
const profile = normalizeWecomHrProfile({
contact: {
userid: "inactive001",
name: "离职测试",
mobile: "19900000002",
email: "[email protected]",
department: [1],
enable: 0,
},
staff: {},
departmentMap: new Map([["1", { externalId: "1", name: "行政部" }]]),
});
expect(profile.sourceStatus).toBe("inactive");
});
it("builds masked field changes for contact fields", () => {
const changes = __testables.buildEmployeeChanges(
{
id: "employee-1",
name: "旧员工",
phone: "19911112222",
email: "[email protected]",
department_id: "dept-old",
title: "旧岗位",
wecom_identity_binding: null,
},
{
externalUserId: "oa_smoke002",
name: "新员工",
phone: "19933334444",
email: "[email protected]",
title: "新岗位",
},
{ id: "dept-new", name: "研发部" },
);
expect(changes.phone.before).toBe(maskPhone("19911112222"));
expect(changes.phone.after).toBe(maskPhone("19933334444"));
expect(changes.email.before).toBe(maskEmail("[email protected]"));
expect(changes.externalUserId.after).toBe(maskExternalUserId("oa_smoke002"));
});
});