const WECOM_API_ORIGIN = "https://qyapi.weixin.qq.com"; const DEFAULT_DUE_DAYS = 3; export type WecomBindings = { WECOM_CORP_ID?: string; WECOM_AGENT_ID?: string; WECOM_SECRET?: string; WECOM_ROBOT_WEBHOOK?: string; WECOM_NOTIFY_DUE_DAYS?: string; }; export type WecomConfig = { corpId: string; agentId: string; secret: string; robotWebhook: string; dueDays: number; }; type FetchLike = typeof fetch; type WecomEnvelope = { errcode?: number; errmsg?: string; access_token?: string; expires_in?: number; invaliduser?: string; userid?: string; follow_user?: Array<{ userid: string }>; external_userid?: string[]; external_contact?: { external_userid?: string; name?: string; avatar?: string; corp_fullname?: string; }; next_cursor?: string; group_chat_list?: Array<{ chat_id?: string; status?: number }>; group_chat?: { name?: string; owner?: string; member_count?: number; }; fail_list?: string[]; msgid?: string; }; export type WecomExternalContact = { externalUserId: string; name: string; avatar: string; corpName: string; ownerUserId: string; }; type CachedAccessToken = { corpId: string; secret: string; token: string; expiresAt: number; }; let cachedAccessToken: CachedAccessToken | null = null; export class WecomClientError extends Error { status: number; constructor(message: string, status = 502) { super(message); this.name = "WecomClientError"; this.status = status; } } function bindingValue(value: unknown) { return String(value ?? "").trim(); } function safeMessage(value: unknown) { return String(value ?? "").trim().slice(0, 240); } export function resolveWecomConfig(bindings: WecomBindings): WecomConfig { return { corpId: bindingValue(bindings.WECOM_CORP_ID), agentId: bindingValue(bindings.WECOM_AGENT_ID), secret: bindingValue(bindings.WECOM_SECRET), robotWebhook: bindingValue(bindings.WECOM_ROBOT_WEBHOOK), dueDays: parseDueDays(bindings.WECOM_NOTIFY_DUE_DAYS), }; } function parseDueDays(value: string | undefined) { const parsed = Number(bindingValue(value)); if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_DUE_DAYS; return Math.min(30, Math.floor(parsed)); } export function hasAppCredentials(config: WecomConfig) { return Boolean(config.corpId && config.agentId && config.secret); } async function readEnvelope( response: Response, fallbackMessage: string, ): Promise { const text = await response.text(); try { return JSON.parse(text) as WecomEnvelope; } catch { throw new WecomClientError( `${fallbackMessage}(企业微信返回了非 JSON 响应)`, 502, ); } } function ensureOk( payload: WecomEnvelope, fallbackMessage: string, ) { const code = Number(payload.errcode ?? 0); if (code === 0) return; const message = safeMessage(payload.errmsg) || fallbackMessage; if (code === 40014 || code === 42001) { throw new WecomClientError(`企业微信 access_token 无效:${message}`, 401); } throw new WecomClientError(`${fallbackMessage}(${message})`, 502); } async function fetchAccessToken( config: WecomConfig, fetchImpl: FetchLike, ) { if ( cachedAccessToken?.corpId === config.corpId && cachedAccessToken?.secret === config.secret && cachedAccessToken.expiresAt > Date.now() + 60_000 ) { return cachedAccessToken.token; } const url = new URL(`${WECOM_API_ORIGIN}/cgi-bin/gettoken`); url.searchParams.set("corpid", config.corpId); url.searchParams.set("corpsecret", config.secret); const response = await fetchImpl(url.toString(), { method: "GET", signal: AbortSignal.timeout(12_000), }); const payload = await readEnvelope(response, "获取企业微信 access_token 失败"); if (!response.ok) { throw new WecomClientError( `获取企业微信 access_token 失败(HTTP ${response.status})`, 502, ); } ensureOk(payload, "获取企业微信 access_token 失败"); const token = bindingValue(payload.access_token); if (!token) { throw new WecomClientError("企业微信未返回有效 access_token", 502); } cachedAccessToken = { corpId: config.corpId, secret: config.secret, token, expiresAt: Date.now() + Math.max(300, Number(payload.expires_in) || 7_200) * 1_000, }; return token; } export async function sendWecomRobotMessage( content: string, config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { if (!config.robotWebhook) { console.warn("[KOC LOOP] wecom robot webhook not configured, skipping"); return; } const response = await fetchImpl(config.robotWebhook, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ msgtype: "text", text: { content }, }), signal: AbortSignal.timeout(10_000), }); const payload = await readEnvelope(response, "企业微信群机器人推送失败"); if (!response.ok) { throw new WecomClientError( `企业微信群机器人推送失败(HTTP ${response.status})`, 502, ); } ensureOk(payload, "企业微信群机器人推送失败"); } export async function sendWecomAppMessage( externalUserIds: string[], content: string, config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise<{ sent: number; failed: number; skipped: boolean }> { const normalized = externalUserIds .map((id) => bindingValue(id)) .filter((id) => id.length > 0); if (normalized.length === 0) { return { sent: 0, failed: 0, skipped: true }; } if (!hasAppCredentials(config)) { return { sent: 0, failed: 0, skipped: true }; } const token = await fetchAccessToken(config, fetchImpl); const url = `${WECOM_API_ORIGIN}/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`; const response = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ touser: normalized.join("|"), msgtype: "text", agentid: Number(config.agentId), text: { content }, }), signal: AbortSignal.timeout(12_000), }); const payload = await readEnvelope(response, "企业微信应用消息推送失败"); if (!response.ok) { throw new WecomClientError( `企业微信应用消息推送失败(HTTP ${response.status})`, 502, ); } ensureOk(payload, "企业微信应用消息推送失败"); const invalid = bindingValue(payload.invaliduser).split("|").filter(Boolean); return { sent: Math.max(0, normalized.length - invalid.length), failed: invalid.length, skipped: false, }; } export function clearWecomAccessTokenCacheForTests() { cachedAccessToken = null; } export type WecomTemplateCardButton = { key: string; name: string; url?: string; }; export async function sendWecomTemplateCard( config: WecomConfig, params: { touser: string[]; taskId: string; title: string; description: string; url: string; buttons: WecomTemplateCardButton[]; }, fetchImpl: FetchLike = fetch, ): Promise<{ sent: number; failed: number; skipped: boolean }> { const normalized = params.touser .map((id) => bindingValue(id)) .filter((id) => id.length > 0); if (normalized.length === 0) { return { sent: 0, failed: 0, skipped: true }; } if (!hasAppCredentials(config)) { return { sent: 0, failed: 0, skipped: true }; } const taskId = bindingValue(params.taskId); if (!taskId) { throw new WecomClientError("模板卡片缺少 task_id(回调识别用)", 400); } const token = await fetchAccessToken(config, fetchImpl); const url = `${WECOM_API_ORIGIN}/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`; const response = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ touser: normalized.join("|"), msgtype: "template_card", agentid: Number(config.agentId), template_card: { card_type: "text_notice", source: { desc: "KOC LOOP", desc_color: 0, }, main_title: { title: safeMessage(params.title), desc: safeMessage(params.description).slice(0, 120), }, card_action: { type: 1, url: bindingValue(params.url), }, button_list: params.buttons.map((button) => ({ type: button.url ? 1 : 0, text: safeMessage(button.name), key: bindingValue(button.key), ...(button.url ? { url: button.url } : {}), })), task_id: taskId, }, }), signal: AbortSignal.timeout(12_000), }); const payload = await readEnvelope(response, "企业微信模板卡片推送失败"); if (!response.ok) { throw new WecomClientError( `企业微信模板卡片推送失败(HTTP ${response.status})`, 502, ); } ensureOk(payload, "企业微信模板卡片推送失败"); const invalid = bindingValue(payload.invaliduser).split("|").filter(Boolean); return { sent: Math.max(0, normalized.length - invalid.length), failed: invalid.length, skipped: false, }; } export type WecomGroupChat = { chatId: string; name: string; ownerUserId: string; memberCount: number; status: number; }; export async function listCustomerGroupChats( config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { if (!hasAppCredentials(config)) return []; const token = await fetchAccessToken(config, fetchImpl); const chatIds: Array<{ chatId: string; status: number }> = []; let cursor = ""; do { const listUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/groupchat/list?access_token=${encodeURIComponent(token)}`; const listResp = await fetchImpl(listUrl, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ limit: 100, cursor }), signal: AbortSignal.timeout(12_000), }); const listPayload = await readEnvelope(listResp, "获取企业微信客户群列表失败"); if (!listResp.ok) { throw new WecomClientError( `获取企业微信客户群列表失败(HTTP ${listResp.status})`, 502, ); } ensureOk(listPayload, "获取企业微信客户群列表失败"); for (const chat of listPayload.group_chat_list ?? []) { const chatId = bindingValue(chat.chat_id); if (chatId) chatIds.push({ chatId, status: Number(chat.status ?? 0) }); } cursor = bindingValue(listPayload.next_cursor); } while (cursor); const groups: WecomGroupChat[] = []; for (const { chatId, status } of chatIds) { if (status !== 0) continue; const getUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/groupchat/get?access_token=${encodeURIComponent(token)}`; const getResp = await fetchImpl(getUrl, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ chat_id: chatId }), signal: AbortSignal.timeout(12_000), }); const getPayload = await readEnvelope(getResp, "获取企业微信客户群详情失败"); if (!getResp.ok) continue; if (Number(getPayload.errcode ?? 0) !== 0) continue; const detail = getPayload.group_chat; groups.push({ chatId, name: bindingValue(detail?.name), ownerUserId: bindingValue(detail?.owner), memberCount: Number(detail?.member_count ?? 0), status, }); } return groups; } export type WecomGroupMsgLink = { title: string; desc?: string; url: string; picurl?: string; }; export async function createGroupMsgTemplate( config: WecomConfig, params: { sender: string; chatIdList: string[]; text?: string; link?: WecomGroupMsgLink; }, fetchImpl: FetchLike = fetch, ): Promise<{ msgid: string; failList: string[] }> { if (!hasAppCredentials(config)) { throw new WecomClientError( "企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)", 400, ); } const chatIdList = params.chatIdList.map(bindingValue).filter(Boolean); if (chatIdList.length === 0) { throw new WecomClientError("客户群列表为空", 400); } const sender = bindingValue(params.sender); if (!sender) { throw new WecomClientError("发送成员(群主 userid)不能为空", 400); } const text = String(params.text ?? "").trim(); const link = params.link; if (!text && !link) { throw new WecomClientError("文本与图文附件不能同时为空", 400); } const token = await fetchAccessToken(config, fetchImpl); const url = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/add_msg_template?access_token=${encodeURIComponent(token)}`; const response = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ chat_type: "group", chat_id_list: chatIdList, sender, ...(text ? { text: { content: text } } : {}), ...(link ? { attachments: [ { msgtype: "link", link: { title: link.title, desc: link.desc ?? "", url: link.url, picurl: link.picurl ?? "", }, }, ], } : {}), }), signal: AbortSignal.timeout(12_000), }); const payload = await readEnvelope(response, "创建企业群发失败"); if (!response.ok) { throw new WecomClientError( `创建企业群发失败(HTTP ${response.status})`, 502, ); } ensureOk(payload, "创建企业群发失败"); return { msgid: bindingValue(payload.msgid), failList: (payload.fail_list ?? []).filter(Boolean), }; } export async function listExternalContacts( config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { if (!hasAppCredentials(config)) return []; const token = await fetchAccessToken(config, fetchImpl); const followUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get_follow_user_list?access_token=${encodeURIComponent(token)}`; const followResp = await fetchImpl(followUrl, { method: "GET", signal: AbortSignal.timeout(12_000), }); const followPayload = await readEnvelope( followResp, "获取企业微信跟进人列表失败", ); if (!followResp.ok) { throw new WecomClientError( `获取企业微信跟进人列表失败(HTTP ${followResp.status})`, 502, ); } ensureOk(followPayload, "获取企业微信跟进人列表失败"); const ownerUserIds = (followPayload.follow_user ?? []) .map((user) => bindingValue(user.userid)) .filter(Boolean); const ownerToExternalIds: Array<{ externalUserId: string; ownerUserId: string }> = []; for (const ownerUserId of ownerUserIds) { const listUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/list?access_token=${encodeURIComponent(token)}&userid=${encodeURIComponent(ownerUserId)}`; const listResp = await fetchImpl(listUrl, { method: "GET", signal: AbortSignal.timeout(12_000), }); const listPayload = await readEnvelope( listResp, "获取企业微信外部联系人列表失败", ); if (!listResp.ok) continue; if (Number(listPayload.errcode ?? 0) !== 0) continue; const externalIds = (listPayload.external_userid ?? []).filter(Boolean); for (const externalUserId of externalIds) { ownerToExternalIds.push({ externalUserId, ownerUserId }); } } const contacts: WecomExternalContact[] = []; for (const { externalUserId, ownerUserId } of ownerToExternalIds) { const getUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get?access_token=${encodeURIComponent(token)}&external_userid=${encodeURIComponent(externalUserId)}`; const getResp = await fetchImpl(getUrl, { method: "GET", signal: AbortSignal.timeout(12_000), }); const getPayload = await readEnvelope( getResp, "获取企业微信外部联系人详情失败", ); if (!getResp.ok) continue; if (Number(getPayload.errcode ?? 0) !== 0) continue; const info = getPayload.external_contact; if (!info) continue; contacts.push({ externalUserId, name: bindingValue(info.name), avatar: bindingValue(info.avatar), corpName: bindingValue(info.corp_fullname) || bindingValue(info.name), ownerUserId, }); } 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 { 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 { 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; } } async function postAppChat( config: WecomConfig, path: string, body: Record, errorLabel: string, fetchImpl: FetchLike, ): Promise> { if (!hasAppCredentials(config)) { throw new WecomClientError( "企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)", 400, ); } const token = await fetchAccessToken(config, fetchImpl); const url = `${WECOM_API_ORIGIN}${path}?access_token=${encodeURIComponent(token)}`; const response = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify(body), signal: AbortSignal.timeout(12_000), }); const payload = await readEnvelope(response, errorLabel); if (!response.ok) { throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502); } ensureOk(payload, errorLabel); return payload; } export async function createAppChat( config: WecomConfig, params: { name: string; userlist: string[]; chatid: string }, fetchImpl: FetchLike = fetch, ): Promise<{ chatid: string }> { const userlist = params.userlist .map((id) => bindingValue(id)) .filter((id) => id.length > 0); if (userlist.length < 2) { throw new WecomClientError("创建群聊会话至少需要 2 名成员", 400); } const chatid = bindingValue(params.chatid); if (!chatid || chatid.length > 32) { throw new WecomClientError("群聊 chatid 缺失或超过 32 字符", 400); } const name = bindingValue(params.name).slice(0, 20); const payload = await postAppChat( config, "/cgi-bin/appchat/create", { name, owner: Number(config.agentId), userlist, chatid }, "创建企业微信群聊会话失败", fetchImpl, ); return { chatid: bindingValue(payload.chatid) || chatid }; } export async function updateAppChat( config: WecomConfig, params: { chatid: string; addUserList?: string[]; delUserList?: string[]; }, fetchImpl: FetchLike = fetch, ): Promise { const body: Record = { chatid: bindingValue(params.chatid), }; const addUserList = (params.addUserList ?? []) .map((id) => bindingValue(id)) .filter((id) => id.length > 0); const delUserList = (params.delUserList ?? []) .map((id) => bindingValue(id)) .filter((id) => id.length > 0); if (addUserList.length === 0 && delUserList.length === 0) return; if (addUserList.length > 0) body.add_user_list = addUserList; if (delUserList.length > 0) body.del_user_list = delUserList; await postAppChat( config, "/cgi-bin/appchat/update", body, "更新企业微信群聊会话失败", fetchImpl, ); } export async function getAppChat( config: WecomConfig, chatid: string, fetchImpl: FetchLike = fetch, ): Promise<{ chatId: string; name: string; userlist: string[] } | null> { try { const payload = await postAppChat( config, "/cgi-bin/appchat/get", { chatid: bindingValue(chatid) }, "获取企业微信群聊会话失败", fetchImpl, ); const detail = payload.chat_info as | { chatid?: unknown; name?: unknown; userlist?: unknown[] } | undefined; if (!detail) return null; return { chatId: bindingValue(detail.chatid), name: bindingValue(detail.name), userlist: Array.isArray(detail.userlist) ? detail.userlist.map((id: unknown) => bindingValue(id)).filter(Boolean) : [], }; } catch { return null; } } export async function sendAppChatMessage( config: WecomConfig, params: { chatid: string; text: string }, fetchImpl: FetchLike = fetch, ): Promise { const chatid = bindingValue(params.chatid); const content = bindingValue(params.text); if (!chatid || !content) return; await postAppChat( config, "/cgi-bin/appchat/send", { chatid, safe: 0, msgtype: "text", text: { content } }, "发送企业微信群聊消息失败", fetchImpl, ); } async function getWecomJson( config: WecomConfig, path: string, params: Record, errorLabel: string, fetchImpl: FetchLike, ): Promise> { if (!hasAppCredentials(config)) { throw new WecomClientError( "企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)", 400, ); } const token = await fetchAccessToken(config, fetchImpl); const query = new URLSearchParams({ access_token: token, ...params }); const response = await fetchImpl(`${WECOM_API_ORIGIN}${path}?${query}`, { method: "GET", signal: AbortSignal.timeout(12_000), }); const payload = await readEnvelope(response, errorLabel); if (!response.ok) { throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502); } ensureOk(payload, errorLabel); return payload as Record; } export type WecomInternalContact = { userid: string; name: string; departmentNames: string[]; }; const INTERNAL_CONTACT_DEPARTMENT_LIMIT = 100; const INTERNAL_CONTACT_CURSOR_ROUNDS = 10; export async function listInternalContacts( config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { const deptPayload = await getWecomJson( config, "/cgi-bin/department/list", {}, "获取企业微信部门列表失败", fetchImpl, ); const departments = ( Array.isArray(deptPayload.department) ? (deptPayload.department as { id?: unknown; name?: unknown }[]) : [] ).slice(0, INTERNAL_CONTACT_DEPARTMENT_LIMIT); const departmentNames = new Map(); for (const dept of departments) { const id = String(dept?.id ?? ""); if (id) departmentNames.set(id, bindingValue(dept?.name)); } const contacts = new Map(); for (const dept of departments) { const departmentId = String(dept?.id ?? ""); if (!departmentId) continue; let cursor = ""; for (let round = 0; round < INTERNAL_CONTACT_CURSOR_ROUNDS; round += 1) { const params: Record = { department_id: departmentId, fetch_child: "0", }; if (cursor) params.cursor = cursor; const payload = await getWecomJson( config, "/cgi-bin/user/simplelist", params, "获取企业微信部门成员失败", fetchImpl, ); const userlist = Array.isArray(payload.userlist) ? (payload.userlist as { userid?: unknown; name?: unknown; department?: unknown[]; }[]) : []; for (const user of userlist) { const userid = bindingValue(user?.userid); if (!userid) continue; const name = bindingValue(user?.name); const deptIds = ( Array.isArray(user?.department) ? user.department.map((id) => String(id)).filter(Boolean) : [departmentId] ); const existing = contacts.get(userid); if (existing) { if (!existing.name && name) existing.name = name; for (const deptId of deptIds) { const deptName = departmentNames.get(deptId); if (deptName && !existing.departmentNames.includes(deptName)) { existing.departmentNames.push(deptName); } } } else { contacts.set(userid, { userid, name, departmentNames: deptIds .map((id) => departmentNames.get(id)) .filter((deptName): deptName is string => Boolean(deptName)), }); } } const nextCursor = bindingValue(payload.next_cursor); if (!nextCursor || nextCursor === cursor) break; cursor = nextCursor; } } return [...contacts.values()].sort((a, b) => `${a.name}${a.userid}`.localeCompare(`${b.name}${b.userid}`, "zh"), ); }