feat: 任务发布到企微客户群(企业群发)与资源库单条新增
- 任务中心新增「发布到企微群」:同步客户群清单(wecom_group_chats)、 按群主分组创建企业群发任务(add_msg_template)、发送记录落库 wecom_group_pushes,迁移 mysql/0009;lib/wecom-client.ts 补 listCustomerGroupChats/createGroupMsgTemplate - KOC 资源库支持单条新增:app/api/resources-insert + lib/resource-write, 拆出资源写入公共逻辑供导入复用;0008 补合作方外部联系人字段 - 环境变量示例补企微凭证与 SEED_DEMO_DATA;next.config 增加 allowedDevOrigins;CLAUDE.md 补充项目说明 - .gitignore 排除 .codegraph/ 与 .ipynb_checkpoints/
This commit is contained in:
@@ -366,6 +366,11 @@ export async function ensureSchema(database?: DatabaseClient) {
|
||||
"last_collection_day",
|
||||
"last_collection_day INTEGER",
|
||||
);
|
||||
await ensureColumn(
|
||||
"partners",
|
||||
"wecom_external_user_id",
|
||||
"wecom_external_user_id TEXT",
|
||||
);
|
||||
|
||||
const tasksWithoutShare = await db
|
||||
.prepare(
|
||||
|
||||
@@ -337,6 +337,59 @@ function normalizeRows(rows: string[][]) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export type ManualResourceInput = {
|
||||
profileUrl: string;
|
||||
nickname?: string;
|
||||
publicAccountId?: string;
|
||||
ipLocation?: string;
|
||||
followers?: string;
|
||||
gender?: string;
|
||||
bio?: string;
|
||||
tags?: string | string[];
|
||||
cooperationSource?: string;
|
||||
};
|
||||
|
||||
export function buildManualRow(input: ManualResourceInput): ResourceImportRow {
|
||||
const rawProfileUrl = (input.profileUrl ?? "").trim();
|
||||
const profileUrl = normalizeProfileUrl(rawProfileUrl);
|
||||
const platform = platformFromProfileUrl(profileUrl);
|
||||
const parsedFollowers = parseResourceFollowers(input.followers ?? "");
|
||||
const parsedGender = normalizeResourceGender(input.gender ?? "");
|
||||
const tags = normalizeResourceTags(input.tags ?? "");
|
||||
const ipLocation = (input.ipLocation ?? "").trim();
|
||||
const errors: string[] = [];
|
||||
if (!rawProfileUrl) errors.push("账号主页不能为空");
|
||||
else if (!profileUrl) errors.push("账号主页链接格式不正确");
|
||||
else if (!platform) errors.push("当前自动解析仅支持小红书或抖音账号主页");
|
||||
if (!parsedFollowers.valid) {
|
||||
errors.push("粉丝数格式不正确,请填写数字或如 1.3万、10+");
|
||||
}
|
||||
if (!parsedGender.valid) {
|
||||
errors.push("性别格式不正确,请填写男、女或留空");
|
||||
}
|
||||
if (tags.length > 5) {
|
||||
errors.push("单个账号最多填写 5 个标签,请用逗号分隔");
|
||||
}
|
||||
if (ipLocation && /^\d+$/.test(ipLocation)) {
|
||||
errors.push("IP属地格式不正确,请填写省份、地区或国家名称");
|
||||
}
|
||||
return {
|
||||
rowNumber: 1,
|
||||
platform,
|
||||
nickname: (input.nickname ?? "").trim(),
|
||||
publicAccountId: (input.publicAccountId ?? "").trim(),
|
||||
profileUrl,
|
||||
ipLocation,
|
||||
followers: parsedFollowers.value,
|
||||
followersResolved: parsedFollowers.resolved,
|
||||
gender: parsedGender.value,
|
||||
bio: (input.bio ?? "").trim(),
|
||||
tags: tags.slice(0, 5),
|
||||
cooperationSource: (input.cooperationSource ?? "").trim(),
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseResourceImportFile(fileName: string, bytes: Uint8Array) {
|
||||
const extension = fileName.toLocaleLowerCase().split(".").pop();
|
||||
const workbooks =
|
||||
|
||||
366
lib/resource-write.ts
Normal file
366
lib/resource-write.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { ensureSchema, getRawDb } from "./mvp-db";
|
||||
import {
|
||||
resolveCollectionMcpConfig,
|
||||
resolveProfileDetailsFromMcp,
|
||||
resolveXhsPublicAccountDetails,
|
||||
type CollectionMcpBindings,
|
||||
} from "./mcp-collection-client";
|
||||
import { getRuntimeEnv } from "./runtime-env";
|
||||
import {
|
||||
mergeCooperationSources,
|
||||
normalizeProfileUrl,
|
||||
resourceImportMissingFields,
|
||||
resourcePlatformUid,
|
||||
type ResourceImportRow,
|
||||
} from "./resource-import";
|
||||
|
||||
export type AccountRow = {
|
||||
id: string;
|
||||
platform: string;
|
||||
platform_uid: string;
|
||||
public_account_id: string;
|
||||
nickname: string;
|
||||
profile_url: string;
|
||||
ip_location: string;
|
||||
followers: number;
|
||||
gender: string;
|
||||
bio: string;
|
||||
tags: string;
|
||||
cooperation_source: string;
|
||||
};
|
||||
|
||||
export type AnalyzedRow = ResourceImportRow & {
|
||||
action: "create" | "update" | "error";
|
||||
accountId: string;
|
||||
platformUid: string;
|
||||
cooperationSource: string;
|
||||
};
|
||||
|
||||
export const RESOURCE_IMPORT_SYNC_ENRICH_ROWS = 100;
|
||||
const RESOURCE_IMPORT_DB_BATCH_SIZE = 100;
|
||||
|
||||
export function identityKey(platform: string, value: string) {
|
||||
return `${platform.trim().toLocaleLowerCase("zh-CN")}|${value
|
||||
.trim()
|
||||
.toLocaleLowerCase("zh-CN")}`;
|
||||
}
|
||||
|
||||
export async function loadAccounts() {
|
||||
await ensureSchema();
|
||||
return getRawDb()
|
||||
.prepare(
|
||||
`SELECT id, platform, platform_uid, public_account_id, nickname,
|
||||
profile_url, ip_location, followers, gender, bio, tags,
|
||||
cooperation_source
|
||||
FROM accounts`,
|
||||
)
|
||||
.all<AccountRow>();
|
||||
}
|
||||
|
||||
async function mapConcurrent<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
worker: (item: T) => Promise<R>,
|
||||
) {
|
||||
const results = new Array<R>(items.length);
|
||||
let cursor = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
results[index] = await worker(items[index]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
export function mergeExistingFields(
|
||||
rows: ResourceImportRow[],
|
||||
accounts: AccountRow[],
|
||||
) {
|
||||
const existingByProfile = new Map<string, AccountRow>();
|
||||
for (const account of accounts) {
|
||||
const profileUrl = normalizeProfileUrl(account.profile_url || "");
|
||||
if (profileUrl) {
|
||||
existingByProfile.set(identityKey(account.platform, profileUrl), account);
|
||||
}
|
||||
}
|
||||
return rows.map((row) => {
|
||||
if (
|
||||
row.errors.length > 0 ||
|
||||
!row.profileUrl ||
|
||||
!["小红书", "抖音"].includes(row.platform)
|
||||
) {
|
||||
return row;
|
||||
}
|
||||
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
|
||||
const existingNickname =
|
||||
existing?.nickname && existing.nickname !== "待识别账号"
|
||||
? existing.nickname
|
||||
: "";
|
||||
const existingIpLocation =
|
||||
existing?.ip_location && existing.ip_location !== "待识别"
|
||||
? existing.ip_location
|
||||
: "";
|
||||
const existingGender: ResourceImportRow["gender"] =
|
||||
existing?.gender === "男" || existing?.gender === "女"
|
||||
? existing.gender
|
||||
: "";
|
||||
return {
|
||||
...row,
|
||||
nickname: row.nickname || existingNickname,
|
||||
publicAccountId: row.publicAccountId || existing?.public_account_id || "",
|
||||
ipLocation: row.ipLocation || existingIpLocation,
|
||||
followers: row.followersResolved
|
||||
? row.followers
|
||||
: Number(existing?.followers || 0),
|
||||
followersResolved:
|
||||
row.followersResolved || Number(existing?.followers || 0) > 0,
|
||||
gender: row.gender || existingGender,
|
||||
bio: row.bio || existing?.bio || "",
|
||||
tags: row.tags.length > 0
|
||||
? row.tags
|
||||
: (existing?.tags || "")
|
||||
.split(/[,,、;;|]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 5),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function enrichRows(
|
||||
rows: ResourceImportRow[],
|
||||
accounts: AccountRow[],
|
||||
) {
|
||||
const mcpConfig = resolveCollectionMcpConfig(
|
||||
getRuntimeEnv() as unknown as CollectionMcpBindings,
|
||||
);
|
||||
const baselineRows = mergeExistingFields(rows, accounts);
|
||||
return mapConcurrent(baselineRows, 4, async (baseline) => {
|
||||
const row = baseline;
|
||||
if (
|
||||
row.errors.length > 0 ||
|
||||
!row.profileUrl ||
|
||||
!["小红书", "抖音"].includes(row.platform)
|
||||
) {
|
||||
return row;
|
||||
}
|
||||
if (resourceImportMissingFields(baseline).length === 0) {
|
||||
return baseline;
|
||||
}
|
||||
|
||||
let details: {
|
||||
nickname: string | null;
|
||||
redId: string | null;
|
||||
followers: number | null;
|
||||
ipLocation: string | null;
|
||||
gender: "" | "男" | "女";
|
||||
bio: string;
|
||||
recentNoteTitles: string[];
|
||||
providerTags: string[];
|
||||
} = await resolveProfileDetailsFromMcp(
|
||||
row.profileUrl,
|
||||
row.platform === "抖音" ? "抖音" : "小红书",
|
||||
mcpConfig,
|
||||
).catch(() => ({
|
||||
nickname: null,
|
||||
redId: null,
|
||||
followers: null,
|
||||
ipLocation: null,
|
||||
gender: "" as const,
|
||||
bio: "",
|
||||
recentNoteTitles: [],
|
||||
providerTags: [],
|
||||
}));
|
||||
const mcpResult = {
|
||||
nickname: baseline.nickname || details.nickname?.trim() || "",
|
||||
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
|
||||
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "",
|
||||
followersResolved: baseline.followersResolved || details.followers !== null,
|
||||
gender: baseline.gender || details.gender,
|
||||
bio: baseline.bio || details.bio,
|
||||
tags: baseline.tags,
|
||||
};
|
||||
if (
|
||||
row.platform === "小红书" &&
|
||||
resourceImportMissingFields(mcpResult).length > 0
|
||||
) {
|
||||
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl).catch(
|
||||
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
|
||||
);
|
||||
details = {
|
||||
...details,
|
||||
nickname: details.nickname || publicDetails.nickname,
|
||||
redId: details.redId || publicDetails.redId,
|
||||
followers: details.followers ?? publicDetails.followers,
|
||||
ipLocation: details.ipLocation || publicDetails.ipLocation,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...baseline,
|
||||
nickname: baseline.nickname || details.nickname?.trim() || "待识别账号",
|
||||
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
|
||||
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "待识别",
|
||||
followers: baseline.followersResolved
|
||||
? baseline.followers
|
||||
: (details.followers ?? 0),
|
||||
followersResolved:
|
||||
baseline.followersResolved || details.followers !== null,
|
||||
gender: baseline.gender || details.gender,
|
||||
bio: baseline.bio || details.bio,
|
||||
tags: baseline.tags,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function analyzeRows(
|
||||
rows: ResourceImportRow[],
|
||||
accountRows: AccountRow[],
|
||||
) {
|
||||
const profileMap = new Map<string, AccountRow>();
|
||||
const publicIdMap = new Map<string, AccountRow>();
|
||||
const platformUidMap = new Map<string, AccountRow>();
|
||||
for (const account of accountRows) {
|
||||
const profileUrl = normalizeProfileUrl(account.profile_url || "");
|
||||
if (profileUrl) profileMap.set(identityKey(account.platform, profileUrl), account);
|
||||
if (account.public_account_id) {
|
||||
publicIdMap.set(identityKey(account.platform, account.public_account_id), account);
|
||||
}
|
||||
platformUidMap.set(identityKey(account.platform, account.platform_uid), account);
|
||||
}
|
||||
|
||||
return rows.map<AnalyzedRow>((row) => {
|
||||
const platformUid = resourcePlatformUid(row);
|
||||
const profileMatch = row.profileUrl
|
||||
? profileMap.get(identityKey(row.platform, row.profileUrl))
|
||||
: undefined;
|
||||
const publicIdMatch = row.publicAccountId
|
||||
? publicIdMap.get(identityKey(row.platform, row.publicAccountId))
|
||||
: undefined;
|
||||
const uidMatch = platformUidMap.get(identityKey(row.platform, platformUid));
|
||||
const matches = [profileMatch, publicIdMatch, uidMatch].filter(
|
||||
(account): account is AccountRow => Boolean(account),
|
||||
);
|
||||
const matchedIds = [...new Set(matches.map((account) => account.id))];
|
||||
const errors = [...row.errors];
|
||||
if (matchedIds.length > 1) {
|
||||
errors.push("账号主页和账号ID匹配到不同的现有账号,请先核对");
|
||||
}
|
||||
const existing = matchedIds.length === 1 ? matches[0] : undefined;
|
||||
const accountId = existing?.id ?? `account-${crypto.randomUUID().slice(0, 12)}`;
|
||||
const analyzed: AnalyzedRow = {
|
||||
...row,
|
||||
errors,
|
||||
action: errors.length > 0 ? "error" : existing ? "update" : "create",
|
||||
accountId,
|
||||
platformUid: existing?.platform_uid ?? platformUid,
|
||||
cooperationSource: mergeCooperationSources(
|
||||
existing?.cooperation_source ?? "",
|
||||
row.cooperationSource,
|
||||
),
|
||||
};
|
||||
if (analyzed.action !== "error") {
|
||||
const virtual: AccountRow = {
|
||||
id: accountId,
|
||||
platform: row.platform,
|
||||
platform_uid: analyzed.platformUid,
|
||||
public_account_id: row.publicAccountId || existing?.public_account_id || "",
|
||||
nickname: row.nickname,
|
||||
profile_url: row.profileUrl || existing?.profile_url || "",
|
||||
ip_location: row.ipLocation || existing?.ip_location || "待识别",
|
||||
followers: row.followers || existing?.followers || 0,
|
||||
gender: row.gender || existing?.gender || "",
|
||||
bio: row.bio || existing?.bio || "",
|
||||
tags: (row.tags.length > 0
|
||||
? row.tags
|
||||
: (existing?.tags || "").split(/[,,、;;|]/).filter(Boolean)
|
||||
).slice(0, 5).join(","),
|
||||
cooperation_source: analyzed.cooperationSource,
|
||||
};
|
||||
if (row.profileUrl) profileMap.set(identityKey(row.platform, row.profileUrl), virtual);
|
||||
if (row.publicAccountId) {
|
||||
publicIdMap.set(identityKey(row.platform, row.publicAccountId), virtual);
|
||||
}
|
||||
platformUidMap.set(identityKey(row.platform, analyzed.platformUid), virtual);
|
||||
}
|
||||
return analyzed;
|
||||
});
|
||||
}
|
||||
|
||||
function statementForAnalyzedRow(
|
||||
db: ReturnType<typeof getRawDb>,
|
||||
row: AnalyzedRow,
|
||||
) {
|
||||
return row.action === "update"
|
||||
? db
|
||||
.prepare(
|
||||
`UPDATE accounts SET
|
||||
nickname = ?,
|
||||
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
|
||||
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
|
||||
ip_location = CASE
|
||||
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
|
||||
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
|
||||
gender = CASE WHEN ? != '' THEN ? ELSE gender END,
|
||||
bio = CASE WHEN ? != '' THEN ? ELSE bio END,
|
||||
tags = CASE WHEN ? != '' THEN ? ELSE tags END,
|
||||
cooperation_source = ?,
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
row.nickname || "待识别账号",
|
||||
row.publicAccountId,
|
||||
row.publicAccountId,
|
||||
row.profileUrl,
|
||||
row.profileUrl,
|
||||
row.ipLocation,
|
||||
row.ipLocation,
|
||||
row.ipLocation,
|
||||
row.followersResolved ? 1 : 0,
|
||||
row.followers,
|
||||
row.gender,
|
||||
row.gender,
|
||||
row.bio,
|
||||
row.bio,
|
||||
row.tags.join(","),
|
||||
row.tags.join(","),
|
||||
row.cooperationSource,
|
||||
row.accountId,
|
||||
)
|
||||
: db
|
||||
.prepare(
|
||||
`INSERT INTO accounts
|
||||
(id, platform, platform_uid, public_account_id, nickname,
|
||||
profile_url, ip_location, followers, post_count, avg_views,
|
||||
gender, bio, tags, cooperation_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
row.accountId,
|
||||
row.platform,
|
||||
row.platformUid,
|
||||
row.publicAccountId,
|
||||
row.nickname || "待识别账号",
|
||||
row.profileUrl,
|
||||
row.ipLocation || "待识别",
|
||||
row.followers,
|
||||
row.gender,
|
||||
row.bio,
|
||||
row.tags.join(","),
|
||||
row.cooperationSource,
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeAnalyzedRows(rows: AnalyzedRow[]) {
|
||||
const db = getRawDb();
|
||||
const statements = rows
|
||||
.filter((row) => row.action !== "error")
|
||||
.map((row) => statementForAnalyzedRow(db, row));
|
||||
for (let index = 0; index < statements.length; index += RESOURCE_IMPORT_DB_BATCH_SIZE) {
|
||||
await db.batch(statements.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,31 @@ type WecomEnvelope = {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
invaliduser?: 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 = {
|
||||
@@ -217,3 +242,219 @@ export async function sendWecomAppMessage(
|
||||
export function clearWecomAccessTokenCacheForTests() {
|
||||
cachedAccessToken = null;
|
||||
}
|
||||
|
||||
export type WecomGroupChat = {
|
||||
chatId: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
memberCount: number;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export async function listCustomerGroupChats(
|
||||
config: WecomConfig,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<WecomGroupChat[]> {
|
||||
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<WecomExternalContact[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
326
lib/wecom-group-push-service.ts
Normal file
326
lib/wecom-group-push-service.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import type { DatabaseClient } from "./database";
|
||||
import { getRuntimeEnv } from "./runtime-env";
|
||||
import {
|
||||
createGroupMsgTemplate,
|
||||
listCustomerGroupChats,
|
||||
WecomClientError,
|
||||
type WecomConfig,
|
||||
} from "./wecom-client";
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
|
||||
export const WECOM_GROUP_BATCH_LIMIT = 2000;
|
||||
const TEXT_MAX_BYTES = 4000;
|
||||
const LINK_TITLE_MAX_BYTES = 128;
|
||||
const LINK_DESC_MAX_BYTES = 512;
|
||||
const LINK_URL_MAX_BYTES = 2048;
|
||||
|
||||
export type TaskPushRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
quantity: number;
|
||||
due_at: string;
|
||||
task_type: string;
|
||||
platform: string;
|
||||
content_format: string;
|
||||
share_token: string | null;
|
||||
};
|
||||
|
||||
export type TaskPushMessage = {
|
||||
text: string;
|
||||
link: { title: string; desc: string; url: string } | null;
|
||||
};
|
||||
|
||||
export type GroupChatRow = {
|
||||
chat_id: string;
|
||||
name: string;
|
||||
owner_user_id: string;
|
||||
member_count: number;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type GroupPushResult = {
|
||||
sender: string;
|
||||
msgid: string;
|
||||
chatCount: number;
|
||||
failList: string[];
|
||||
};
|
||||
|
||||
export type GroupPushRecord = {
|
||||
id: string;
|
||||
taskId: string;
|
||||
msgid: string;
|
||||
sender: string;
|
||||
chatIds: string[];
|
||||
failList: string[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function bindingValue(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
export function trimToBytes(value: string, maxBytes: number) {
|
||||
let output = "";
|
||||
let used = 0;
|
||||
for (const char of Array.from(value)) {
|
||||
const size = Buffer.byteLength(char, "utf8");
|
||||
if (used + size > maxBytes) break;
|
||||
output += char;
|
||||
used += size;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function buildTaskGroupPushMessage(
|
||||
task: TaskPushRow,
|
||||
portalUrl: string,
|
||||
): TaskPushMessage {
|
||||
const portal = portalUrl.trim().replace(/\/$/, "");
|
||||
const shareToken = bindingValue(task.share_token);
|
||||
const claimUrl = portal && shareToken ? `${portal}/?task=${shareToken}` : "";
|
||||
const formatLabel =
|
||||
task.task_type === "screenshot_collect"
|
||||
? "截图回收"
|
||||
: task.content_format === "video"
|
||||
? "视频"
|
||||
: "图文";
|
||||
const lines = [
|
||||
`【新任务】${task.name}`,
|
||||
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
|
||||
`平台:${task.platform}|形式:${formatLabel}`,
|
||||
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
|
||||
];
|
||||
return {
|
||||
text: trimToBytes(lines.join("\n"), TEXT_MAX_BYTES),
|
||||
link: claimUrl
|
||||
? {
|
||||
title: trimToBytes(task.name, LINK_TITLE_MAX_BYTES),
|
||||
desc: trimToBytes(
|
||||
`品牌 ${task.brand} · ${task.quantity} 份 · 截止 ${task.due_at}`,
|
||||
LINK_DESC_MAX_BYTES,
|
||||
),
|
||||
url: claimUrl.slice(0, LINK_URL_MAX_BYTES),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function groupChatsByOwner(chats: GroupChatRow[]) {
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const chat of chats) {
|
||||
const owner = bindingValue(chat.owner_user_id);
|
||||
if (!owner) continue;
|
||||
grouped.set(owner, [...(grouped.get(owner) ?? []), chat.chat_id]);
|
||||
}
|
||||
return [...grouped.entries()].map(([ownerUserId, chatIds]) => ({
|
||||
ownerUserId,
|
||||
chatIds,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function syncGroupChats(
|
||||
db: DatabaseClient,
|
||||
config: WecomConfig,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<GroupChatRow[]> {
|
||||
if (!(config.corpId && config.agentId && config.secret)) {
|
||||
throw new WecomClientError(
|
||||
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
|
||||
400,
|
||||
);
|
||||
}
|
||||
const groups = await listCustomerGroupChats(config, fetchImpl);
|
||||
const syncedAt = new Date().toISOString();
|
||||
await db.prepare("DELETE FROM wecom_group_chats").run();
|
||||
const inserts = groups.map((group) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO wecom_group_chats
|
||||
(chat_id, name, owner_user_id, member_count, status, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
group.chatId,
|
||||
group.name,
|
||||
group.ownerUserId,
|
||||
group.memberCount,
|
||||
group.status,
|
||||
syncedAt,
|
||||
),
|
||||
);
|
||||
if (inserts.length > 0) await db.batch(inserts);
|
||||
return groups.map((group) => ({
|
||||
chat_id: group.chatId,
|
||||
name: group.name,
|
||||
owner_user_id: group.ownerUserId,
|
||||
member_count: group.memberCount,
|
||||
status: group.status,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listGroupChatRows(db: DatabaseClient) {
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT chat_id, name, owner_user_id, member_count, status
|
||||
FROM wecom_group_chats
|
||||
ORDER BY member_count DESC`,
|
||||
)
|
||||
.all<GroupChatRow>();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
export async function listGroupPushes(
|
||||
db: DatabaseClient,
|
||||
taskId?: string,
|
||||
): Promise<GroupPushRecord[]> {
|
||||
const filter = taskId ? "WHERE task_id = ?" : "";
|
||||
const statement = db
|
||||
.prepare(
|
||||
`SELECT id, task_id, msgid, sender, chat_ids, fail_list, created_at
|
||||
FROM wecom_group_pushes
|
||||
${filter}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
)
|
||||
.bind(...(taskId ? [taskId] : []));
|
||||
const result = await statement.all<{
|
||||
id: string;
|
||||
task_id: string;
|
||||
msgid: string;
|
||||
sender: string;
|
||||
chat_ids: string;
|
||||
fail_list: string;
|
||||
created_at: string;
|
||||
}>();
|
||||
return result.results.map((row) => ({
|
||||
id: row.id,
|
||||
taskId: row.task_id,
|
||||
msgid: row.msgid,
|
||||
sender: row.sender,
|
||||
chatIds: safeParseArray(row.chat_ids),
|
||||
failList: safeParseArray(row.fail_list),
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function pushTaskToGroupChats(
|
||||
db: DatabaseClient,
|
||||
params: { taskId: string; chatIds: unknown; text?: unknown },
|
||||
config: WecomConfig,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<{ results: GroupPushResult[]; linkUrl: string }> {
|
||||
const taskId = bindingValue(params.taskId);
|
||||
if (!taskId) throw new WecomClientError("缺少 taskId", 400);
|
||||
|
||||
const requested = [
|
||||
...new Set(
|
||||
(Array.isArray(params.chatIds) ? params.chatIds : [])
|
||||
.map((id) => bindingValue(id))
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (requested.length === 0) {
|
||||
throw new WecomClientError("请选择要发送的客户群", 400);
|
||||
}
|
||||
|
||||
const task = await db
|
||||
.prepare(
|
||||
`SELECT id, name, brand, quantity, due_at, task_type, platform,
|
||||
content_format, share_token
|
||||
FROM tasks WHERE id = ?`,
|
||||
)
|
||||
.bind(taskId)
|
||||
.first<TaskPushRow>();
|
||||
if (!task) throw new WecomClientError("任务不存在", 404);
|
||||
|
||||
const chatRows: GroupChatRow[] = [];
|
||||
for (let index = 0; index < requested.length; index += 100) {
|
||||
const chunk = requested.slice(index, index + 100);
|
||||
const placeholders = chunk.map(() => "?").join(", ");
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT chat_id, name, owner_user_id, member_count, status
|
||||
FROM wecom_group_chats WHERE chat_id IN (${placeholders})`,
|
||||
)
|
||||
.bind(...chunk)
|
||||
.all<GroupChatRow>();
|
||||
chatRows.push(...result.results);
|
||||
}
|
||||
const byId = new Map(chatRows.map((row) => [row.chat_id, row]));
|
||||
const missing = requested.filter((id) => !byId.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new WecomClientError(
|
||||
`以下客户群未同步到平台,请先同步群列表:${missing.join("、")}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const noOwner = requested
|
||||
.map((id) => byId.get(id))
|
||||
.filter((row) => row && !bindingValue(row.owner_user_id));
|
||||
if (noOwner.length > 0) {
|
||||
throw new WecomClientError(
|
||||
`群「${noOwner.map((row) => row?.name || row?.chat_id).join("、")}」缺少群主信息,请重新同步群列表`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
|
||||
const fallback = buildTaskGroupPushMessage(task, portalUrl);
|
||||
const text = bindingValue(params.text) || fallback.text;
|
||||
const link = fallback.link;
|
||||
if (!text && !link) {
|
||||
throw new WecomClientError("文本与图文附件不能同时为空", 400);
|
||||
}
|
||||
|
||||
const results: GroupPushResult[] = [];
|
||||
const groups = requested.map((id) => byId.get(id)!);
|
||||
for (const { ownerUserId, chatIds } of groupChatsByOwner(groups)) {
|
||||
for (
|
||||
let index = 0;
|
||||
index < chatIds.length;
|
||||
index += WECOM_GROUP_BATCH_LIMIT
|
||||
) {
|
||||
const batch = chatIds.slice(index, index + WECOM_GROUP_BATCH_LIMIT);
|
||||
const sent = await createGroupMsgTemplate(
|
||||
config,
|
||||
{ sender: ownerUserId, chatIdList: batch, text, link: link ?? undefined },
|
||||
fetchImpl,
|
||||
);
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO wecom_group_pushes
|
||||
(id, task_id, msgid, sender, chat_ids, text_content, link_url, fail_list)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
`wgp-${crypto.randomUUID().slice(0, 12)}`,
|
||||
taskId,
|
||||
sent.msgid,
|
||||
ownerUserId,
|
||||
JSON.stringify(batch),
|
||||
text,
|
||||
link?.url ?? "",
|
||||
JSON.stringify(sent.failList),
|
||||
)
|
||||
.run();
|
||||
results.push({
|
||||
sender: ownerUserId,
|
||||
msgid: sent.msgid,
|
||||
chatCount: batch.length,
|
||||
failList: sent.failList,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { results, linkUrl: link?.url ?? "" };
|
||||
}
|
||||
|
||||
function safeParseArray(value: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user