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

这个提交包含在:
2026-07-27 13:05:27 +08:00
父节点 9875bc37a4
当前提交 2526052e73
共修改 24 个文件,包含 845 行新增和 63 行删除
+19 -14
查看文件
@@ -58,10 +58,15 @@ const normalizedEmailSchema = z
.max(200)
.transform((value) => value.toLowerCase());
const nullablePhoneSchema = z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? null : value),
z.string().trim().min(6).max(30).nullable().optional(),
);
const createBodySchema = z.object({
employeeNo: z.string().trim().min(1).max(50).optional(),
name: z.string().min(1).max(100),
phone: z.string().min(6).max(30),
phone: nullablePhoneSchema,
email: normalizedEmailSchema,
departmentId: z.string().uuid().optional(),
title: z.string().min(1).max(100).optional(),
@@ -74,7 +79,7 @@ const createBodySchema = z.object({
const updateBodySchema = z.object({
name: z.string().trim().min(1).max(100).optional(),
phone: z.string().trim().min(6).max(30).optional(),
phone: nullablePhoneSchema,
email: normalizedEmailSchema.optional(),
departmentId: z.string().uuid().optional(),
title: z.string().trim().min(1).max(100).optional(),
@@ -98,8 +103,8 @@ const sensitiveBodySchema = z
})
.strict();
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "phone", "email", "departmentName"];
const USER_IMPORT_OPTIONAL_HEADERS = ["title", "directManagerEmployeeNo", "roles", "hireDate"];
const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "email", "departmentName"];
const USER_IMPORT_OPTIONAL_HEADERS = ["phone", "title", "directManagerEmployeeNo", "roles", "hireDate"];
const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]);
const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([
"text/csv",
@@ -773,11 +778,9 @@ function validateImportRowFields(row) {
pushImportRowError(row, "name", "too_long");
}
if (!normalized.phone) {
pushImportRowError(row, "phone", "required");
} else if (normalized.phone.length < 6) {
if (normalized.phone && normalized.phone.length < 6) {
pushImportRowError(row, "phone", "too_short");
} else if (normalized.phone.length > 30) {
} else if (normalized.phone && normalized.phone.length > 30) {
pushImportRowError(row, "phone", "too_long");
}
@@ -834,7 +837,7 @@ function createDepartmentResolutionMap(departments) {
function createEmployeeLookupMaps(employees) {
return {
byEmployeeNo: new Map(employees.map((item) => [item.employee_no, item])),
byPhone: new Map(employees.map((item) => [item.phone, item])),
byPhone: new Map(employees.filter((item) => item.phone).map((item) => [item.phone, item])),
byEmail: new Map(employees.map((item) => [item.email, item])),
};
}
@@ -912,7 +915,9 @@ async function buildUserImportPreview(prisma, rawRows, options = {}) {
pushImportRowError(row, "employeeNo", "duplicate_existing");
}
const phoneConflict = existingLookup.byPhone.get(row.normalized.phone);
const phoneConflict = row.normalized.phone
? existingLookup.byPhone.get(row.normalized.phone)
: null;
if (phoneConflict) {
pushImportRowError(row, "phone", "duplicate_existing");
}
@@ -1012,7 +1017,7 @@ async function createSystemUser(tx, options) {
? { employee_no: options.employeeNo.trim() }
: {}),
name: options.name,
phone: options.phone,
phone: options.phone || null,
email: options.email.trim().toLowerCase(),
department_id: options.departmentId,
title: options.title ?? "员工",
@@ -1573,7 +1578,7 @@ async function usersRoutes(fastify, opts = {}) {
const employee = await createSystemUser(tx, {
employeeNo: row.normalized.employeeNo,
name: row.normalized.name,
phone: row.normalized.phone,
phone: row.normalized.phone || null,
email: row.normalized.email,
departmentId: row.resolvedDepartmentId,
title: row.normalized.title,
@@ -1742,7 +1747,7 @@ async function usersRoutes(fastify, opts = {}) {
if (typeof body.name === "string" && body.name !== current.name) {
changes.name = { before: current.name, after: body.name };
}
if (typeof body.phone === "string" && body.phone !== current.phone) {
if (Object.prototype.hasOwnProperty.call(body, "phone") && (body.phone ?? null) !== current.phone) {
changes.phone = { before: current.phone, after: body.phone };
}
if (typeof body.email === "string" && body.email !== current.email) {
@@ -1768,7 +1773,7 @@ async function usersRoutes(fastify, opts = {}) {
where: { id: userId },
data: {
...(typeof body.name === "string" ? { name: body.name } : {}),
...(typeof body.phone === "string" ? { phone: body.phone } : {}),
...(Object.prototype.hasOwnProperty.call(body, "phone") ? { phone: body.phone ?? null } : {}),
...(typeof body.email === "string" ? { email: body.email } : {}),
...(typeof body.departmentId === "string" ? { department_id: body.departmentId } : {}),
...(typeof body.title === "string" ? { title: body.title } : {}),
@@ -0,0 +1,296 @@
#!/usr/bin/env node
require("dotenv/config");
const fs = require("node:fs");
const ExcelJS = require("exceljs");
const { createPrismaClient } = require("../prismaClient");
const {
maskEmail,
maskExternalUserId,
maskPhone,
runWecomHrMasterSync,
} = require("../services/integrations/wecomHrMaster");
const APPLY_CONFIRM_ENV = "WECOM_DIRECTORY_EXPORT_APPLY";
const REQUIRED_HEADERS = ["姓名", "账号", "部门", "企业邮箱"];
const SAFE_EMPTY_PHONE = null;
class DirectoryExportError extends Error {
constructor(code, details = {}) {
super(code);
this.name = "DirectoryExportError";
this.code = code;
this.details = details;
}
}
function printHelp() {
process.stdout.write(`Usage:
node src/scripts/import-wecom-directory-export.js [--input=-] [--dry-run] [--format=json]
WECOM_DIRECTORY_EXPORT_APPLY=1 node src/scripts/import-wecom-directory-export.js --input=- --apply --actor-employee-no=<no>
Reads an Enterprise WeChat directory XLSX from stdin. Dry-run is the default.
Reports contain counts and categories only; personal field values are never printed.
`);
}
function parseArgs(argv) {
const options = {
input: "-",
mode: "dry_run",
format: "text",
actorEmployeeNo: "",
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") options.help = true;
else if (arg === "--dry-run") options.mode = "dry_run";
else if (arg === "--apply") options.mode = "apply";
else if (arg === "--input") options.input = argv[++index] ?? "";
else if (arg.startsWith("--input=")) options.input = arg.slice("--input=".length);
else if (arg === "--format") options.format = argv[++index] ?? "";
else if (arg.startsWith("--format=")) options.format = arg.slice("--format=".length);
else if (arg === "--actor-employee-no") options.actorEmployeeNo = argv[++index] ?? "";
else if (arg.startsWith("--actor-employee-no=")) {
options.actorEmployeeNo = arg.slice("--actor-employee-no=".length);
} else throw new DirectoryExportError("unsupported_argument");
}
if (options.input !== "-") throw new DirectoryExportError("stdin_required");
if (!new Set(["text", "json"]).has(options.format)) throw new DirectoryExportError("invalid_format");
if (options.mode === "apply") {
if (process.env[APPLY_CONFIRM_ENV] !== "1") throw new DirectoryExportError("apply_confirmation_required");
if (!/^[A-Za-z0-9_-]{1,80}$/.test(options.actorEmployeeNo)) {
throw new DirectoryExportError("actor_required");
}
}
return options;
}
function cellToString(value) {
if (value == null) return "";
if (value instanceof Date) return value.toISOString().slice(0, 10);
if (typeof value === "object") {
if (Array.isArray(value.richText)) return value.richText.map((item) => item.text ?? "").join("").trim();
if (value.text != null) return String(value.text).trim();
if (value.result != null) return cellToString(value.result);
}
return String(value).trim();
}
function findHeaderRow(worksheet) {
const maximum = Math.min(50, worksheet.rowCount);
for (let rowNumber = 1; rowNumber <= maximum; rowNumber += 1) {
const labels = worksheet.getRow(rowNumber).values.slice(1).map(cellToString);
if (REQUIRED_HEADERS.every((header) => labels.includes(header))) {
return { rowNumber, labels };
}
}
throw new DirectoryExportError("required_headers_missing", { missingHeaderCount: REQUIRED_HEADERS.length });
}
function normalizeEmail(value) {
const raw = cellToString(value).toLowerCase();
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw) ? raw : "";
}
function normalizePhone(value) {
const raw = cellToString(value);
if (!raw) return { value: SAFE_EMPTY_PHONE, invalid: false };
const normalized = raw.replace(/[\s-]/g, "");
if (!/^\+?\d{6,30}$/.test(normalized)) {
return { value: SAFE_EMPTY_PHONE, invalid: true };
}
return { value: normalized, invalid: false };
}
function buildProfile(data) {
const externalUserId = cellToString(data["账号"]).slice(0, 128);
const name = cellToString(data["姓名"]).slice(0, 100);
const phone = normalizePhone(data["手机"]);
const email = normalizeEmail(data["企业邮箱"]);
const departmentName = cellToString(data["部门"]).slice(0, 100);
const title = cellToString(data["职务"]).slice(0, 100) || "员工";
const activation = cellToString(data["激活状态"]);
const disabled = cellToString(data["禁用状态"]);
const active = (!activation || activation === "已激活")
&& (!disabled || new Set(["否", "未禁用", "正常", "0"]).has(disabled));
const missingRequiredFields = [];
for (const [field, value] of [
["externalUserId", externalUserId],
["name", name],
["email", email],
["department", departmentName],
]) {
if (!value) missingRequiredFields.push(field);
}
if (phone.invalid) missingRequiredFields.push("phone_invalid");
return {
externalUserId,
name,
phone: phone.value,
email,
title,
departmentExternalId: departmentName,
departmentName,
managerExternalUserId: "",
hireDate: null,
sourceStatus: active ? "active" : "inactive",
missingRequiredFields,
sourceSummary: {
externalUserIdPrefix: maskExternalUserId(externalUserId),
phoneMasked: maskPhone(phone.value),
emailMasked: maskEmail(email),
hasPhone: Boolean(phone.value),
hasEmail: Boolean(email),
hasDepartment: Boolean(departmentName),
sourceStatus: active ? "active" : "inactive",
sourceKind: "wecom_directory_export",
},
};
}
function countDuplicateValues(profiles, key) {
const counts = new Map();
for (const profile of profiles) {
const value = String(profile[key] ?? "").trim().toLowerCase();
if (!value) continue;
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return [...counts.values()].filter((count) => count > 1).length;
}
async function parseDirectoryExport(buffer) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer);
const worksheet = workbook.worksheets[0];
if (!worksheet) throw new DirectoryExportError("worksheet_missing");
const header = findHeaderRow(worksheet);
const profiles = [];
worksheet.eachRow((row, rowNumber) => {
if (rowNumber <= header.rowNumber) return;
const data = {};
header.labels.forEach((label, index) => {
if (label) data[label] = cellToString(row.getCell(index + 1).value);
});
if (Object.values(data).some(Boolean)) profiles.push(buildProfile(data));
});
if (!profiles.length) throw new DirectoryExportError("member_rows_missing");
const duplicateCounts = {
account: countDuplicateValues(profiles, "externalUserId"),
phone: countDuplicateValues(profiles, "phone"),
email: countDuplicateValues(profiles, "email"),
};
if (Object.values(duplicateCounts).some((count) => count > 0)) {
throw new DirectoryExportError("duplicate_values", duplicateCounts);
}
return {
profiles,
sourceSummary: {
memberCount: profiles.length,
activeCount: profiles.filter((profile) => profile.sourceStatus === "active").length,
phonePresentCount: profiles.filter((profile) => Boolean(profile.phone)).length,
phoneMissingCount: profiles.filter((profile) => !profile.phone).length,
emailPresentCount: profiles.filter((profile) => Boolean(profile.email)).length,
incompleteCount: profiles.filter((profile) => profile.missingRequiredFields.length > 0).length,
},
};
}
async function resolveActorId(prisma, employeeNo) {
if (!employeeNo) return null;
const employee = await prisma.employee.findUnique({
where: { employee_no: employeeNo },
select: { id: true, status: true, archived_at: true },
});
if (!employee || employee.status !== "active" || employee.archived_at) {
throw new DirectoryExportError("actor_unavailable");
}
return employee.id;
}
function buildSafeReport(options, parsed, syncRun) {
const summary = syncRun?.summary ?? {};
const blocked = Number(summary.failed ?? 0) > 0
|| Number(summary.conflicts ?? 0) > 0
|| Number(summary.skipped ?? 0) > 0
|| parsed.sourceSummary.incompleteCount > 0;
return {
issueId: "WECOM_DIRECTORY_EXPORT",
status: blocked
? "BLOCKED_DIRECTORY_EXPORT"
: options.mode === "apply"
? "PASS_DIRECTORY_EXPORT_APPLY"
: "PASS_DIRECTORY_EXPORT_DRY_RUN",
mode: options.mode,
source: parsed.sourceSummary,
sync: {
created: Number(summary.created ?? 0),
updated: Number(summary.updated ?? 0),
noop: Number(summary.noop ?? 0),
skipped: Number(summary.skipped ?? 0),
conflicts: Number(summary.conflicts ?? 0),
failed: Number(summary.failed ?? 0),
planned: Number(summary.planned ?? 0),
success: Number(summary.success ?? 0),
},
privacy: "counts/categories only; no names, accounts, phones, emails, departments, paths, or raw rows",
};
}
function printReport(report, format) {
if (format === "json") {
process.stdout.write(`${JSON.stringify(report)}\n`);
return;
}
process.stdout.write(`[wecom-directory-export] status=${report.status} mode=${report.mode}\n`);
process.stdout.write(`[wecom-directory-export] members=${report.source.memberCount} phonePresent=${report.source.phonePresentCount} phoneMissing=${report.source.phoneMissingCount} incomplete=${report.source.incompleteCount}\n`);
process.stdout.write(`[wecom-directory-export] planned=${report.sync.planned} conflicts=${report.sync.conflicts} skipped=${report.sync.skipped} failed=${report.sync.failed}\n`);
}
async function main() {
let resources = null;
try {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const parsed = await parseDirectoryExport(await fs.promises.readFile(0));
resources = createPrismaClient();
const actorId = await resolveActorId(resources.prisma, options.actorEmployeeNo);
const syncRun = await runWecomHrMasterSync({
prisma: resources.prisma,
config: null,
actorId,
mode: options.mode,
limit: parsed.profiles.length,
sourceProfiles: parsed.profiles,
sourceLabel: "manual_export",
});
const report = buildSafeReport(options, parsed, syncRun);
printReport(report, options.format);
if (report.status.startsWith("BLOCKED")) process.exitCode = 2;
} catch (error) {
const code = error instanceof DirectoryExportError ? error.code : "directory_export_failed";
process.stderr.write(`[wecom-directory-export][ERROR] ${code}\n`);
process.exitCode = 1;
} finally {
await resources?.prisma?.$disconnect?.().catch(() => {});
await resources?.pool?.end?.().catch(() => {});
}
}
if (require.main === module) main();
module.exports = {
DirectoryExportError,
buildProfile,
buildSafeReport,
countDuplicateValues,
findHeaderRow,
parseArgs,
parseDirectoryExport,
};
@@ -468,7 +468,7 @@ function normalizeWecomHrProfile({ contact = {}, staff = {}, departmentMap = new
};
profile.missingRequiredFields = [];
for (const field of ["externalUserId", "name", "phone", "email"]) {
for (const field of ["externalUserId", "name", "email"]) {
if (!isMeaningfulString(profile[field])) profile.missingRequiredFields.push(field);
}
if (!isMeaningfulString(profile.departmentExternalId) && !isMeaningfulString(profile.departmentName)) {
@@ -721,18 +721,21 @@ async function detectUniqueConflicts(prisma, employeeId, profile) {
function buildEmployeeChanges(employee, profile, department) {
if (!employee) {
return {
const changes = {
name: buildChange("name", null, profile.name),
phone: buildChange("phone", null, profile.phone),
email: buildChange("email", null, profile.email),
department: buildChange("department", null, department.name),
title: buildChange("title", null, profile.title),
};
if (profile.phone) changes.phone = buildChange("phone", null, profile.phone);
return changes;
}
const changes = {};
if (employee.name !== profile.name) changes.name = buildChange("name", employee.name, profile.name);
if (employee.phone !== profile.phone) changes.phone = buildChange("phone", employee.phone, profile.phone);
if (profile.phone && employee.phone !== profile.phone) {
changes.phone = buildChange("phone", employee.phone, profile.phone);
}
if (employee.email !== profile.email) changes.email = buildChange("email", employee.email, profile.email);
if (department.id && employee.department_id !== department.id) {
changes.department = buildChange("department", employee.department_id, department.name);
@@ -854,7 +857,7 @@ async function processProfile(prisma, runId, profile, context) {
where: { id: existing.id },
data: {
name: profile.name,
phone: profile.phone,
...(profile.phone ? { phone: profile.phone } : {}),
email: profile.email,
department_id: department.id,
title: profile.title,
@@ -866,7 +869,7 @@ async function processProfile(prisma, runId, profile, context) {
: await prisma.employee.create({
data: {
name: profile.name,
phone: profile.phone,
phone: profile.phone || null,
email: profile.email,
department_id: department.id,
title: profile.title,
@@ -1077,6 +1080,8 @@ async function runWecomHrMasterSync({
userids: rawUserids,
limit: rawLimit,
fetchImpl = fetch,
sourceProfiles = null,
sourceLabel = "manual_export",
}) {
const mode = normalizeMode(rawMode);
const userids = normalizeUserids(rawUserids);
@@ -1095,12 +1100,17 @@ async function runWecomHrMasterSync({
let source = null;
try {
source = await fetchWecomHrProfiles({
config,
userids,
limit,
fetchImpl,
});
source = Array.isArray(sourceProfiles)
? {
profiles: sourceProfiles.slice(0, limit),
tokenSource: { offline: toSafeString(sourceLabel, 40) || "manual_export" },
}
: await fetchWecomHrProfiles({
config,
userids,
limit,
fetchImpl,
});
} catch (error) {
const status = error instanceof WecomHrMasterSyncError && error.code === "wecom_token_unavailable"
? "skipped"
@@ -115,7 +115,7 @@ async function fetchWecomOAuthOnboardingProfile({
};
const missing = [];
for (const field of ["name", "phone", "email", "departmentExternalId", "departmentName"]) {
for (const field of ["name", "email", "departmentExternalId", "departmentName"]) {
if (!profile[field]) missing.push(field);
}
if (missing.length) {
@@ -186,7 +186,12 @@ async function resolveEmployee(tx, profile) {
},
});
const contactMatches = await tx.employee.findMany({
where: { OR: [{ phone: profile.phone }, { email: profile.email }] },
where: {
OR: [
...(profile.phone ? [{ phone: profile.phone }] : []),
{ email: profile.email },
],
},
select: {
id: true,
status: true,
@@ -229,7 +234,7 @@ async function provisionWecomOAuthEmployee({
where: { id: existing.id },
data: {
name: profile.name,
phone: profile.phone,
...(profile.phone ? { phone: profile.phone } : {}),
email: profile.email,
department_id: departmentId,
title: profile.title,
@@ -241,7 +246,7 @@ async function provisionWecomOAuthEmployee({
: await tx.employee.create({
data: {
name: profile.name,
phone: profile.phone,
phone: profile.phone || null,
email: profile.email,
department_id: departmentId,
title: profile.title,
@@ -294,7 +299,7 @@ async function provisionWecomOAuthEmployee({
extra: {
created: !existing,
source: "wecom_oauth",
fieldPresence: { phone: true, email: true },
fieldPresence: { phone: Boolean(profile.phone), email: true },
},
}),
});