完善企微开通与部署端口契约(07-26-internal-ops-recovery-baseline)
这个提交包含在:
@@ -327,7 +327,7 @@ async function fetchDepartmentMap({ accessToken, fetchImpl, timeoutMs }) {
|
||||
return normalizeDepartmentMap(result.payload);
|
||||
}
|
||||
|
||||
async function listContactMembers({ accessToken, limit, fetchImpl, timeoutMs }) {
|
||||
async function listContactMembers({ accessToken, detailAccessToken = accessToken, limit, fetchImpl, timeoutMs }) {
|
||||
const membersByUserid = new Map();
|
||||
let cursor = "";
|
||||
|
||||
@@ -363,7 +363,7 @@ async function listContactMembers({ accessToken, limit, fetchImpl, timeoutMs })
|
||||
const members = Array.from(membersByUserid.values()).slice(0, limit);
|
||||
const contacts = await Promise.all(members.map(async (member) => {
|
||||
const detail = await fetchOptionalContactMember({
|
||||
accessToken,
|
||||
accessToken: detailAccessToken,
|
||||
userid: member.userid,
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
@@ -477,21 +477,22 @@ function normalizeWecomHrProfile({ contact = {}, staff = {}, departmentMap = new
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function fetchContactProfilesInput({ config, token, userids, limit, fetchImpl, timeoutMs }) {
|
||||
async function fetchContactProfilesInput({ config, token, detailToken = token, userids, limit, fetchImpl, timeoutMs }) {
|
||||
const departmentMap = await fetchOptionalDepartmentMap({
|
||||
accessToken: token.accessToken,
|
||||
accessToken: detailToken.accessToken,
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
});
|
||||
const contacts = userids.length
|
||||
? await Promise.all(userids.map((userid) => fetchOptionalContactMember({
|
||||
accessToken: token.accessToken,
|
||||
accessToken: detailToken.accessToken,
|
||||
userid,
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
})))
|
||||
: await listContactMembers({
|
||||
accessToken: token.accessToken,
|
||||
detailAccessToken: detailToken.accessToken,
|
||||
limit,
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
@@ -526,6 +527,7 @@ async function fetchWecomHrProfiles({ config, userids, limit, fetchImpl = fetch
|
||||
const contactInput = await fetchContactProfilesInput({
|
||||
config,
|
||||
token: contactToken,
|
||||
detailToken: hrToken,
|
||||
userids,
|
||||
limit: readLimit,
|
||||
fetchImpl,
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { z } = require("zod");
|
||||
|
||||
const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { hashPassword } = require("../../utils/crypto");
|
||||
|
||||
const WECOM_API_BASE = "https://qyapi.weixin.qq.com";
|
||||
const PRIVATE_EMAIL_SCHEMA = z.string().trim().email().max(200).transform((value) => value.toLowerCase());
|
||||
const PRIVATE_PHONE_SCHEMA = z.string().trim().min(6).max(30);
|
||||
|
||||
class WecomOAuthOnboardingError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = "WecomOAuthOnboardingError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(value, maxLength) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function normalizeEmail(value) {
|
||||
const parsed = PRIVATE_EMAIL_SCHEMA.safeParse(value);
|
||||
return parsed.success ? parsed.data : "";
|
||||
}
|
||||
|
||||
function normalizePhone(value) {
|
||||
const parsed = PRIVATE_PHONE_SCHEMA.safeParse(value);
|
||||
return parsed.success ? parsed.data : "";
|
||||
}
|
||||
|
||||
async function fetchWecomJson(pathName, accessToken, options = {}) {
|
||||
const url = new URL(`${WECOM_API_BASE}${pathName}`);
|
||||
url.searchParams.set("access_token", accessToken);
|
||||
for (const [key, value] of Object.entries(options.query ?? {})) {
|
||||
if (value != null && value !== "") url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await (options.fetchImpl ?? fetch)(url, {
|
||||
method: options.body ? "POST" : "GET",
|
||||
headers: options.body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new WecomOAuthOnboardingError("wecom_api_failed", "WeCom onboarding API failed");
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) {
|
||||
throw new WecomOAuthOnboardingError("wecom_api_failed", "WeCom onboarding API failed");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function normalizeDepartmentMap(payload) {
|
||||
const map = new Map();
|
||||
for (const item of Array.isArray(payload?.department) ? payload.department : []) {
|
||||
const externalId = requiredString(String(item?.id ?? item?.department_id ?? ""), 64);
|
||||
const name = requiredString(item?.name, 100);
|
||||
if (externalId && name) map.set(externalId, name);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function fetchWecomOAuthOnboardingProfile({
|
||||
accessToken,
|
||||
externalUserId,
|
||||
userTicket,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const userid = requiredString(externalUserId, 128);
|
||||
const ticket = requiredString(userTicket, 512);
|
||||
if (!userid || !ticket) {
|
||||
throw new WecomOAuthOnboardingError("consent_required", "WeCom private information consent is required");
|
||||
}
|
||||
|
||||
const [privateDetail, contact, departmentPayload] = await Promise.all([
|
||||
fetchWecomJson("/cgi-bin/auth/getuserdetail", accessToken, {
|
||||
body: { user_ticket: ticket },
|
||||
fetchImpl,
|
||||
}),
|
||||
fetchWecomJson("/cgi-bin/user/get", accessToken, {
|
||||
query: { userid },
|
||||
fetchImpl,
|
||||
}),
|
||||
fetchWecomJson("/cgi-bin/department/list", accessToken, { fetchImpl }),
|
||||
]);
|
||||
|
||||
const detailUserid = requiredString(privateDetail.userid ?? privateDetail.UserId, 128);
|
||||
if (!detailUserid || detailUserid !== userid) {
|
||||
throw new WecomOAuthOnboardingError("identity_mismatch", "WeCom onboarding identity mismatch");
|
||||
}
|
||||
|
||||
const status = Number(contact.status ?? contact.enable ?? 0);
|
||||
if (status !== 1) {
|
||||
throw new WecomOAuthOnboardingError("member_inactive", "WeCom member is not active");
|
||||
}
|
||||
|
||||
const departmentExternalId = requiredString(
|
||||
String(contact.main_department ?? contact.department?.[0] ?? ""),
|
||||
64,
|
||||
);
|
||||
const departmentName = normalizeDepartmentMap(departmentPayload).get(departmentExternalId) ?? "";
|
||||
const profile = {
|
||||
externalUserId: userid,
|
||||
name: requiredString(contact.name, 100),
|
||||
phone: normalizePhone(privateDetail.mobile),
|
||||
email: normalizeEmail(privateDetail.email) || normalizeEmail(privateDetail.biz_mail),
|
||||
departmentExternalId,
|
||||
departmentName,
|
||||
title: requiredString(contact.position, 100) || "员工",
|
||||
};
|
||||
|
||||
const missing = [];
|
||||
for (const field of ["name", "phone", "email", "departmentExternalId", "departmentName"]) {
|
||||
if (!profile[field]) missing.push(field);
|
||||
}
|
||||
if (missing.length) {
|
||||
throw new WecomOAuthOnboardingError("missing_required_field", "WeCom onboarding fields are incomplete");
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function resolveDepartment(tx, profile, actorId) {
|
||||
const binding = await tx.integrationBinding.findFirst({
|
||||
where: {
|
||||
provider: "wecom",
|
||||
entity_type: "department",
|
||||
external_id: profile.departmentExternalId,
|
||||
},
|
||||
});
|
||||
if (binding) {
|
||||
const department = await tx.department.findUnique({
|
||||
where: { id: binding.local_id },
|
||||
select: { id: true, archived_at: true },
|
||||
});
|
||||
if (!department || department.archived_at) {
|
||||
throw new WecomOAuthOnboardingError("department_invalid", "WeCom department binding is unavailable");
|
||||
}
|
||||
return department.id;
|
||||
}
|
||||
|
||||
const matches = await tx.department.findMany({
|
||||
where: { name: profile.departmentName, archived_at: null },
|
||||
select: { id: true },
|
||||
take: 2,
|
||||
});
|
||||
if (matches.length > 1) {
|
||||
throw new WecomOAuthOnboardingError("department_ambiguous", "WeCom department matched multiple OA departments");
|
||||
}
|
||||
|
||||
const departmentId = matches[0]?.id ?? (await tx.department.create({
|
||||
data: {
|
||||
name: profile.departmentName,
|
||||
created_by: actorId,
|
||||
updated_by: actorId,
|
||||
},
|
||||
select: { id: true },
|
||||
})).id;
|
||||
|
||||
await tx.integrationBinding.create({
|
||||
data: {
|
||||
provider: "wecom",
|
||||
entity_type: "department",
|
||||
local_id: departmentId,
|
||||
external_id: profile.departmentExternalId,
|
||||
external_code: profile.departmentName,
|
||||
last_synced_at: new Date(),
|
||||
created_by: actorId,
|
||||
updated_by: actorId,
|
||||
},
|
||||
});
|
||||
return departmentId;
|
||||
}
|
||||
|
||||
async function resolveEmployee(tx, profile) {
|
||||
const binding = await tx.wecomIdentityBinding.findUnique({
|
||||
where: { external_user_id: profile.externalUserId },
|
||||
select: {
|
||||
employee: {
|
||||
select: { id: true, status: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const contactMatches = await tx.employee.findMany({
|
||||
where: { OR: [{ phone: profile.phone }, { email: profile.email }] },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
wecom_identity_binding: { select: { external_user_id: true } },
|
||||
},
|
||||
take: 3,
|
||||
});
|
||||
|
||||
const matches = new Map();
|
||||
if (binding?.employee) matches.set(binding.employee.id, binding.employee);
|
||||
for (const employee of contactMatches) matches.set(employee.id, employee);
|
||||
if (matches.size > 1) {
|
||||
throw new WecomOAuthOnboardingError("employee_conflict", "WeCom onboarding matched multiple OA employees");
|
||||
}
|
||||
const employee = [...matches.values()][0] ?? null;
|
||||
if (employee && employee.status !== "active") {
|
||||
throw new WecomOAuthOnboardingError("employee_inactive", "Matched OA employee is inactive");
|
||||
}
|
||||
const existingExternalUserId = requiredString(employee?.wecom_identity_binding?.external_user_id, 128);
|
||||
if (existingExternalUserId && existingExternalUserId.toLowerCase() !== profile.externalUserId.toLowerCase()) {
|
||||
throw new WecomOAuthOnboardingError("employee_conflict", "Matched OA employee is bound to another WeCom member");
|
||||
}
|
||||
return employee;
|
||||
}
|
||||
|
||||
async function provisionWecomOAuthEmployee({
|
||||
prisma,
|
||||
request,
|
||||
profile,
|
||||
externalOpenUserId = null,
|
||||
}) {
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const existing = await resolveEmployee(tx, profile);
|
||||
const actorId = existing?.id ?? null;
|
||||
const departmentId = await resolveDepartment(tx, profile, actorId);
|
||||
const now = new Date();
|
||||
const employee = existing
|
||||
? await tx.employee.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: profile.name,
|
||||
phone: profile.phone,
|
||||
email: profile.email,
|
||||
department_id: departmentId,
|
||||
title: profile.title,
|
||||
updated_at: now,
|
||||
updated_by: existing.id,
|
||||
},
|
||||
select: { id: true, token_version: true },
|
||||
})
|
||||
: await tx.employee.create({
|
||||
data: {
|
||||
name: profile.name,
|
||||
phone: profile.phone,
|
||||
email: profile.email,
|
||||
department_id: departmentId,
|
||||
title: profile.title,
|
||||
hire_date: now,
|
||||
status: "active",
|
||||
employment_status: "active",
|
||||
bank_account_encrypted: "",
|
||||
bank_name: "",
|
||||
password_hash: await hashPassword(`WecomOnboardA1${crypto.randomBytes(18).toString("hex")}`),
|
||||
must_change_password: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
select: { id: true, token_version: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await tx.employeeRole.create({
|
||||
data: {
|
||||
employee_id: employee.id,
|
||||
role_code: "employee",
|
||||
created_at: now,
|
||||
created_by: employee.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.wecomIdentityBinding.upsert({
|
||||
where: { employee_id: employee.id },
|
||||
update: {
|
||||
external_user_id: profile.externalUserId,
|
||||
external_open_user_id: externalOpenUserId,
|
||||
updated_by: employee.id,
|
||||
},
|
||||
create: {
|
||||
employee_id: employee.id,
|
||||
external_user_id: profile.externalUserId,
|
||||
external_open_user_id: externalOpenUserId,
|
||||
created_by: employee.id,
|
||||
updated_by: employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: employee.id,
|
||||
action: existing ? "AUTH_WECOM_PROFILE_COMPLETED" : "AUTH_WECOM_SELF_PROVISIONED",
|
||||
targetType: "employee",
|
||||
targetId: employee.id,
|
||||
extra: {
|
||||
created: !existing,
|
||||
source: "wecom_oauth",
|
||||
fieldPresence: { phone: true, email: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
return { ...employee, created: !existing };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WecomOAuthOnboardingError) throw error;
|
||||
if (error?.code === "P2002") {
|
||||
throw new WecomOAuthOnboardingError("employee_conflict", "WeCom onboarding unique field conflict");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WecomOAuthOnboardingError,
|
||||
fetchWecomOAuthOnboardingProfile,
|
||||
provisionWecomOAuthEmployee,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户