feat: 企微通讯录选人替换手填 userid(项目负责人/总群成员/群主合作方)

- wecom-client 新增 listInternalContacts:department/list + 逐部门 user/simplelist(cursor 翻页、去重、部门名映射)
- 新 action wecom_list_internal_contacts(仅返回应用可见范围内成员)
- admin 新增 WecomUserPickerModal:单选点行填入/多选勾选合并,姓名+userid 搜索,5 分钟共享缓存
- 三处接线:项目负责人(单选)、项目总群成员(多选合并去重)、新建群主合作方(单选)
- 拉取失败(60020 等)提示手填兜底,手填路径不变
This commit is contained in:
ABAPPLO
2026-09-03 13:52:10 +08:00
parent bd80b2d7d8
commit 5430624a43
5 changed files with 559 additions and 13 deletions

View File

@@ -767,3 +767,124 @@ export async function sendAppChatMessage(
);
}
async function getWecomJson(
config: WecomConfig,
path: string,
params: Record<string, string>,
errorLabel: string,
fetchImpl: FetchLike,
): Promise<Record<string, unknown>> {
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<string, unknown>;
}
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<WecomInternalContact[]> {
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<string, string>();
for (const dept of departments) {
const id = String(dept?.id ?? "");
if (id) departmentNames.set(id, bindingValue(dept?.name));
}
const contacts = new Map<string, WecomInternalContact>();
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<string, string> = {
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"),
);
}