WAPI 固化企微 API 能力目录与读取口径

这个提交包含在:
2026-05-18 22:36:56 +08:00
父节点 6273201a13
当前提交 d81b5425a7
共修改 13 个文件,包含 276 行新增和 153 行删除
@@ -182,6 +182,7 @@ function parseDateOnly(value) {
function sanitizeWecomPayload(payload) {
const base = sanitizeWecomResponse(payload) ?? {};
if (Array.isArray(payload?.userlist)) base.userCount = payload.userlist.length;
if (Array.isArray(payload?.dept_user)) base.deptUserCount = payload.dept_user.length;
if (Array.isArray(payload?.department)) base.departmentCount = payload.department.length;
if (payload?.userid != null) base.useridPrefix = maskExternalUserId(payload.userid);
if (payload?.enable != null) base.enable = payload.enable;
@@ -289,21 +290,15 @@ async function getTokenFromCandidates({ config, candidates, timeoutMs, fetchImpl
function contactTokenCandidates(config) {
return [
{ source: "WECOM_CONTACT_SECRET", secret: config?.wecomContactSecret },
{ source: "WECOM_AGENT_SECRET_FALLBACK", secret: config?.wecomAgentSecret },
];
}
function hrTokenCandidates(config) {
return [
{ source: "WECOM_HR_ASSISTANT_SECRET", secret: config?.wecomHrAssistantSecret },
{ source: "WECOM_AGENT_SECRET_FALLBACK", secret: config?.wecomAgentSecret },
{ source: "WECOM_AGENT_SECRET", secret: config?.wecomAgentSecret },
];
}
function canFallbackToAgentSecret(error) {
return error instanceof WecomHrMasterSyncError && error.code === "wecom_errcode_48009";
}
function normalizeDepartmentMap(payload) {
const map = new Map();
const departments = Array.isArray(payload?.department) ? payload.department : [];
@@ -332,17 +327,65 @@ async function fetchDepartmentMap({ accessToken, fetchImpl, timeoutMs }) {
return normalizeDepartmentMap(result.payload);
}
async function listContactMembers({ accessToken, departmentId, limit, fetchImpl, timeoutMs }) {
const result = await fetchWecomJson("/cgi-bin/user/list", accessToken, {
query: { department_id: departmentId, fetch_child: 1 },
fetchImpl,
timeoutMs,
});
if (!result.ok) {
const error = classifyWecomResult(result, "wecom_user_list_failed");
throw new WecomHrMasterSyncError(error.code, error.message, { responseSummary: error.responseSummary });
async function listContactMembers({ accessToken, limit, fetchImpl, timeoutMs }) {
const membersByUserid = new Map();
let cursor = "";
do {
const result = await fetchWecomJson("/cgi-bin/user/list_id", accessToken, {
body: {
cursor,
limit: Math.min(10000, Math.max(1, limit - membersByUserid.size)),
},
fetchImpl,
timeoutMs,
});
if (!result.ok) {
const error = classifyWecomResult(result, "wecom_user_list_id_failed");
throw new WecomHrMasterSyncError(error.code, error.message, { responseSummary: error.responseSummary });
}
const batch = Array.isArray(result.payload?.dept_user) ? result.payload.dept_user : [];
for (const item of batch) {
const userid = toSafeString(item?.userid, 128);
if (!userid) continue;
const member = membersByUserid.get(userid) || { userid, department: [] };
if (item?.department != null && !member.department.includes(item.department)) {
member.department.push(item.department);
}
membersByUserid.set(userid, member);
if (membersByUserid.size >= limit) break;
}
cursor = toSafeString(result.payload?.next_cursor, 256);
} while (cursor && membersByUserid.size < limit);
const members = Array.from(membersByUserid.values()).slice(0, limit);
const contacts = await Promise.all(members.map(async (member) => {
const detail = await fetchOptionalContactMember({
accessToken,
userid: member.userid,
fetchImpl,
timeoutMs,
});
return {
...detail,
userid: detail.userid ?? member.userid,
department: Array.isArray(detail.department) && detail.department.length ? detail.department : member.department,
};
}));
return contacts;
}
async function fetchOptionalDepartmentMap({ accessToken, fetchImpl, timeoutMs }) {
try {
return await fetchDepartmentMap({ accessToken, fetchImpl, timeoutMs });
} catch (error) {
if (error instanceof WecomHrMasterSyncError) {
return new Map();
}
throw error;
}
return (Array.isArray(result.payload?.userlist) ? result.payload.userlist : []).slice(0, limit);
}
async function fetchContactMember({ accessToken, userid, fetchImpl, timeoutMs }) {
@@ -358,6 +401,17 @@ async function fetchContactMember({ accessToken, userid, fetchImpl, timeoutMs })
return result.payload;
}
async function fetchOptionalContactMember({ accessToken, userid, fetchImpl, timeoutMs }) {
try {
return await fetchContactMember({ accessToken, userid, fetchImpl, timeoutMs });
} catch (error) {
if (error instanceof WecomHrMasterSyncError) {
return { userid };
}
throw error;
}
}
async function fetchHrStaffInfo({ accessToken, userid, fetchImpl, timeoutMs }) {
const result = await fetchWecomJson("/cgi-bin/hr/get_staff_info", accessToken, {
body: { userid, get_all: true },
@@ -424,14 +478,13 @@ function normalizeWecomHrProfile({ contact = {}, staff = {}, departmentMap = new
}
async function fetchContactProfilesInput({ config, token, userids, limit, fetchImpl, timeoutMs }) {
const departmentMap = await fetchDepartmentMap({
const departmentMap = await fetchOptionalDepartmentMap({
accessToken: token.accessToken,
fetchImpl,
timeoutMs,
});
const departmentId = toSafeString(config.wecomContactDefaultDepartmentId, 32) || "1";
const contacts = userids.length
? await Promise.all(userids.map((userid) => fetchContactMember({
? await Promise.all(userids.map((userid) => fetchOptionalContactMember({
accessToken: token.accessToken,
userid,
fetchImpl,
@@ -439,7 +492,6 @@ async function fetchContactProfilesInput({ config, token, userids, limit, fetchI
})))
: await listContactMembers({
accessToken: token.accessToken,
departmentId,
limit,
fetchImpl,
timeoutMs,
@@ -448,55 +500,9 @@ async function fetchContactProfilesInput({ config, token, userids, limit, fetchI
return { departmentMap, contacts, tokenSource: token.source };
}
async function fetchContactProfilesInputWithFallback({ config, contactToken, userids, limit, fetchImpl, timeoutMs }) {
try {
return await fetchContactProfilesInput({
config,
token: contactToken,
userids,
limit,
fetchImpl,
timeoutMs,
});
} catch (error) {
if (!canFallbackToAgentSecret(error) || contactToken.source === "WECOM_AGENT_SECRET_FALLBACK") {
throw error;
}
const fallback = await getTokenFromCandidates({
config,
candidates: [{ source: "WECOM_AGENT_SECRET_FALLBACK", secret: config?.wecomAgentSecret }],
timeoutMs,
fetchImpl,
});
if (!fallback.ok) {
throw error;
}
try {
return await fetchContactProfilesInput({
config,
token: fallback,
userids,
limit,
fetchImpl,
timeoutMs,
});
} catch (fallbackError) {
if (canFallbackToAgentSecret(fallbackError)) {
fallbackError.details = {
...(fallbackError.details ?? {}),
firstTokenSource: contactToken.source,
fallbackTokenSource: fallback.source,
};
}
throw fallbackError;
}
}
}
async function fetchWecomHrProfiles({ config, userids, limit, fetchImpl = fetch }) {
const timeoutMs = config.wecomRequestTimeoutMs ?? 10000;
const readLimit = normalizeLimit(limit);
const contactToken = await getTokenFromCandidates({
config,
candidates: contactTokenCandidates(config),
@@ -517,11 +523,11 @@ async function fetchWecomHrProfiles({ config, userids, limit, fetchImpl = fetch
throw new WecomHrMasterSyncError(hrToken.code, hrToken.message, { missing: hrToken.missing });
}
const contactInput = await fetchContactProfilesInputWithFallback({
const contactInput = await fetchContactProfilesInput({
config,
contactToken,
token: contactToken,
userids,
limit,
limit: readLimit,
fetchImpl,
timeoutMs,
});
+47 -29
查看文件
@@ -111,66 +111,84 @@ describe("wecom HR master sync helpers", () => {
expect(changes.externalUserId.after).toBe(maskExternalUserId("oa_smoke002"));
});
it("falls back to the authorized app secret when the contact secret is forbidden for read APIs", async () => {
it("uses fixed contact and agent secrets with list_id for full contact inventory", async () => {
const calls = [];
const fetchImpl = async (url, init = {}) => {
const parsed = new URL(String(url));
calls.push({ path: parsed.pathname, token: parsed.searchParams.get("access_token") });
calls.push({
path: parsed.pathname,
token: parsed.searchParams.get("access_token"),
method: init.method ?? "GET",
body: init.body ? JSON.parse(init.body) : null,
secret: parsed.searchParams.get("corpsecret"),
});
if (parsed.pathname === "/cgi-bin/gettoken") {
const secret = parsed.searchParams.get("corpsecret");
if (secret === "contact-secret-whms-fallback") {
if (secret === "contact-secret-wapi") {
return jsonResponse({ errcode: 0, access_token: "contact-token", expires_in: 7200 });
}
if (secret === "agent-secret-whms-fallback") {
if (secret === "agent-secret-wapi") {
return jsonResponse({ errcode: 0, access_token: "agent-token", expires_in: 7200 });
}
if (secret === "hr-secret-whms-fallback") {
return jsonResponse({ errcode: 0, access_token: "hr-token", expires_in: 7200 });
}
}
if (parsed.pathname === "/cgi-bin/department/list" && parsed.searchParams.get("access_token") === "contact-token") {
return jsonResponse({ errcode: 48009, errmsg: "api forbidden for contact assistant" });
return jsonResponse({ errcode: 48009, errmsg: "department list not visible to contact app" });
}
if (parsed.pathname === "/cgi-bin/department/list" && parsed.searchParams.get("access_token") === "agent-token") {
return jsonResponse({ errcode: 0, department: [{ id: 1, name: "管理部", parentid: 0 }] });
}
if (parsed.pathname === "/cgi-bin/user/get" && parsed.searchParams.get("access_token") === "agent-token") {
if (parsed.pathname === "/cgi-bin/user/list_id" && parsed.searchParams.get("access_token") === "contact-token") {
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ cursor: "", limit: 2 });
return jsonResponse({
errcode: 0,
userid: "oa_smoke002",
name: "测试员工",
mobile: "19912345678",
email: "[email protected]",
department: [1],
enable: 1,
dept_user: [
{ userid: "oa_smoke002", department: 1 },
{ userid: "oa_smoke002", department: 2 },
],
next_cursor: "",
});
}
if (parsed.pathname === "/cgi-bin/hr/get_staff_info" && parsed.searchParams.get("access_token") === "hr-token") {
if (parsed.pathname === "/cgi-bin/user/get" && parsed.searchParams.get("access_token") === "contact-token") {
return jsonResponse({ errcode: 48009, errmsg: "api forbidden for contact assistant" });
}
if (parsed.pathname === "/cgi-bin/hr/get_staff_info" && parsed.searchParams.get("access_token") === "agent-token") {
expect(init.method).toBe("POST");
return jsonResponse({ errcode: 0, staff_info: [{ fieldid: "在职状态", value: "在职" }] });
return jsonResponse({
errcode: 0,
staff_info: [
{ fieldid: "name", value: "测试员工" },
{ fieldid: "mobile", value: "19912345678" },
{ fieldid: "email", value: "[email protected]" },
{ fieldid: "department", value: "研发部" },
{ fieldid: "在职状态", value: "在职" },
],
});
}
return jsonResponse({ errcode: 40001, errmsg: "unexpected mock request" });
};
const result = await fetchWecomHrProfiles({
config: {
wecomCorpId: "corp-whms-fallback",
wecomCorpId: "corp-wapi",
wecomAgentId: "1000002",
wecomContactSecret: "contact-secret-whms-fallback",
wecomAgentSecret: "agent-secret-whms-fallback",
wecomHrAssistantSecret: "hr-secret-whms-fallback",
wecomContactSecret: "contact-secret-wapi",
wecomAgentSecret: "agent-secret-wapi",
wecomHrAssistantSecret: "deprecated-hr-secret-should-not-be-used",
wecomContactDefaultDepartmentId: "1",
},
userids: ["oa_smoke002"],
limit: 1,
userids: [],
limit: 2,
fetchImpl,
});
expect(result.tokenSource.contact).toBe("WECOM_AGENT_SECRET_FALLBACK");
expect(result.tokenSource.hr).toBe("WECOM_HR_ASSISTANT_SECRET");
expect(result.tokenSource.contact).toBe("WECOM_CONTACT_SECRET");
expect(result.tokenSource.hr).toBe("WECOM_AGENT_SECRET");
expect(result.profiles).toHaveLength(1);
expect(result.profiles[0].externalUserId).toBe("oa_smoke002");
expect(result.profiles[0].name).toBe("测试员工");
expect(result.profiles[0].missingRequiredFields).toEqual([]);
expect(calls.some((call) => call.path === "/cgi-bin/department/list" && call.token === "contact-token")).toBe(true);
expect(calls.some((call) => call.path === "/cgi-bin/department/list" && call.token === "agent-token")).toBe(true);
expect(calls.some((call) => call.path === "/cgi-bin/user/list_id" && call.token === "contact-token")).toBe(true);
expect(calls.some((call) => call.path === "/cgi-bin/user/get" && call.token === "contact-token")).toBe(true);
expect(calls.some((call) => call.path === "/cgi-bin/hr/get_staff_info" && call.token === "agent-token")).toBe(true);
expect(calls.some((call) => call.secret === "deprecated-hr-secret-should-not-be-used")).toBe(false);
});
});