实现企微首次免密自动绑定(07-28-all-account-login-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-28 11:18:50 +08:00
父节点 0551212b78
当前提交 9b12451b85
共修改 22 个文件,包含 1764 行新增和 132 行删除
@@ -0,0 +1,257 @@
const { z } = require("zod");
const { buildAuditLogData } = require("../../utils/audit");
const WECOM_API_BASE = "https://qyapi.weixin.qq.com";
const EMAIL_SCHEMA = z
.string()
.trim()
.email()
.max(200)
.transform((value) => value.toLowerCase());
class WecomLoginAutoBindingError extends Error {
constructor(code, message) {
super(message);
this.name = "WecomLoginAutoBindingError";
this.code = code;
}
}
function requiredString(value, maxLength) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
function normalizeEmail(value) {
const parsed = EMAIL_SCHEMA.safeParse(value);
return parsed.success ? parsed.data : "";
}
async function fetchWecomLoginIdentity({
accessToken,
externalUserId,
fetchImpl = fetch,
timeoutMs = 5000,
}) {
const token = requiredString(accessToken, 2000);
const userid = requiredString(externalUserId, 128);
if (!token || !userid) {
throw new WecomLoginAutoBindingError(
"wecom_identity_invalid",
"WeCom login identity is invalid",
);
}
const url = new URL(`${WECOM_API_BASE}/cgi-bin/user/get`);
url.searchParams.set("access_token", token);
url.searchParams.set("userid", userid);
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
Math.max(100, Number(timeoutMs) || 5000),
);
let response;
try {
response = await fetchImpl(url, {
method: "GET",
signal: controller.signal,
});
} catch {
throw new WecomLoginAutoBindingError(
"wecom_profile_unavailable",
"WeCom member profile is unavailable",
);
} finally {
clearTimeout(timer);
}
if (!response || typeof response.json !== "function") {
throw new WecomLoginAutoBindingError(
"wecom_profile_unavailable",
"WeCom member profile is unavailable",
);
}
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) {
throw new WecomLoginAutoBindingError(
"wecom_profile_unavailable",
"WeCom member profile is unavailable",
);
}
const returnedUserid = requiredString(payload.userid ?? payload.UserId, 128);
if (!returnedUserid || returnedUserid !== userid) {
throw new WecomLoginAutoBindingError(
"wecom_identity_mismatch",
"WeCom member identity does not match OAuth identity",
);
}
const status = Number(payload.status ?? payload.enable ?? 0);
if (status !== 1) {
throw new WecomLoginAutoBindingError(
"wecom_member_inactive",
"WeCom member is inactive",
);
}
const email =
normalizeEmail(payload.biz_mail) || normalizeEmail(payload.email);
return { externalUserId: userid, email: email || null };
}
async function resolveUniqueEmployee(tx, identity) {
const matches = await tx.employee.findMany({
where: {
OR: [
{ employee_no: identity.externalUserId },
...(identity.email
? [{ email: { equals: identity.email, mode: "insensitive" } }]
: []),
],
},
select: {
id: true,
employee_no: true,
email: true,
status: true,
archived_at: true,
token_version: true,
},
take: 3,
});
const uniqueMatches = new Map(
matches.map((employee) => [employee.id, employee]),
);
if (uniqueMatches.size === 0) {
throw new WecomLoginAutoBindingError(
"oa_employee_not_found",
"No OA employee matches the WeCom identity",
);
}
if (uniqueMatches.size !== 1) {
throw new WecomLoginAutoBindingError(
"oa_employee_conflict",
"WeCom identity matches multiple OA employees",
);
}
const employee = [...uniqueMatches.values()][0];
if (employee.status !== "active" || employee.archived_at !== null) {
throw new WecomLoginAutoBindingError(
"oa_employee_inactive",
"Matched OA employee is inactive",
);
}
const matchByUserid = employee.employee_no === identity.externalUserId;
const matchByEmail = Boolean(
identity.email && employee.email?.toLowerCase() === identity.email,
);
if (!matchByUserid && !matchByEmail) {
throw new WecomLoginAutoBindingError(
"oa_employee_not_found",
"No OA employee matches the WeCom identity",
);
}
return {
employee,
matchBy:
matchByUserid && matchByEmail
? "userid_and_email"
: matchByUserid
? "userid"
: "email",
};
}
async function autoBindExistingWecomEmployee({
prisma,
request,
identity,
externalOpenUserId = null,
}) {
const externalUserId = requiredString(identity?.externalUserId, 128);
const email = normalizeEmail(identity?.email);
if (!externalUserId) {
throw new WecomLoginAutoBindingError(
"wecom_identity_invalid",
"WeCom login identity is invalid",
);
}
try {
return await prisma.$transaction(async (tx) => {
const { employee, matchBy } = await resolveUniqueEmployee(tx, {
externalUserId,
email,
});
const [useridBinding, employeeBinding] = await Promise.all([
tx.wecomIdentityBinding.findUnique({
where: { external_user_id: externalUserId },
select: { employee_id: true },
}),
tx.wecomIdentityBinding.findUnique({
where: { employee_id: employee.id },
select: { external_user_id: true },
}),
]);
if (useridBinding && useridBinding.employee_id !== employee.id) {
throw new WecomLoginAutoBindingError(
"wecom_userid_conflict",
"WeCom userid is already bound to another OA employee",
);
}
if (
employeeBinding &&
employeeBinding.external_user_id !== externalUserId
) {
throw new WecomLoginAutoBindingError(
"oa_employee_binding_conflict",
"OA employee is already bound to another WeCom userid",
);
}
if (!useridBinding && !employeeBinding) {
await tx.wecomIdentityBinding.create({
data: {
employee_id: employee.id,
external_user_id: externalUserId,
external_open_user_id:
requiredString(externalOpenUserId, 128) || null,
created_by: employee.id,
updated_by: employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: employee.id,
action: "AUTH_WECOM_AUTO_BOUND",
targetType: "employee",
targetId: employee.id,
extra: { matchBy, emailMatched: matchBy.includes("email") },
}),
});
}
return {
id: employee.id,
status: employee.status,
token_version: employee.token_version,
autoBound: !useridBinding && !employeeBinding,
};
});
} catch (error) {
if (error instanceof WecomLoginAutoBindingError) throw error;
if (error?.code === "P2002") {
throw new WecomLoginAutoBindingError(
"binding_race_conflict",
"WeCom binding changed concurrently",
);
}
throw error;
}
}
module.exports = {
WecomLoginAutoBindingError,
autoBindExistingWecomEmployee,
fetchWecomLoginIdentity,
};