WHMS 实现企微HR主数据镜像同步
这个提交包含在:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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: "待办工作台",
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户