实现企微首次免密自动绑定(07-28-all-account-login-readiness)
这个提交包含在:
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户