feat: wecom-client 新增网页授权能力(授权 URL / code 换身份 / 外部联系人显示名)

This commit is contained in:
ABAPPLO
2026-08-31 17:10:03 +08:00
parent a6f5a5fd4a
commit 7f6c0e0983
2 changed files with 262 additions and 0 deletions

View File

@@ -25,6 +25,7 @@ type WecomEnvelope = {
access_token?: string; access_token?: string;
expires_in?: number; expires_in?: number;
invaliduser?: string; invaliduser?: string;
userid?: string;
follow_user?: Array<{ userid: string }>; follow_user?: Array<{ userid: string }>;
external_userid?: string[]; external_userid?: string[];
external_contact?: { external_contact?: {
@@ -458,3 +459,98 @@ export async function listExternalContacts(
} }
return contacts; return contacts;
} }
export function buildWecomOAuthUrl(params: {
corpId: string;
agentId: string;
redirectUri: string;
state: string;
}): string {
const query = new URLSearchParams({
appid: params.corpId,
redirect_uri: params.redirectUri,
response_type: "code",
scope: "snsapi_base",
state: params.state,
agentid: params.agentId,
});
return `https://open.weixin.qq.com/connect/oauth2/authorize?${query.toString()}#wechat_redirect`;
}
export type WecomOAuthIdentity = {
userId?: string;
externalUserId?: string;
};
export async function exchangeWecomOAuthCode(
config: WecomConfig,
code: string,
fetchImpl: FetchLike = fetch,
): Promise<WecomOAuthIdentity> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const authorizationCode = bindingValue(code);
if (!authorizationCode) {
throw new WecomClientError("缺少企业微信授权 code", 400);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/auth/getuserinfo?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ code: authorizationCode }),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "企业微信授权身份换取失败");
if (!response.ok) {
throw new WecomClientError(
`企业微信授权身份换取失败(HTTP ${response.status})`,
502,
);
}
if (Number(payload.errcode ?? 0) === 40029) {
throw new WecomClientError("企业微信授权 code 无效或已过期", 400);
}
ensureOk(payload, "企业微信授权身份换取失败");
const userId = bindingValue(payload.userid);
const externalUserId =
typeof payload.external_userid === "string"
? bindingValue(payload.external_userid)
: "";
if (!userId && !externalUserId) {
throw new WecomClientError("企业微信未返回有效身份", 400);
}
return {
...(userId ? { userId } : {}),
...(externalUserId ? { externalUserId } : {}),
};
}
export async function fetchWecomExternalContactName(
config: WecomConfig,
externalUserId: string,
fetchImpl: FetchLike = fetch,
): Promise<string | null> {
if (!hasAppCredentials(config)) return null;
const externalId = bindingValue(externalUserId);
if (!externalId) return null;
try {
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get?access_token=${encodeURIComponent(token)}&external_userid=${encodeURIComponent(externalId)}`;
const response = await fetchImpl(url, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "获取外部联系人详情失败");
if (!response.ok) return null;
if (Number(payload.errcode ?? 0) !== 0) return null;
const name = bindingValue(payload.external_contact?.name);
return name || null;
} catch {
return null;
}
}

View File

@@ -1,8 +1,11 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
buildWecomOAuthUrl,
clearWecomAccessTokenCacheForTests, clearWecomAccessTokenCacheForTests,
createGroupMsgTemplate, createGroupMsgTemplate,
exchangeWecomOAuthCode,
fetchWecomExternalContactName,
listCustomerGroupChats, listCustomerGroupChats,
listExternalContacts, listExternalContacts,
resolveWecomConfig, resolveWecomConfig,
@@ -605,3 +608,166 @@ test("createGroupMsgTemplate rejects missing sender or empty chat list", async (
/客户群列表为空/, /客户群列表为空/,
); );
}); });
function fakeWecomOAuth(options = {}) {
const calls = [];
const fetchImpl = async (input, init = {}) => {
const url = new URL(String(input));
calls.push({ url, init });
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
return Response.json({
errcode: 0,
errmsg: "ok",
access_token: "token-test",
expires_in: 7200,
});
}
if (url.pathname.endsWith("/cgi-bin/auth/getuserinfo")) {
return Response.json({
errcode: options.oauthErrcode ?? 0,
errmsg: options.oauthErrmsg ?? "ok",
...(options.userId ? { userid: options.userId } : {}),
...(options.externalUserId ? { external_userid: options.externalUserId } : {}),
});
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/get")) {
const eid = url.searchParams.get("external_userid");
if (options.contactNames && options.contactNames[eid]) {
return Response.json({
errcode: 0,
errmsg: "ok",
external_contact: { external_userid: eid, name: options.contactNames[eid] },
});
}
return Response.json({ errcode: 60111, errmsg: "not found" });
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl };
}
test("buildWecomOAuthUrl assembles authorize URL with encoded params", () => {
const url = new URL(
buildWecomOAuthUrl({
corpId: "corp-test",
agentId: "1000001",
redirectUri: "https://portal.example.com/koc/?task=abc",
state: "t=abc",
}),
);
assert.equal(url.origin, "https://open.weixin.qq.com");
assert.equal(url.pathname, "/connect/oauth2/authorize");
assert.equal(url.hash, "#wechat_redirect");
assert.equal(url.searchParams.get("appid"), "corp-test");
assert.equal(url.searchParams.get("agentid"), "1000001");
assert.equal(url.searchParams.get("response_type"), "code");
assert.equal(url.searchParams.get("scope"), "snsapi_base");
assert.equal(url.searchParams.get("state"), "t=abc");
assert.equal(
url.searchParams.get("redirect_uri"),
"https://portal.example.com/koc/?task=abc",
);
});
test("exchangeWecomOAuthCode returns external identity and posts code", async () => {
const { calls, fetchImpl } = fakeWecomOAuth({ externalUserId: "wmExt123" });
const identity = await exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-abc",
fetchImpl,
);
assert.deepEqual(identity, { externalUserId: "wmExt123" });
const call = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/auth/getuserinfo"),
);
assert.ok(call, "getuserinfo was called");
assert.equal(call.url.searchParams.get("access_token"), "token-test");
assert.equal(JSON.parse(String(call.init.body)).code, "code-abc");
});
test("exchangeWecomOAuthCode returns internal member userid", async () => {
const { fetchImpl } = fakeWecomOAuth({ userId: "zhangsan" });
const identity = await exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-abc",
fetchImpl,
);
assert.deepEqual(identity, { userId: "zhangsan" });
});
test("exchangeWecomOAuthCode throws 400 on invalid code (errcode 40029)", async () => {
const { fetchImpl } = fakeWecomOAuth({
oauthErrcode: 40029,
oauthErrmsg: "invalid code",
});
await assert.rejects(
() =>
exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-expired",
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 400);
return true;
},
);
});
test("exchangeWecomOAuthCode throws when provider returns no identity", async () => {
const { fetchImpl } = fakeWecomOAuth();
await assert.rejects(
() =>
exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-abc",
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 400);
return true;
},
);
});
test("exchangeWecomOAuthCode reuses cached access_token", async () => {
const { calls, fetchImpl } = fakeWecomOAuth({ externalUserId: "wmExt123" });
const config = resolveWecomConfig(baseBindings);
await exchangeWecomOAuthCode(config, "code-1", fetchImpl);
await exchangeWecomOAuthCode(config, "code-2", fetchImpl);
assert.equal(
calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/gettoken")).length,
1,
);
});
test("fetchWecomExternalContactName resolves name and falls back to null", async () => {
const okImpl = fakeWecomOAuth({ contactNames: { wmExt123: "张三" } });
assert.equal(
await fetchWecomExternalContactName(
resolveWecomConfig(baseBindings),
"wmExt123",
okImpl.fetchImpl,
),
"张三",
);
const failImpl = fakeWecomOAuth();
assert.equal(
await fetchWecomExternalContactName(
resolveWecomConfig(baseBindings),
"wmMissing",
failImpl.fetchImpl,
),
null,
);
assert.equal(
await fetchWecomExternalContactName(
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
"wmExt123",
failImpl.fetchImpl,
),
null,
);
});