feat: wecom-client 新增网页授权能力(授权 URL / code 换身份 / 外部联系人显示名)
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user