完善企微开通与部署端口契约(07-26-internal-ops-recovery-baseline)

这个提交包含在:
2026-07-26 23:44:44 +08:00
父节点 9a624700d0
当前提交 130518f471
共修改 21 个文件,包含 1350 行新增和 55 行删除
+1
查看文件
@@ -183,6 +183,7 @@ function getConfig() {
wecomHrMasterSyncEnabled: process.env.WECOM_HR_MASTER_SYNC_ENABLED === "true",
wecomHrAssistantSecret: process.env.WECOM_HR_ASSISTANT_SECRET ?? "",
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
wecomOauthOnboardingEnabled: process.env.WECOM_OAUTH_ONBOARDING_ENABLED === "true",
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
wecomApprovalCallbackUrl: process.env.WECOM_APPROVAL_CALLBACK_URL ?? "",
+92 -11
查看文件
@@ -6,6 +6,11 @@ const { buildAuditLogData } = require("../utils/audit");
const { randomRefreshToken, sha256Hex } = require("../utils/crypto");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { sendData } = require("../utils/reply");
const {
WecomOAuthOnboardingError,
fetchWecomOAuthOnboardingProfile,
provisionWecomOAuthEmployee,
} = require("../services/integrations/wecomOAuthOnboarding");
const BIND_COOKIE_NAME = "wecom_bind";
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
@@ -15,7 +20,7 @@ const DEFAULT_RETURN_TO = "/mobile";
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
const QUERY_SCHEMA = z.object({
returnTo: z.string().optional(),
mode: z.enum(["login", "bind"]).optional(),
mode: z.enum(["login", "bind", "onboard"]).optional(),
deviceFingerprint: z.string().trim().min(1).max(128).optional(),
});
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
@@ -159,6 +164,7 @@ function resolveWecomEntryConfig(config) {
&& config.wecomRedirectUri,
);
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled);
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
return {
@@ -169,6 +175,8 @@ function resolveWecomEntryConfig(config) {
agentId: String(config.wecomAgentId ?? ""),
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
bindEnabled,
onboardingEnabled,
onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null,
defaultReturnTo: DEFAULT_RETURN_TO,
status: entryEnabled ? "ready" : hasDisabledReason ? "disabled" : "misconfigured",
statusReasons,
@@ -199,10 +207,17 @@ function assertWecomEntryReady(config) {
return entryConfig;
}
function assertWecomOnboardingReady(config) {
assertWecomOauthReady(config);
if (!config.wecomOauthOnboardingEnabled) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
}
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") {
const params = new URLSearchParams({ returnTo });
if (mode === "bind") {
params.set("mode", "bind");
if (mode === "bind" || mode === "onboard") {
params.set("mode", mode);
}
return `/api/auth/wecom/login?${params.toString()}`;
}
@@ -219,14 +234,24 @@ async function getWecomAccessToken(config) {
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/gettoken");
url.searchParams.set("corpid", config.wecomCorpId);
url.searchParams.set("corpsecret", config.wecomAgentSecret);
const response = await fetch(url);
let response;
try {
response = await fetch(url);
} catch {
throw new AppError({
code: "AUTH_003",
message: "企业微信认证失败",
statusCode: 401,
details: { wecom: "token_exchange_failed" },
});
}
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0 || !payload.access_token) {
throw new AppError({
code: "AUTH_003",
message: "企业微信认证失败",
statusCode: 401,
details: { wecom: "token_exchange_failed", payload },
details: { wecom: "token_exchange_failed" },
});
}
return String(payload.access_token);
@@ -237,14 +262,24 @@ async function exchangeWecomCode(config, code) {
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo");
url.searchParams.set("access_token", accessToken);
url.searchParams.set("code", code);
const response = await fetch(url);
let response;
try {
response = await fetch(url);
} catch {
throw new AppError({
code: "AUTH_003",
message: "企业微信认证失败",
statusCode: 401,
details: { wecom: "userinfo_exchange_failed" },
});
}
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) {
throw new AppError({
code: "AUTH_003",
message: "企业微信认证失败",
statusCode: 401,
details: { wecom: "userinfo_exchange_failed", payload },
details: { wecom: "userinfo_exchange_failed" },
});
}
const payloadKeys = payload && typeof payload === "object" ? Object.keys(payload).sort() : [];
@@ -261,6 +296,8 @@ async function exchangeWecomCode(config, code) {
externalUserId: pickCandidate(payload.UserId, payload.userid),
externalOpenUserId: pickCandidate(payload.OpenId, payload.openid),
deviceId: pickCandidate(payload.DeviceId, payload.deviceid),
userTicket: pickCandidate(payload.user_ticket),
accessToken,
diagnostics: {
errcode: Number(payload.errcode ?? 0),
errmsg: typeof payload.errmsg === "string" ? payload.errmsg : null,
@@ -280,9 +317,9 @@ async function exchangeWecomCode(config, code) {
};
}
function buildWecomAuthorizeUrl(config, state) {
function buildWecomAuthorizeUrl(config, state, mode = "login") {
const redirectUri = encodeURIComponent(config.wecomRedirectUri);
const scope = "snsapi_base";
const scope = mode === "onboard" ? "snsapi_privateinfo" : "snsapi_base";
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`;
}
@@ -364,6 +401,8 @@ async function wecomAuthRoutes(fastify) {
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
} else if (mode === "onboard") {
assertWecomOnboardingReady(fastify.config);
} else {
assertWecomEntryReady(fastify.config);
}
@@ -376,7 +415,7 @@ async function wecomAuthRoutes(fastify) {
mode,
deviceFingerprint,
}, fastify.config.jwtSecret);
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state));
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode));
});
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -427,7 +466,7 @@ async function wecomAuthRoutes(fastify) {
if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) {
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
}
const mode = ctx.mode === "bind" ? "bind" : "login";
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request);
const profile = await exchangeWecomCode(fastify.config, code);
if (!profile.externalUserId) {
@@ -459,6 +498,48 @@ async function wecomAuthRoutes(fastify) {
const origin = resolvePrimaryAppOrigin(fastify.config, request);
if (mode === "onboard") {
assertWecomOnboardingReady(fastify.config);
if (!profile.userTicket) {
return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=consent_required`);
}
try {
const onboardingProfile = await fetchWecomOAuthOnboardingProfile({
accessToken: profile.accessToken,
externalUserId: profile.externalUserId,
userTicket: profile.userTicket,
});
const employee = await provisionWecomOAuthEmployee({
prisma: fastify.prisma,
request,
profile: onboardingProfile,
externalOpenUserId: profile.externalOpenUserId,
});
const deviceFingerprint = resolveOAuthDeviceFingerprint(ctx.deviceFingerprint);
const session = await createAuthenticatedSession({
prisma: fastify.prisma,
request,
config: fastify.config,
employeeId: employee.id,
tokenVersion: employee.token_version,
deviceFingerprint,
auditAction: "AUTH_WECOM_ONBOARDING_LOGIN_SUCCESS",
extra: { created: employee.created },
});
reply.setCookie(fastify.config.refreshCookieName, session.refreshToken, refreshCookieOptions(fastify.config));
reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config));
reply.setCookie(DEVICE_FINGERPRINT_COOKIE_NAME, deviceFingerprint, deviceFingerprintCookieOptions(fastify.config));
return reply.redirect(`${origin}${returnTo}`);
} catch (error) {
if (error instanceof WecomOAuthOnboardingError) {
return reply.redirect(
`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=${encodeURIComponent(error.code)}`,
);
}
throw error;
}
}
if (mode === "bind") {
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
const bindVerified = verifyToken(bindCookie, fastify.config.jwtSecret);
@@ -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,
};