支持企微通讯录缺失手机号(07-26-internal-ops-recovery-baseline)

这个提交包含在:
2026-07-27 13:05:27 +08:00
父节点 9875bc37a4
当前提交 2526052e73
共修改 24 个文件,包含 845 行新增和 63 行删除
@@ -219,6 +219,33 @@ describe("users import routes", () => {
await app.close();
});
it("accepts an import without a phone header and persists a missing phone as null", async () => {
const { app, employeeCreate } = await buildApp({
departments: [{ id: "dept-1", name: "行政部", archived_at: null }],
});
const multipartRequest = buildMultipartRequest({
filename: "users.csv",
contentType: "text/csv",
content: [
"employeeNo,name,email,departmentName,title,roles,hireDate",
"E006,NoPhone,[email protected],行政部,员工,employee,2026-07-27",
].join("\n"),
});
const response = await app.inject({
method: "POST",
url: "/api/users/import/apply",
...multipartRequest,
});
expect(response.statusCode).toBe(200);
expect(employeeCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ phone: null }),
select: expect.any(Object),
});
await app.close();
});
it("rejects dry-run uploads with unsupported mime type", async () => {
const { app } = await buildApp();
const multipartRequest = buildMultipartRequest({
+60 -5
查看文件
@@ -62,7 +62,27 @@ async function buildApp({
roles: [{ role_code: "employee" }],
},
);
const employeeUpdate = vi.fn();
const employeeUpdate = vi.fn(async ({ data }) => ({
...(currentEmployee ?? {
id: USER_ID,
employee_no: "HYD000321",
name: "Alice",
phone: "13800000000",
email: "[email protected]",
status: "active",
department_id: DEPARTMENT_ID,
title: "员工",
direct_manager_id: null,
hire_date: new Date("2026-05-01T00:00:00.000Z"),
archived_at: null,
created_at: new Date("2026-05-01T00:00:00.000Z"),
updated_at: new Date("2026-05-01T00:00:00.000Z"),
roles: [{ role_code: "employee" }],
wecom_identity_binding: null,
wecom_directory_sync_runs: [],
}),
...data,
}));
const employeeRoleCreateMany = vi.fn().mockResolvedValue({ count: 1 });
const employeeRoleFindMany = vi.fn().mockResolvedValue(currentRoles.map((role_code) => ({ role_code })));
const employeeRoleDeleteMany = vi.fn().mockResolvedValue({ count: currentRoles.length });
@@ -139,10 +159,6 @@ async function buildApp({
version: 1,
}),
},
wecomDirectorySyncRun: {
create: wecomSyncRunCreate,
},
wecomIdentityBinding: {},
$transaction: vi.fn(async (callback) => callback(tx)),
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
@@ -243,6 +259,45 @@ describe("usersRoutes write flows", () => {
await app.close();
});
it("creates a user with a null phone when the field is omitted", async () => {
const { app, employeeCreate } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/users",
payload: {
name: "暂缺手机号成员",
email: "[email protected]",
roles: ["employee"],
syncWecomDirectory: false,
},
});
expect(response.statusCode).toBe(200);
expect(employeeCreate).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ phone: null }),
}));
await app.close();
});
it("allows an administrator to explicitly clear an existing phone to null", async () => {
const { app, employeeUpdate } = await buildApp();
const response = await app.inject({
method: "PATCH",
url: `/api/users/${USER_ID}`,
payload: { phone: null },
});
expect(response.statusCode).toBe(200);
expect(employeeUpdate).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ phone: null }),
}));
await app.close();
});
it("rejects employeeNo changes on patch", async () => {
const { app, employeeFindUnique, employeeUpdate } = await buildApp();
@@ -0,0 +1,169 @@
const ExcelJS = require("exceljs");
const {
DirectoryExportError,
buildSafeReport,
parseArgs,
parseDirectoryExport,
} = require("../../../src/scripts/import-wecom-directory-export");
async function buildDirectoryWorkbook(rows) {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("通讯录");
worksheet.addRow(["填写须知:本行为企微导出说明"]);
worksheet.addRow([]);
worksheet.addRow([
"姓名", "账号", "别名", "职务", "部门", "性别", "手机", "座机",
"邮箱", "地址", "企业邮箱", "视频号", "激活状态", "禁用状态", "微信插件",
]);
for (const row of rows) {
worksheet.addRow([
row.name,
row.account,
"",
row.title ?? "员工",
row.department ?? "总部",
"",
row.phone ?? "",
"",
row.email ?? "",
"",
row.businessEmail,
"",
"已激活",
"",
"未关注",
]);
}
return Buffer.from(await workbook.xlsx.writeBuffer());
}
describe("WeCom directory export importer", () => {
it("requires stdin, the apply environment gate, and an actor for apply", () => {
const previous = process.env.WECOM_DIRECTORY_EXPORT_APPLY;
delete process.env.WECOM_DIRECTORY_EXPORT_APPLY;
try {
expect(() => parseArgs(["--input=directory.xlsx"])).toThrowError(
expect.objectContaining({ code: "stdin_required" }),
);
expect(() => parseArgs(["--apply", "--actor-employee-no=HYD000001"])).toThrowError(
expect.objectContaining({ code: "apply_confirmation_required" }),
);
process.env.WECOM_DIRECTORY_EXPORT_APPLY = "1";
expect(() => parseArgs(["--apply"])).toThrowError(
expect.objectContaining({ code: "actor_required" }),
);
expect(parseArgs(["--apply", "--actor-employee-no=HYD000001"])).toEqual(
expect.objectContaining({ input: "-", mode: "apply", actorEmployeeNo: "HYD000001" }),
);
} finally {
if (previous == null) delete process.env.WECOM_DIRECTORY_EXPORT_APPLY;
else process.env.WECOM_DIRECTORY_EXPORT_APPLY = previous;
}
});
it("finds the official Chinese header after instruction rows and accepts one missing phone", async () => {
const rows = Array.from({ length: 6 }, (_, index) => ({
name: `合成员${index + 1}`,
account: `member-${index + 1}`,
phone: index === 5 ? "" : `1990000000${index + 1}`,
businessEmail: `member-${index + 1}@example.test`,
}));
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook(rows));
expect(parsed.sourceSummary).toEqual({
memberCount: 6,
activeCount: 6,
phonePresentCount: 5,
phoneMissingCount: 1,
emailPresentCount: 6,
incompleteCount: 0,
});
expect(parsed.profiles[5].phone).toBeNull();
expect(parsed.profiles[5].missingRequiredFields).toEqual([]);
const report = buildSafeReport(
{ mode: "dry_run" },
parsed,
{ summary: { created: 5, updated: 1, planned: 6 } },
);
const serialized = JSON.stringify(report);
expect(report.status).toBe("PASS_DIRECTORY_EXPORT_DRY_RUN");
for (const row of rows) {
expect(serialized).not.toContain(row.name);
expect(serialized).not.toContain(row.account);
expect(serialized).not.toContain(row.businessEmail);
if (row.phone) expect(serialized).not.toContain(row.phone);
}
});
it.each([
["account", { account: "member-1", phone: "19900000002", businessEmail: "[email protected]" }],
["phone", { account: "member-2", phone: "19900000001", businessEmail: "[email protected]" }],
["email", { account: "member-2", phone: "19900000002", businessEmail: "[email protected]" }],
])("rejects duplicate %s values without returning the duplicated value", async (_field, duplicateRow) => {
const buffer = await buildDirectoryWorkbook([
{
name: "合成员一",
account: "member-1",
phone: "19900000001",
businessEmail: "[email protected]",
},
{ name: "合成员二", ...duplicateRow },
]);
let caught;
try {
await parseDirectoryExport(buffer);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(DirectoryExportError);
expect(caught.code).toBe("duplicate_values");
const serialized = JSON.stringify(caught);
expect(serialized).not.toContain("[email protected]");
expect(serialized).not.toContain("19900000001");
});
it("requires a valid enterprise email even when a personal email is present", async () => {
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{
name: "合成员一",
account: "member-1",
phone: "19900000001",
email: "[email protected]",
businessEmail: "",
}]));
expect(parsed.sourceSummary.incompleteCount).toBe(1);
expect(parsed.profiles[0].missingRequiredFields).toContain("email");
});
it("blocks a supplied invalid phone instead of treating it as an absent phone", async () => {
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{
name: "合成员一",
account: "member-1",
phone: "not-a-phone",
businessEmail: "[email protected]",
}]));
expect(parsed.sourceSummary.phoneMissingCount).toBe(1);
expect(parsed.sourceSummary.incompleteCount).toBe(1);
expect(parsed.profiles[0].missingRequiredFields).toContain("phone_invalid");
});
it("uses a distinct success status for an apply report", async () => {
const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{
name: "合成员一",
account: "member-1",
phone: "",
businessEmail: "[email protected]",
}]));
const report = buildSafeReport(
{ mode: "apply" },
parsed,
{ summary: { created: 1, success: 1 } },
);
expect(report.status).toBe("PASS_DIRECTORY_EXPORT_APPLY");
});
});
@@ -67,6 +67,38 @@ describe("wecom HR master sync helpers", () => {
expect(profile.missingRequiredFields).toContain("email");
});
it("accepts a profile with no phone and does not plan clearing an existing phone", () => {
const profile = normalizeWecomHrProfile({
contact: {
userid: "missing-phone",
name: "缺手机号成员",
biz_mail: "[email protected]",
department: [1],
enable: 1,
},
staff: {},
departmentMap: new Map([["1", { externalId: "1", name: "行政部" }]]),
});
expect(profile.phone).toBe("");
expect(profile.missingRequiredFields).toEqual([]);
const changes = __testables.buildEmployeeChanges(
{
id: "employee-1",
name: profile.name,
phone: "19900000009",
email: profile.email,
department_id: "department-1",
title: "员工",
wecom_identity_binding: { external_user_id: profile.externalUserId },
},
profile,
{ id: "department-1", name: "行政部" },
);
expect(changes).not.toHaveProperty("phone");
});
it("marks inactive WeCom members as confirmation-required instead of active", () => {
const profile = normalizeWecomHrProfile({
contact: {
@@ -101,6 +101,20 @@ describe("wecom OAuth onboarding service", () => {
});
expect(profile.email).toBe("[email protected]");
const profileWithoutPhone = await fetchWecomOAuthOnboardingProfile({
accessToken: "agent-token",
externalUserId: "member-fields",
userTicket: "ticket",
fetchImpl: buildFetch({
errcode: 0,
userid: "member-fields",
mobile: "",
biz_mail: "[email protected]",
}),
});
expect(profileWithoutPhone.phone).toBe("");
expect(profileWithoutPhone.email).toBe("[email protected]");
await expect(fetchWecomOAuthOnboardingProfile({
accessToken: "agent-token",
externalUserId: "member-fields",
@@ -238,6 +252,52 @@ describe("wecom OAuth onboarding service", () => {
expect(persisted).not.toContain("[email protected]");
});
it("creates a member with a null phone when the authorized profile has no phone", async () => {
const tx = {
wecomIdentityBinding: {
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn().mockResolvedValue({ id: "binding-1" }),
},
employee: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: "employee-1", token_version: 0 }),
},
integrationBinding: {
findFirst: vi.fn().mockResolvedValue({ local_id: "department-1" }),
},
department: {
findUnique: vi.fn().mockResolvedValue({ id: "department-1", archived_at: null }),
},
employeeRole: { create: vi.fn().mockResolvedValue({}) },
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) };
await provisionWecomOAuthEmployee({
prisma,
request: buildRequest(),
profile: {
externalUserId: "member-no-phone",
name: "缺手机号成员",
phone: "",
email: "[email protected]",
departmentExternalId: "1",
departmentName: "总部",
title: "员工",
},
});
expect(tx.employee.create).toHaveBeenCalledWith({
data: expect.objectContaining({ phone: null }),
select: expect.any(Object),
});
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
extra: expect.objectContaining({ fieldPresence: { phone: false, email: true } }),
}),
});
});
it("rejects contact data that resolves to multiple OA employees", async () => {
const tx = {
wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null) },