3 Commits

Author SHA1 Message Date
ABAPPLO
9697b5890d 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/
2026-08-20 15:16:18 +08:00
8f7ea0558d Merge pull request 'feat: 接入企业微信通知(临期催办 + 群机器人汇总)' (#3) from feat/account-tags into main
Reviewed-on: #3
2026-08-18 09:12:59 +00:00
ABAPPLO
f2ac751c4c feat: 接入企业微信通知(临期催办 + 群机器人汇总)
新增 lib/wecom-client.ts 与 lib/wecom-notifier-service.ts,支持
群机器人 webhook 与应用消息双通道;scheduler 加入临期 N 天催办
与每日管理员汇总;action 路由补 bind_wecom_external_id 与
send_test_wecom 两个管理端动作,配套测试。
2026-08-18 17:05:16 +08:00
27 changed files with 4418 additions and 445 deletions

View File

@@ -11,3 +11,14 @@ AI_TOOL_CENTER_MCP_KEY=replace-with-mcp-key
# Feishu custom app credentials. Keep the secret out of source control. # Feishu custom app credentials. Keep the secret out of source control.
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxxx FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxxx
FEISHU_APP_SECRET=replace-with-feishu-app-secret FEISHU_APP_SECRET=replace-with-feishu-app-secret
# 企业微信 · 全部选填;不填则临期催办与汇总都跳过,不影响其他功能
WECOM_CORP_ID=
WECOM_AGENT_ID=
WECOM_SECRET=
WECOM_ROBOT_WEBHOOK=
WECOM_NOTIFY_DUE_DAYS=3
WECOM_NOTIFY_ENABLED=true
# 本地测试用:让 /api/bootstrap 自动 seed 测试 partner + distribution上线设为 false
SEED_DEMO_DATA=false

View File

@@ -24,6 +24,15 @@ FEISHU_APP_SECRET=
AI_TOOL_CENTER_MCP_URL= AI_TOOL_CENTER_MCP_URL=
AI_TOOL_CENTER_MCP_KEY= AI_TOOL_CENTER_MCP_KEY=
# 企业微信通知(临期催办 + 管理员汇总)。群机器人只需 webhookKOC 侧催办还需 corp/agent/secret
# 并在后台 partners 编辑里把 wecom_external_user_id 填好。
WECOM_ROBOT_WEBHOOK=
WECOM_CORP_ID=
WECOM_AGENT_ID=
WECOM_SECRET=
WECOM_NOTIFY_DUE_DAYS=3
WECOM_NOTIFY_ENABLED=true
# 每天北京时间 09:00 自动执行采集计划。 # 每天北京时间 09:00 自动执行采集计划。
ENABLE_SCHEDULER=true ENABLE_SCHEDULER=true
SEED_DEMO_DATA=false SEED_DEMO_DATA=false

2
.gitignore vendored
View File

@@ -22,6 +22,8 @@
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
.codegraph/
.ipynb_checkpoints/
# debug # debug
npm-debug.log* npm-debug.log*

132
CLAUDE.md Normal file
View File

@@ -0,0 +1,132 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What This Project Does
KOC LOOP is a content distribution & data collection platform for KOC (Key Opinion Consumer) operations teams. It runs on **vinext** (Vite-based Next.js 16 on Cloudflare Workers + Pages) with Cloudflare D1 (SQLite) and R2 storage.
The platform manages: task creation from Feishu (飞书) spreadsheets, content distribution to KOC partners, publishing to Xiaohongshu (小红书), and automated data collection (likes/comments/collects) via MCP-based metrics scraping.
## Two Applications
### 1. Admin App (`app/`) — Main dashboard
- Server-rendered admin UI at root (`/`), protected by ChatGPT Sign-In + admin email check
- Heavy client-side SPA in `app/admin-app.tsx` (a single ~59KB component with all dashboard state/views)
- API routes under `app/api/` for CRUD, Feishu import, data collection, image upload
### 2. KOC Portal (`koc-portal/`) — External task portal
- Independent Next.js app (separate `package.json`) for external KOC collaborators
- KOCs can claim tasks, view assigned content, and submit publish URLs/screenshots
- Does NOT connect directly to the database — uses the admin app's API
## Tech Stack
- **Framework**: Next.js 16 + React 19 + TypeScript
- **Build/Runtime**: vinext 0.0.50 (Vite 8 plugin → Cloudflare Workers/Pages)
- **Database**: Cloudflare D1 (SQLite via Drizzle ORM 0.45)
- **Storage**: Cloudflare R2 for uploads/screenshots
- **Styling**: Tailwind CSS 4
- **Cron**: Cloudflare Workers cron (daily 02:00 UTC / 10:00 CST)
## Database Schema (7 tables in `db/schema.ts`)
| Table | Purpose |
|-------|---------|
| `partners` | KOC partners/groups (name, owner, stats) |
| `tasks` | Campaign tasks (brand, quantity, due date, Feishu source) |
| `contents` | Content items (title, body, images, linked to task) |
| `accounts` | Xiaohongshu accounts scraped from publish links |
| `claims` | Partner claims on task content |
| `delegation_bundles` | Delegation bundles with share tokens |
| `distributions` | Content-to-partner assignments (publish URL, metrics, collection status) |
| `collection_runs` | Scheduled metrics collection run log |
Schema is maintained both via Drizzle (`db/schema.ts`) and imperative migrations in `lib/mvp-db.ts:ensureSchema()`. The imperative path is the source of truth for production — Drizzle migrations are optional.
## Key Libraries
- `lib/mvp-db.ts` — Raw D1 helpers, schema bootstrapping, seed data, `getDashboardData()`
- `lib/collection-service.ts` — Scheduled metrics collection orchestration
- `lib/mcp-collection-client.ts` — MCP-based client for scraping Xiaohongshu public metrics
- `lib/feishu-client.ts` — Feishu API client (spreadsheet/wiki/doc reading)
- `lib/account-enrichment-service.ts` — Backfill account profiles from Xiaohongshu
- `lib/admin-auth.ts` — Admin email/token authentication
- `lib/partner-utils.ts` / `lib/publish-url.ts` — URL extraction helpers
- `lib/date-utils.ts` — Date formatting (Shanghai timezone)
## Worker (`worker/index.ts`)
The Cloudflare Worker entry point handles:
1. `fetch` — Image optimization proxy at `/_vinext/image`, delegates everything else to vinext app router
2. `scheduled` — Daily cron: ensures DB schema, runs scheduled collections, backfills account profiles
> Note: 在私有化部署Node + MySQL里实际调度走 `lib/scheduler.ts` 的 `node-cron`,每天 09:00 Asia/Shanghai 跑同一套 `runDailyJob`(采集 + 账号资料补全 + 企业微信催办。Cloudflare Worker 入口仅用于原线上版本。
## API Routes (`app/api/`)
- `action/route.ts` — Central admin action endpoint: create task from Feishu, dashboard data, collect metrics, batch operations, account backfill, 企业微信测试 / 状态查询 / 绑定外部联系人 / 客户群同步与企业群发(半自动,群主确认后送达)
- `partner/route.ts` — Partner-facing API (claim, delegation, distribution)
- `bootstrap/route.ts` — Seed database with demo data
- `upload/route.ts` — Generic file upload to R2
- `partner-upload/route.ts` — Partner screenshot upload (CORS-enabled)
- `partner-image/route.ts` — Partner image serving (CORS-enabled)
- `content-image-upload/route.ts` — Content image upload
- `creator-screenshot/route.ts` — Creator screenshot upload
- `resources-import/route.ts` — 批量导入 KOC 账号资源Excel/CSV
- `resources-insert/route.ts` — 单条新增 KOC 账号资源(表单)
## Environment Variables (`.dev.vars`)
```
KOC_PORTAL_URL — URL of the koc-portal app
ADMIN_ALLOWED_EMAIL — ChatGPT email allowed for admin access
ADMIN_INTERNAL_TOKEN — Shared secret for API-to-API auth
AI_TOOL_CENTER_MCP_URL — MCP endpoint for XHS data collection
AI_TOOL_CENTER_MCP_KEY — MCP API key
FEISHU_APP_ID — Feishu custom app credentials
FEISHU_APP_SECRET — Feishu app secret
WECOM_CORP_ID — 企业微信企业 ID自建应用消息推送用可选
WECOM_AGENT_ID — 企业微信自建应用 AgentId可选
WECOM_SECRET — 企业微信自建应用 Secret可选
WECOM_ROBOT_WEBHOOK — 企业微信群机器人 webhook当日催办汇总可选
WECOM_NOTIFY_DUE_DAYS — 临期阈值,默认 3取值 130可选
WECOM_NOTIFY_ENABLED — 企业微信通知总开关,默认 true可选
```
## Commands
```bash
# Admin app (root of repo)
npm run dev # Start local dev server
npm run build # Build for production
npm test # Build + run integration tests
npm run lint # ESLint check
npm run db:generate # Generate Drizzle SQL migration
# KOC Portal (koc-portal/)
cd koc-portal && npm run dev -- --port 3000 # Start on port 3000
cd koc-portal && npm run build
cd koc-portal && npm test
# Tests use `node --test` (Node built-in test runner), live in tests/
node --test tests/date-utils.test.mjs # Run a single test
```
## Build System
- `vinext` replaces the Next.js build pipeline with Vite
- `@cloudflare/vite-plugin` provides D1/R2/cron local bindings
- Vite config at root resolves bindings from `.openai/hosting.json`
- Custom `build/sites-vite-plugin.ts` copies `.openai/` and `drizzle/` into `dist/` for deployment
- The `koc-portal/` subdirectory has its own identical build setup (separate `vite.config.ts`, `build/sites-vite-plugin.ts`)
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,21 @@ import {
DistributionReleaseError, DistributionReleaseError,
releaseUnfinishedDistribution, releaseUnfinishedDistribution,
} from "../../../lib/distribution-release-service"; } from "../../../lib/distribution-release-service";
import {
resolveWecomConfig,
sendWecomAppMessage,
sendWecomRobotMessage,
listExternalContacts,
WecomClientError,
type WecomBindings,
} from "../../../lib/wecom-client";
import { runDueSoonWecomNotifications } from "../../../lib/wecom-notifier-service";
import {
listGroupChatRows,
listGroupPushes,
pushTaskToGroupChats,
syncGroupChats,
} from "../../../lib/wecom-group-push-service";
import { isManagerRequest } from "../../../lib/user-auth"; import { isManagerRequest } from "../../../lib/user-auth";
import { extractPublishUrl } from "../../../lib/publish-url"; import { extractPublishUrl } from "../../../lib/publish-url";
@@ -534,6 +549,143 @@ export async function POST(request: Request) {
) )
.bind(exposure, views, distributionId) .bind(exposure, views, distributionId)
.run(); .run();
} else if (body.action === "bind_wecom_external_id") {
const partnerId = String(body.partnerId ?? "").trim().slice(0, 80);
const externalId = String(body.wecomExternalUserId ?? "")
.trim()
.slice(0, 128);
const wecomName = String(body.wecomName ?? "").trim().slice(0, 120);
if (!partnerId) {
return Response.json(
{ error: "缺少 partnerId" },
{ status: 400 },
);
}
if (wecomName) {
await db
.prepare(
"UPDATE partners SET wecom_external_user_id = ?, wecom_name = ? WHERE id = ?",
)
.bind(externalId || null, wecomName, partnerId)
.run();
} else {
await db
.prepare(
"UPDATE partners SET wecom_external_user_id = ? WHERE id = ?",
)
.bind(externalId || null, partnerId)
.run();
}
return Response.json({
partnerId,
wecomExternalUserId: externalId || null,
wecomName,
});
} else if (body.action === "send_test_wecom") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const partnerId = String(body.partnerId ?? "").trim();
let partnerExternalId: string | null = null;
if (partnerId) {
const row = await db
.prepare(
"SELECT wecom_external_user_id FROM partners WHERE id = ?",
)
.bind(partnerId)
.first<{ wecom_external_user_id: string | null }>();
partnerExternalId = row?.wecom_external_user_id ?? null;
}
const testContent = `[KOC LOOP 测试] 群机器人连通性正常,时间 ${new Date().toISOString()}`;
let robotStatus: "ok" | "skipped" = "skipped";
if (wecomConfig.robotWebhook) {
await sendWecomRobotMessage(testContent, wecomConfig);
robotStatus = "ok";
}
let appStatus: "ok" | "skipped" | "failed" = "skipped";
if (
partnerExternalId &&
wecomConfig.corpId &&
wecomConfig.agentId &&
wecomConfig.secret
) {
const result = await sendWecomAppMessage(
[partnerExternalId],
testContent,
wecomConfig,
);
appStatus = result.failed > 0 ? "failed" : "ok";
}
return Response.json({
robot: robotStatus,
app: appStatus,
});
} else if (body.action === "wecom_status") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
return Response.json({
robotConfigured: Boolean(wecomConfig.robotWebhook),
appConfigured: Boolean(
wecomConfig.corpId && wecomConfig.agentId && wecomConfig.secret,
),
dueDays: wecomConfig.dueDays,
});
} else if (body.action === "trigger_wecom_due_soon") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const summary = await runDueSoonWecomNotifications(
db,
wecomConfig,
);
return Response.json(summary);
} else if (body.action === "wecom_list_external_contacts") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const contacts = await listExternalContacts(wecomConfig);
return Response.json({ contacts });
} else if (body.action === "wecom_sync_group_chats") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const groups = await syncGroupChats(db, wecomConfig);
return Response.json({ groups, syncedCount: groups.length });
} else if (body.action === "wecom_push_task_to_groups") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const summary = await pushTaskToGroupChats(
db,
{
taskId: String(body.taskId ?? ""),
chatIds: body.chatIds,
text: body.text,
},
wecomConfig,
);
const failCount = summary.results.reduce(
(total, item) => total + item.failList.length,
0,
);
const groupCount = summary.results.reduce(
(total, item) => total + item.chatCount,
0,
);
return Response.json({
results: summary.results,
groupCount,
failCount,
hint: "群发任务已创建,群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群",
});
} else if (body.action === "wecom_task_group_pushes") {
const taskId = String(body.taskId ?? "").trim();
const pushes = await listGroupPushes(db, taskId || undefined);
return Response.json({ pushes });
} else if (body.action === "wecom_group_chats") {
const groups = await listGroupChatRows(db);
return Response.json({ groups });
} else { } else {
return Response.json({ error: "不支持的操作" }, { status: 400 }); return Response.json({ error: "不支持的操作" }, { status: 400 });
} }
@@ -545,7 +697,8 @@ export async function POST(request: Request) {
{ {
status: status:
error instanceof FeishuSourceError || error instanceof FeishuSourceError ||
error instanceof DistributionReleaseError error instanceof DistributionReleaseError ||
error instanceof WecomClientError
? error.status ? error.status
: 500, : 500,
}, },

View File

@@ -1,290 +1,24 @@
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth"; import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { runInBackground } from "../../../lib/background"; import { runInBackground } from "../../../lib/background";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import { import {
resolveCollectionMcpConfig,
resolveProfileDetailsFromMcp,
resolveXhsPublicAccountDetails,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import {
mergeCooperationSources,
normalizeProfileUrl,
parseResourceImportFile, parseResourceImportFile,
RESOURCE_IMPORT_MAX_BYTES, RESOURCE_IMPORT_MAX_BYTES,
RESOURCE_IMPORT_MAX_ROWS, RESOURCE_IMPORT_MAX_ROWS,
resourcePlatformUid,
resourceImportMissingFields, resourceImportMissingFields,
type ResourceImportRow, type ResourceImportRow,
} from "../../../lib/resource-import"; } from "../../../lib/resource-import";
import {
type AccountRow = { analyzeRows,
id: string; enrichRows,
platform: string; loadAccounts,
platform_uid: string; mergeExistingFields,
public_account_id: string; writeAnalyzedRows,
nickname: string; RESOURCE_IMPORT_SYNC_ENRICH_ROWS,
profile_url: string; type AnalyzedRow,
ip_location: string; type AccountRow,
followers: number; } from "../../../lib/resource-write";
gender: string;
bio: string;
tags: string;
cooperation_source: string;
};
type AnalyzedRow = ResourceImportRow & {
action: "create" | "update" | "error";
accountId: string;
platformUid: string;
cooperationSource: string;
};
const RESOURCE_IMPORT_PREVIEW_ROWS = 100; const RESOURCE_IMPORT_PREVIEW_ROWS = 100;
const RESOURCE_IMPORT_SYNC_ENRICH_ROWS = 100;
const RESOURCE_IMPORT_DB_BATCH_SIZE = 100;
function identityKey(platform: string, value: string) {
return `${platform.trim().toLocaleLowerCase("zh-CN")}|${value
.trim()
.toLocaleLowerCase("zh-CN")}`;
}
async function loadAccounts() {
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;
}
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),
};
});
}
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,
};
});
}
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 summarize(rows: AnalyzedRow[]) { function summarize(rows: AnalyzedRow[]) {
return { return {
@@ -310,81 +44,6 @@ function previewAnalyzedRows(rows: AnalyzedRow[]) {
]; ];
} }
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,
);
}
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));
}
}
function deferredEnrichmentCount(rows: ResourceImportRow[]) { function deferredEnrichmentCount(rows: ResourceImportRow[]) {
return rows.filter( return rows.filter(
(row) => (row) =>
@@ -415,7 +74,6 @@ async function enrichImportedRowsInBackground(rows: ResourceImportRow[]) {
export async function POST(request: Request) { export async function POST(request: Request) {
if (!(await isManagerRequest(request))) return managerForbidden(); if (!(await isManagerRequest(request))) return managerForbidden();
try { try {
await ensureSchema();
const form = await request.formData(); const form = await request.formData();
const file = form.get("file"); const file = form.get("file");
const mode = String(form.get("mode") ?? "preview"); const mode = String(form.get("mode") ?? "preview");
@@ -427,11 +85,12 @@ export async function POST(request: Request) {
} }
const rows = parseResourceImportFile(file.name, new Uint8Array(await file.arrayBuffer())); const rows = parseResourceImportFile(file.name, new Uint8Array(await file.arrayBuffer()));
const accounts = await loadAccounts(); const accounts = await loadAccounts();
const accountRows: AccountRow[] = accounts.results;
const shouldEnrichSynchronously = rows.length <= RESOURCE_IMPORT_SYNC_ENRICH_ROWS; const shouldEnrichSynchronously = rows.length <= RESOURCE_IMPORT_SYNC_ENRICH_ROWS;
const preparedRows = shouldEnrichSynchronously const preparedRows = shouldEnrichSynchronously
? await enrichRows(rows, accounts.results) ? await enrichRows(rows, accountRows)
: mergeExistingFields(rows, accounts.results); : mergeExistingFields(rows, accountRows);
const analyzed = analyzeRows(preparedRows, accounts.results); const analyzed = analyzeRows(preparedRows, accountRows);
const summary = summarize(analyzed); const summary = summarize(analyzed);
const importableRows = analyzed.filter((row) => row.action !== "error"); const importableRows = analyzed.filter((row) => row.action !== "error");
const importableRowNumbers = new Set( const importableRowNumbers = new Set(

View File

@@ -0,0 +1,64 @@
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { buildManualRow, type ManualResourceInput } from "../../../lib/resource-import";
import {
analyzeRows,
enrichRows,
loadAccounts,
writeAnalyzedRows,
} from "../../../lib/resource-write";
type InsertResult = {
action: "create" | "update";
message: string;
};
export async function POST(request: Request) {
if (!(await isManagerRequest(request))) return managerForbidden();
try {
const payload = (await request.json()) as Partial<ManualResourceInput> & {
mode?: string;
};
const row = buildManualRow({
profileUrl: payload.profileUrl ?? "",
nickname: payload.nickname ?? "",
publicAccountId: payload.publicAccountId ?? "",
ipLocation: payload.ipLocation ?? "",
followers: payload.followers ?? "",
gender: payload.gender ?? "",
bio: payload.bio ?? "",
tags: payload.tags ?? "",
cooperationSource: payload.cooperationSource ?? "",
});
if (row.errors.length > 0) {
return Response.json(
{ error: row.errors[0], errors: row.errors },
{ status: 400 },
);
}
const accounts = await loadAccounts();
const enriched = await enrichRows([row], accounts.results);
const analyzed = analyzeRows(enriched, accounts.results).filter(
(item) => item.action !== "error",
);
if (analyzed.length === 0) {
return Response.json(
{ error: "账号数据校验未通过,无法保存" },
{ status: 400 },
);
}
await writeAnalyzedRows(analyzed);
const result = analyzed[0];
const outcome: InsertResult = {
action: result.action === "update" ? "update" : "create",
message:
result.action === "update"
? `已更新账号 ${row.nickname || row.publicAccountId || "资料"}`
: `已新增账号 ${row.nickname || row.publicAccountId || "资料"}`,
};
return Response.json(outcome);
} catch (error) {
const message = error instanceof Error ? error.message : "保存失败";
return Response.json({ error: message }, { status: 400 });
}
}

View File

@@ -984,7 +984,8 @@ a {
.workflow-panel, .workflow-panel,
.task-mini-panel, .task-mini-panel,
.table-panel { .table-panel,
.wecom-status-panel {
padding: 21px; padding: 21px;
} }
@@ -1358,6 +1359,406 @@ a {
margin-top: 0; margin-top: 0;
} }
.wecom-status-panel .wecom-status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.wecom-status-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border-radius: 12px;
background: #f4f8f5;
border: 1px solid #e2ebe4;
}
.wecom-status-card span {
color: #687773;
font-size: 10px;
font-weight: 620;
}
.wecom-status-card strong {
font-size: 15px;
color: var(--ink);
}
.wecom-status-card small {
color: #9aa39f;
font-size: 9px;
}
.wecom-status-card.ok {
background: #eaf6ee;
border-color: #c5e4cf;
}
.wecom-status-card.ok strong {
color: var(--green-deep);
}
.wecom-status-card.off {
background: #fdf2ee;
border-color: #f3d4c4;
}
.wecom-status-card.off strong {
color: #c4622b;
}
.wecom-status-actions {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.wecom-status-hint {
margin: 0;
color: #9aa39f;
font-size: 10px;
}
.wecom-status-hint code {
padding: 1px 5px;
border-radius: 4px;
background: #eef2ef;
font-size: 9px;
}
.partner-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.partner-row {
display: grid;
grid-template-columns: 1fr 1fr 0.6fr 1.4fr auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 9px;
background: #fbfcfb;
border: 1px solid #e8ece8;
font-size: 11px;
}
.partner-row.head {
background: #f1f4f2;
color: #687773;
font-weight: 620;
font-size: 10px;
}
.partner-row.head span:last-child {
text-align: right;
}
.partner-row.empty {
justify-content: center;
color: #9aa39f;
font-size: 11px;
padding: 18px;
text-align: center;
}
.partner-external-cell {
min-width: 0;
}
.partner-external-cell input,
.partner-external-cell code {
width: 100%;
font-size: 11px;
}
.partner-external-cell code {
display: block;
max-width: 100%;
padding: 4px 8px;
background: #eef2ef;
border-radius: 6px;
color: #556861;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.partner-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
min-width: 228px;
}
.partner-actions .ghost-button,
.partner-actions .primary-button {
min-height: 28px;
padding: 0 10px;
border-radius: 7px;
font-size: 11px;
}
.wecom-picker-modal {
width: min(640px, 100%);
}
.wecom-picker-modal .import-guide-strip {
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.wecom-picker-search {
flex: 1 1 220px;
min-width: 180px;
padding: 8px 12px;
border: 1px solid rgb(20 40 34 / 0.16);
border-radius: 10px;
font-size: 13px;
background: white;
color: rgb(20 40 34);
}
.wecom-picker-cache-hint {
color: rgb(20 40 34 / 0.5);
font-size: 11px;
}
.wecom-picker-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
max-height: 48vh;
overflow-y: auto;
padding-right: 2px;
}
.wecom-picker-empty {
padding: 28px 12px;
text-align: center;
color: rgb(20 40 34 / 0.55);
font-size: 13px;
border: 1px dashed rgb(20 40 34 / 0.18);
border-radius: 12px;
}
.wecom-picker-row {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgb(20 40 34 / 0.08);
background: white;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
width: 100%;
}
.wecom-picker-row:hover:not(:disabled) {
border-color: rgb(20 184 166 / 0.4);
background: rgb(20 184 166 / 0.06);
}
.wecom-picker-row:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.wecom-picker-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.wecom-picker-avatar.placeholder {
display: grid;
place-items: center;
background: rgb(20 40 34 / 0.06);
color: rgb(20 40 34 / 0.55);
font-size: 14px;
font-weight: 600;
}
.wecom-picker-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.wecom-picker-name {
font-size: 14px;
font-weight: 600;
color: rgb(20 40 34);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wecom-picker-sub {
font-size: 11px;
color: rgb(20 40 34 / 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wecom-picker-id {
font-size: 10px;
color: rgb(20 40 34 / 0.4);
word-break: break-all;
}
.wecom-picker-binding,
.wecom-picker-current {
font-size: 11px;
color: rgb(20 184 166);
padding: 2px 8px;
border: 1px solid rgb(20 184 166 / 0.35);
border-radius: 999px;
white-space: nowrap;
}
.wecom-picker-binding {
color: rgb(20 40 34 / 0.5);
border-color: rgb(20 40 34 / 0.18);
}
.group-push-modal {
width: min(720px, 100%);
}
.group-push-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.group-push-count {
font-size: 12px;
color: rgb(20 40 34 / 0.55);
}
.group-push-toolbar-actions {
display: flex;
gap: 8px;
}
.group-push-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgb(20 40 34 / 0.08);
background: white;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.group-push-row:hover {
border-color: rgb(20 184 166 / 0.4);
background: rgb(20 184 166 / 0.06);
}
.group-push-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: rgb(20 184 166);
}
.group-push-text {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
font-size: 12px;
color: rgb(20 40 34 / 0.6);
}
.group-push-text textarea {
width: 100%;
resize: vertical;
}
.group-push-result {
margin-top: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgb(20 184 166 / 0.35);
background: rgb(20 184 166 / 0.06);
font-size: 12px;
color: rgb(20 40 34 / 0.75);
}
.group-push-result ul,
.group-push-history ul {
margin: 6px 0 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 2px;
}
.group-push-fail {
color: rgb(190 60 50 / 0.9);
}
.group-push-history {
margin-top: 12px;
font-size: 12px;
color: rgb(20 40 34 / 0.6);
}
.group-push-history-title {
font-weight: 600;
color: rgb(20 40 34 / 0.75);
}
@media (max-width: 540px) {
.wecom-picker-row {
grid-template-columns: 32px 1fr auto;
gap: 8px;
padding: 8px 10px;
}
.wecom-picker-avatar,
.wecom-picker-avatar.placeholder {
width: 32px;
height: 32px;
font-size: 12px;
}
}
@media (max-width: 720px) {
.wecom-status-panel .wecom-status-grid {
grid-template-columns: 1fr;
}
.partner-row,
.partner-row.head {
grid-template-columns: 1fr;
gap: 4px;
}
.partner-row.head {
display: none;
}
}
.task-hero { .task-hero {
display: flex; display: flex;
min-height: 170px; min-height: 170px;
@@ -2182,6 +2583,39 @@ a {
overflow-y: auto; overflow-y: auto;
} }
.import-mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
padding: 4px;
border-radius: 11px;
background: #f1f4f2;
}
.import-mode-tabs button {
flex: 1;
height: 36px;
padding: 0 16px;
border: none;
border-radius: 8px;
background: transparent;
color: #687773;
font-size: 11px;
font-weight: 620;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.import-mode-tabs button:hover {
color: var(--ink);
}
.import-mode-tabs button.active {
background: white;
color: var(--green-deep);
box-shadow: 0 1px 2px rgb(31 49 41 / 0.08);
}
.import-guide-strip { .import-guide-strip {
display: grid; display: grid;
grid-template-columns: auto auto 1fr auto auto 1fr auto auto; grid-template-columns: auto auto 1fr auto auto 1fr auto auto;

View File

@@ -21,6 +21,7 @@ export const partners = mysqlTable("partners", {
owner: varchar("owner", { length: 255 }).notNull().default("运营组"), owner: varchar("owner", { length: 255 }).notNull().default("运营组"),
claimedTotal: int("claimed_total").notNull().default(0), claimedTotal: int("claimed_total").notNull().default(0),
completedTotal: int("completed_total").notNull().default(0), completedTotal: int("completed_total").notNull().default(0),
wecomExternalUserId: varchar("wecom_external_user_id", { length: 128 }),
createdAt: timestamp("created_at"), createdAt: timestamp("created_at"),
}); });
@@ -252,6 +253,35 @@ export const mcpExportTokens = mysqlTable(
(table) => [index("mcp_export_tokens_expires_at_idx").on(table.expiresAt)], (table) => [index("mcp_export_tokens_expires_at_idx").on(table.expiresAt)],
); );
export const wecomGroupChats = mysqlTable(
"wecom_group_chats",
{
chatId: varchar("chat_id", { length: 128 }).primaryKey(),
name: varchar("name", { length: 512 }).notNull().default(""),
ownerUserId: varchar("owner_user_id", { length: 128 }).notNull(),
memberCount: int("member_count").notNull().default(0),
status: int("status").notNull().default(0),
syncedAt: timestamp("synced_at"),
},
(table) => [index("wecom_group_chats_owner_idx").on(table.ownerUserId)],
);
export const wecomGroupPushes = mysqlTable(
"wecom_group_pushes",
{
id: varchar("id", { length: 64 }).primaryKey(),
taskId: varchar("task_id", { length: 64 }).notNull(),
msgid: varchar("msgid", { length: 128 }).notNull().default(""),
sender: varchar("sender", { length: 128 }).notNull(),
chatIds: text("chat_ids").notNull(),
textContent: text("text_content").notNull(),
linkUrl: text("link_url").notNull(),
failList: text("fail_list").notNull(),
createdAt: timestamp("created_at"),
},
(table) => [index("wecom_group_pushes_task_idx").on(table.taskId)],
);
export const backgroundJobs = mysqlTable( export const backgroundJobs = mysqlTable(
"background_jobs", "background_jobs",
{ {

32
dev.vars_exp Normal file
View File

@@ -0,0 +1,32 @@
KOC_PORTAL_URL=http://localhost:3000
ADMIN_ALLOWED_EMAIL=operator@example.com
ADMIN_INTERNAL_TOKEN=replace-with-a-random-secret
KOC_DEV=1
# Optional override. The production key must be stored as a runtime secret.
AI_TOOL_CENTER_MCP_URL=https://middle-aitool.gbotai.cn/mcp
AI_TOOL_CENTER_MCP_KEY=replace-with-mcp-key
# Feishu custom app credentials. Keep the secret out of source control.
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxxx
FEISHU_APP_SECRET=replace-with-feishu-app-secret
# 企业微信 · 全部选填;不填则临期催办与汇总都跳过,不影响其他功能
# 管理后台 https://work.weixin.qq.com →「我的企业 → 企业信息 → 企业 ID」
WECOM_CORP_ID=
# 管理后台 →「应用管理 → 自建」→ 创建应用「KOC LOOP 催办」后,应用详情页顶部显示
WECOM_AGENT_ID=
# 同一应用详情页 →「Secret」→ 点「发送」→ 企业微信里收到 64 位字符串
# 注意Secret 只能发送一次,收到立刻保存;丢了只能点「重置 Secret」重新生成
WECOM_SECRET=
# 测试群 → 群设置 → 群机器人 → 添加机器人 → 完整 webhook URL
# 形如 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx
WECOM_ROBOT_WEBHOOK=
# 临期阈值,默认 3超过则不在催办。取值 1-30
WECOM_NOTIFY_DUE_DAYS=3
# 企业微信通知总开关,默认 true临时关闭设为 false
WECOM_NOTIFY_ENABLED=true
# 本地测试:让 /api/bootstrap 自动 seed 3 个测试 partner + 5 条待发布 distribution
# 上线后改为 false
SEED_DEMO_DATA=true

View File

@@ -54,6 +54,12 @@ cp .env.self-hosted.example .env.self-hosted
| `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | 读取飞书内容表和配图 | | `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | 读取飞书内容表和配图 |
| `AI_TOOL_CENTER_MCP_URL` / `AI_TOOL_CENTER_MCP_KEY` | 小红书公开数据采集服务 | | `AI_TOOL_CENTER_MCP_URL` / `AI_TOOL_CENTER_MCP_KEY` | 小红书公开数据采集服务 |
| `ENABLE_SCHEDULER` | 是否启用每天 09:00 自动采集,生产保持 `true` | | `ENABLE_SCHEDULER` | 是否启用每天 09:00 自动采集,生产保持 `true` |
| `WECOM_CORP_ID` / `WECOM_AGENT_ID` / `WECOM_SECRET` | 企业微信自建应用,用于临期催办的应用消息推送;三项需同时填写,全部留空则只走群机器人 |
| `WECOM_ROBOT_WEBHOOK` | 企业微信群机器人 webhook用于当日催办汇总 |
| `WECOM_NOTIFY_DUE_DAYS` | 临期阈值,默认 3超过则不在催办可填 130 |
| `WECOM_NOTIFY_ENABLED` | 企业微信通知总开关,默认 `true`;临时关闭设为 `false` |
企业微信变量全部选填:不填则临期催办与汇总都跳过,不影响其他功能。所有变量都可以在「合作方」页面顶部「企业微信连通性」面板查看就绪状态,并用「测试群机器人」「测试发送」按钮验证。
密钥必须由密码管理器生成,禁止提交到 Git、聊天、部署日志或 URL。三个业务密钥 `ADMIN_INTERNAL_TOKEN``KOC_MCP_API_KEY``AI_TOOL_CENTER_MCP_KEY` 不得复用。 密钥必须由密码管理器生成,禁止提交到 Git、聊天、部署日志或 URL。三个业务密钥 `ADMIN_INTERNAL_TOKEN``KOC_MCP_API_KEY``AI_TOOL_CENTER_MCP_KEY` 不得复用。
@@ -206,3 +212,57 @@ docker compose --env-file .env.self-hosted \
| 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 | | 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 |
生产日志不得打印数据库密码、飞书 Secret、MCP key 或完整带 key 的采集服务 URL。 生产日志不得打印数据库密码、飞书 Secret、MCP key 或完整带 key 的采集服务 URL。
## 11. 企业微信自建应用与群机器人配置
KOC LOOP 的企业微信通知是**单向外发**:每天 09:00 与定时采集一同触发,给合作方绑定的外部联系人推送临期催办,再向群机器人发送当日汇总。不需要 OAuth 回调,也不需要拉通讯录。
### 11.1 群机器人(用于当日催办汇总)
1. 在企业微信里建立一个用于催办的群;
2. 群设置 → 群机器人 → 添加机器人 → 复制 webhook 地址,形如 `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...`
3. 把整个 webhook 写入 `WECOM_ROBOT_WEBHOOK`
只要这一项就绪,每天 09:00 的催办汇总就会推到该群。
### 11.2 自建应用(用于给单个合作方发应用消息)
1. 登录企业微信管理后台 → 应用管理 → 自建 → 创建应用,名称建议 `KOC LOOP 催办`
2. 应用详情页记下 `AgentId`,填入 `WECOM_AGENT_ID`
3. 我的企业 → 企业信息 → 企业 ID填入 `WECOM_CORP_ID`
4. 应用详情页 → Secret → 发送 Secret 到管理端,填入 `WECOM_SECRET`
5. 应用「可见范围」必须包含合作方对应的企业微信成员,否则推送时会返回 `invaliduser`
### 11.3 绑定外部联系人
企业微信应用消息推送需要 `external_userid`(外部联系人 IDKOC LOOP 不自动拉取,需要手动绑定:
1. 在企业微信「客户联系 → 外部联系人」里找到合作方对应的客户;
2. 复制其 external_userid形如 `woxxxxxx`
3. 在 KOC LOOP 后台「合作方」页面,找到对应合作方行,点「绑定」粘贴 ID → 「保存」;
4. 点「测试发送」验证;如果返回 `应用消息发送失败`,多半是应用可见范围未包含该外部联系人,或 external_userid 复制错了。
外部联系人 ID 不入库后不会自动同步企业微信端的变更;如果合作方的外部联系人在企业微信里被删除或转移,需要回来更新这一列。
### 11.4 验证
部署后到后台「合作方」页面,顶部「企业微信连通性」面板应显示三项就绪状态:群机器人、自建应用消息、临期阈值。逐项点「测试」:
- 「测试群机器人」:群内收到 `[KOC LOOP 测试] 群机器人连通性正常,时间 ...` 即配置成功;
- 合作方行的「测试发送」:对应外部联系人收到同样格式的测试消息即绑定正确。
如果面板显示「未配置」但已填环境变量,先确认容器加载了新的 `.env.self-hosted`(重启服务),再回到该页面点「刷新状态」。
### 11.5 任务发布到企微客户群(企业群发)
平台支持把任务以「企业群发」方式推送到多个外部客户群。**企业微信不允许外部群添加群机器人 webhook**,官方路径是半自动群发:平台创建群发任务 → 各群的群主在企业微信客户端「群发助手」里点击发送 → 消息才送达客户群。依赖 11.2 的同一个自建应用,无需新增环境变量;数据库表 `wecom_group_chats` / `wecom_group_pushes` 由迁移 `mysql/0009_wecom_group_push.sql` 创建(`npm run db:migrate` 自动执行)。
使用前需在企业微信管理后台完成三项配置:
1. **客户联系 → 配置 → 可调用应用**:把该自建应用加入可调用列表,否则客户群相关接口会报权限错误;
2. **应用可见范围**:必须包含所有目标群的群主(群主是群发任务的确认人);
3. **应用可信 IP**(应用详情页 → 开发者接口 → 企业可信 IP必须包含服务器出口 IP否则接口返回 `60020 not allow to access from your ip`
使用流程:后台「任务中心」→ 任务行「发布到企微群」→ 弹窗内「同步群列表」(拉取客户联系下的正常状态客户群)→ 勾选目标群、确认文案 → 「创建群发」。创建成功后平台返回 msgid 并落库 `wecom_group_pushes`,各群主在企微客户端收到群发助手提醒,**点击发送后**消息(含 KOC 领取链接)才出现在群里。
限制与注意:每个客户群每月最多接收「当月天数」条企业群发;群主使用的企业微信客户端需 ≥4.1.10 才能免选群直接发送;无效的 chat_id 会进入失败列表但不影响其他群。

View File

@@ -366,6 +366,11 @@ export async function ensureSchema(database?: DatabaseClient) {
"last_collection_day", "last_collection_day",
"last_collection_day INTEGER", "last_collection_day INTEGER",
); );
await ensureColumn(
"partners",
"wecom_external_user_id",
"wecom_external_user_id TEXT",
);
const tasksWithoutShare = await db const tasksWithoutShare = await db
.prepare( .prepare(

View File

@@ -337,6 +337,59 @@ function normalizeRows(rows: string[][]) {
return result; 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) { export function parseResourceImportFile(fileName: string, bytes: Uint8Array) {
const extension = fileName.toLocaleLowerCase().split(".").pop(); const extension = fileName.toLocaleLowerCase().split(".").pop();
const workbooks = const workbooks =

366
lib/resource-write.ts Normal file
View 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));
}
}

View File

@@ -20,6 +20,12 @@ export type RuntimeEnv = {
AI_TOOL_CENTER_MCP_KEY?: string; AI_TOOL_CENTER_MCP_KEY?: string;
COLLECTION_MCP_URL?: string; COLLECTION_MCP_URL?: string;
COLLECTION_MCP_KEY?: string; COLLECTION_MCP_KEY?: string;
WECOM_CORP_ID?: string;
WECOM_AGENT_ID?: string;
WECOM_SECRET?: string;
WECOM_ROBOT_WEBHOOK?: string;
WECOM_NOTIFY_DUE_DAYS?: string;
WECOM_NOTIFY_ENABLED?: string;
SEED_DEMO_DATA?: string; SEED_DEMO_DATA?: string;
ENABLE_SCHEDULER?: string; ENABLE_SCHEDULER?: string;
}; };

View File

@@ -8,6 +8,11 @@ import {
} from "./mcp-collection-client"; } from "./mcp-collection-client";
import { ensureSchema, getRawDb } from "./mvp-db"; import { ensureSchema, getRawDb } from "./mvp-db";
import { getRuntimeEnv, isEnabled } from "./runtime-env"; import { getRuntimeEnv, isEnabled } from "./runtime-env";
import {
resolveWecomConfig,
type WecomBindings,
} from "./wecom-client";
import { runDueSoonWecomNotifications } from "./wecom-notifier-service";
declare global { declare global {
var __kocLoopScheduler: ScheduledTask | undefined; var __kocLoopScheduler: ScheduledTask | undefined;
@@ -17,14 +22,31 @@ async function runDailyJob() {
await withDatabaseLock("koc-loop-daily-collection", 0, async () => { await withDatabaseLock("koc-loop-daily-collection", 0, async () => {
await ensureSchema(); await ensureSchema();
const db = getRawDb(); const db = getRawDb();
const env = getRuntimeEnv();
const config = resolveCollectionMcpConfig( const config = resolveCollectionMcpConfig(
getRuntimeEnv() as unknown as CollectionMcpBindings, env as unknown as CollectionMcpBindings,
); );
const collections = await runScheduledCollections(db, Date.now(), config); const collections = await runScheduledCollections(db, Date.now(), config);
const accounts = await backfillAccountProfiles(db, config, 10); const accounts = await backfillAccountProfiles(db, config, 10);
let wecom: Awaited<ReturnType<typeof runDueSoonWecomNotifications>> | null =
null;
if (isEnabled(env.WECOM_NOTIFY_ENABLED, true)) {
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
if (
wecomConfig.robotWebhook ||
(wecomConfig.corpId && wecomConfig.agentId && wecomConfig.secret)
) {
try {
wecom = await runDueSoonWecomNotifications(db, wecomConfig);
} catch (error) {
console.error("[KOC LOOP] wecom notify failed", error);
}
}
}
console.info("[KOC LOOP] daily scheduler completed", { console.info("[KOC LOOP] daily scheduler completed", {
collections, collections,
accounts, accounts,
wecom,
}); });
}); });
} }

460
lib/wecom-client.ts Normal file
View File

@@ -0,0 +1,460 @@
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;
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));
}
function hasAppCredentials(config: WecomConfig) {
return Boolean(config.corpId && config.agentId && config.secret);
}
async function readEnvelope(
response: Response,
fallbackMessage: string,
): Promise<WecomEnvelope> {
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<void> {
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 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;
}

View 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 [];
}
}

View File

@@ -0,0 +1,171 @@
import type { DatabaseClient } from "./database";
import { shanghaiDateFromTimestamp } from "./collection-service";
import {
sendWecomAppMessage,
sendWecomRobotMessage,
type WecomConfig,
} from "./wecom-client";
import { getRuntimeEnv } from "./runtime-env";
type FetchLike = typeof fetch;
export type WecomNotifySummary = {
dueSoonAttempted: number;
dueSoonSent: number;
dueSoonFailed: number;
dueSoonSkipped: number;
digestSent: boolean;
};
type DueSoonRow = {
distribution_id: string;
partner_id: string;
partner_name: string;
wecom_external_user_id: string | null;
task_name: string;
due_at: string;
content_title: string;
};
export function computeDueCutoff(today: string, dueDays: number) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today);
if (!match) return today;
const [, y, m, d] = match;
const date = new Date(
Date.UTC(Number(y), Number(m) - 1, Number(d)) + dueDays * 24 * 60 * 60 * 1_000,
);
return date.toISOString().slice(0, 10);
}
export async function runDueSoonWecomNotifications(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
): Promise<WecomNotifySummary> {
const today = shanghaiDateFromTimestamp(now);
const cutoff = computeDueCutoff(today, config.dueDays);
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
const result = await db
.prepare(
`SELECT
d.id AS distribution_id,
d.partner_id,
p.name AS partner_name,
p.wecom_external_user_id,
t.name AS task_name,
t.due_at,
c.title AS content_title
FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id
JOIN contents c ON c.id = d.content_id
WHERE (d.publish_url IS NULL OR d.publish_url = '')
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at <= ?
ORDER BY t.due_at ASC, p.name ASC`,
)
.bind(cutoff)
.all<DueSoonRow>();
const grouped = new Map<
string,
{
partnerName: string;
externalUserId: string | null;
rows: DueSoonRow[];
}
>();
for (const row of result.results) {
const entry = grouped.get(row.partner_id) ?? {
partnerName: row.partner_name,
externalUserId: row.wecom_external_user_id,
rows: [],
};
entry.rows.push(row);
if (!entry.externalUserId && row.wecom_external_user_id) {
entry.externalUserId = row.wecom_external_user_id;
}
grouped.set(row.partner_id, entry);
}
let dueSoonAttempted = 0;
let dueSoonSent = 0;
let dueSoonFailed = 0;
let dueSoonSkipped = 0;
const digestTasks: string[] = [];
for (const [, entry] of grouped) {
dueSoonAttempted += 1;
const external = entry.externalUserId
? [entry.externalUserId]
: [];
const lines = entry.rows.slice(0, 5).map((row) => {
return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(截止 ${row.due_at}`;
});
const overflow =
entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5}` : "";
const link = portalUrl ? `\n领取链接${portalUrl}` : "";
const content =
`${entry.partnerName},你有 ${entry.rows.length} 条内容待发布:\n${lines.join("\n")}${overflow}${link}`;
const appResult = await sendWecomAppMessage(
external,
content,
config,
fetchImpl,
).catch((error: unknown) => {
console.warn(
"[KOC LOOP] wecom app message failed",
{ partner: entry.partnerName, error: safeError(error) },
);
return null;
});
if (appResult === null) {
dueSoonFailed += 1;
} else if (appResult.skipped) {
dueSoonSkipped += 1;
} else {
dueSoonSent += 1;
}
const earliestDue = entry.rows[0]?.due_at ?? "";
digestTasks.push(
`· ${entry.partnerName}${entry.rows.length} 条,最近截止 ${earliestDue}`,
);
}
let digestSent = false;
if (config.robotWebhook && digestTasks.length > 0) {
const digest =
`今日待发布催办(${today},截止 ≤ ${cutoff}\n${digestTasks.join("\n")}`;
try {
await sendWecomRobotMessage(digest, config, fetchImpl);
digestSent = true;
} catch (error) {
console.warn(
"[KOC LOOP] wecom robot digest failed",
{ error: safeError(error) },
);
}
}
return {
dueSoonAttempted,
dueSoonSent,
dueSoonFailed,
dueSoonSkipped,
digestSent,
};
}
function bindingValue(value: unknown) {
return String(value ?? "").trim();
}
function truncate(value: string, max: number) {
return value.length > max ? `${value.slice(0, max)}` : value;
}
function safeError(error: unknown) {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE partners
ADD COLUMN wecom_external_user_id VARCHAR(128) AFTER completed_total;

View File

@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS `wecom_group_chats` (
`chat_id` VARCHAR(128) NOT NULL,
`name` VARCHAR(512) NOT NULL DEFAULT '',
`owner_user_id` VARCHAR(128) NOT NULL,
`member_count` INT NOT NULL DEFAULT 0,
`status` INT NOT NULL DEFAULT 0,
`synced_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`chat_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- statement-breakpoint
CREATE TABLE IF NOT EXISTS `wecom_group_pushes` (
`id` VARCHAR(64) NOT NULL,
`task_id` VARCHAR(64) NOT NULL,
`msgid` VARCHAR(128) NOT NULL DEFAULT '',
`sender` VARCHAR(128) NOT NULL,
`chat_ids` TEXT NOT NULL,
`text_content` TEXT NOT NULL,
`link_url` TEXT NOT NULL,
`fail_list` TEXT NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `wecom_group_pushes_task_idx` (`task_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

View File

@@ -3,6 +3,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
serverExternalPackages: ["mysql2", "sharp"], serverExternalPackages: ["mysql2", "sharp"],
allowedDevOrigins: ["192.168.30.90", "localhost", "127.0.0.1"],
}; };
export default nextConfig; export default nextConfig;

View File

@@ -510,11 +510,12 @@ test("filters and exports the current KOC resource result set", async () => {
}); });
test("imports existing KOC resources through a validated spreadsheet preview", async () => { test("imports existing KOC resources through a validated spreadsheet preview", async () => {
const [adminApp, globalCss, importRoute, resourceParser, accountMigration, profileMigration, contactMigration] = await Promise.all([ const [adminApp, globalCss, importRoute, resourceParser, resourceWrite, accountMigration, profileMigration, contactMigration] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"), readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"), readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
readFile(new URL("../app/api/resources-import/route.ts", import.meta.url), "utf8"), readFile(new URL("../app/api/resources-import/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/resource-import.ts", import.meta.url), "utf8"), readFile(new URL("../lib/resource-import.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/resource-write.ts", import.meta.url), "utf8"),
readFile(new URL("../mysql/0002_resource_import.sql", import.meta.url), "utf8"), readFile(new URL("../mysql/0002_resource_import.sql", import.meta.url), "utf8"),
readFile(new URL("../mysql/0006_account_profile_tags.sql", import.meta.url), "utf8"), readFile(new URL("../mysql/0006_account_profile_tags.sql", import.meta.url), "utf8"),
readFile(new URL("../mysql/0007_account_current_contact.sql", import.meta.url), "utf8"), readFile(new URL("../mysql/0007_account_current_contact.sql", import.meta.url), "utf8"),
@@ -527,7 +528,7 @@ test("imports existing KOC resources through a validated spreadsheet preview", a
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_ROWS = 10_000/); assert.match(resourceParser, /RESOURCE_IMPORT_MAX_ROWS = 10_000/);
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_BYTES = 20 \* 1024 \* 1024/); assert.match(resourceParser, /RESOURCE_IMPORT_MAX_BYTES = 20 \* 1024 \* 1024/);
assert.match(adminApp, /单次最多 10,000 个账号,文件不超过 20MB/); assert.match(adminApp, /单次最多 10,000 个账号,文件不超过 20MB/);
assert.match(importRoute, /RESOURCE_IMPORT_DB_BATCH_SIZE = 100/); assert.match(resourceWrite, /RESOURCE_IMPORT_DB_BATCH_SIZE = 100/);
assert.match(importRoute, /bulk resource profile enrichment/); assert.match(importRoute, /bulk resource profile enrichment/);
assert.match(adminApp, /异常数据将自动跳过,不会导入/); assert.match(adminApp, /异常数据将自动跳过,不会导入/);
assert.match( assert.match(
@@ -542,7 +543,7 @@ test("imports existing KOC resources through a validated spreadsheet preview", a
assert.match(importRoute, /跳过 \$\{summary\.error\} 条异常数据/); assert.match(importRoute, /跳过 \$\{summary\.error\} 条异常数据/);
assert.match(importRoute, /previewAnalyzedRows\(analyzed\)/); assert.match(importRoute, /previewAnalyzedRows\(analyzed\)/);
assert.match(resourceParser, /当前自动解析仅支持小红书或抖音账号主页/); assert.match(resourceParser, /当前自动解析仅支持小红书或抖音账号主页/);
assert.match(importRoute, /resolveProfileDetailsFromMcp/); assert.match(resourceWrite, /resolveProfileDetailsFromMcp/);
assert.match(accountMigration, /cooperation_source/); assert.match(accountMigration, /cooperation_source/);
assert.match(profileMigration, /ADD COLUMN gender/); assert.match(profileMigration, /ADD COLUMN gender/);
assert.match(profileMigration, /ADD COLUMN bio/); assert.match(profileMigration, /ADD COLUMN bio/);

607
tests/wecom-client.test.mjs Normal file
View File

@@ -0,0 +1,607 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearWecomAccessTokenCacheForTests,
createGroupMsgTemplate,
listCustomerGroupChats,
listExternalContacts,
resolveWecomConfig,
sendWecomAppMessage,
sendWecomRobotMessage,
WecomClientError,
} from "../lib/wecom-client.ts";
const baseBindings = {
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
WECOM_ROBOT_WEBHOOK:
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=robot-test",
WECOM_NOTIFY_DUE_DAYS: "3",
};
function fakeWecom(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/webhook/send")) {
return Response.json({
errcode: options.robotErrcode ?? 0,
errmsg: options.robotErrmsg ?? "ok",
});
}
if (url.pathname.endsWith("/cgi-bin/message/send")) {
const body = JSON.parse(String(init.body ?? "{}"));
const invalid = options.invalidUser
? body.touser.split("|").filter((u) => u === options.invalidUser)
: [];
return Response.json({
errcode: 0,
errmsg: "ok",
invaliduser: invalid.join("|"),
});
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl };
}
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
test("resolveWecomConfig applies dueDays and trims values", () => {
const config = resolveWecomConfig({
WECOM_CORP_ID: " corp ",
WECOM_AGENT_ID: " 10 ",
WECOM_SECRET: " s ",
WECOM_ROBOT_WEBHOOK: " https://hook ",
WECOM_NOTIFY_DUE_DAYS: "5",
});
assert.equal(config.corpId, "corp");
assert.equal(config.agentId, "10");
assert.equal(config.secret, "s");
assert.equal(config.robotWebhook, "https://hook");
assert.equal(config.dueDays, 5);
});
test("resolveWecomConfig falls back to default dueDays for bad input", () => {
assert.equal(resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "0" }).dueDays, 3);
assert.equal(resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "abc" }).dueDays, 3);
assert.equal(
resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "100" }).dueDays,
30,
);
});
test("sendWecomRobotMessage posts text payload and returns void", async () => {
const { calls, fetchImpl } = fakeWecom();
await sendWecomRobotMessage(
"hello",
resolveWecomConfig(baseBindings),
fetchImpl,
);
const hookCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/webhook/send"),
);
assert.ok(hookCall, "robot webhook was called");
const body = JSON.parse(String(hookCall.init.body));
assert.equal(body.msgtype, "text");
assert.equal(body.text.content, "hello");
});
test("sendWecomRobotMessage skips silently when webhook is empty", async () => {
const { calls, fetchImpl } = fakeWecom();
const config = resolveWecomConfig({ ...baseBindings, WECOM_ROBOT_WEBHOOK: "" });
await sendWecomRobotMessage("hello", config, fetchImpl);
assert.equal(
calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/webhook/send"),
).length,
0,
);
});
test("sendWecomRobotMessage throws WecomClientError on provider error", async () => {
const { fetchImpl } = fakeWecom({
robotErrcode: 93000,
robotErrmsg: "invalid webhook url",
});
await assert.rejects(
() =>
sendWecomRobotMessage(
"hello",
resolveWecomConfig(baseBindings),
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 502);
return true;
},
);
});
test("sendWecomAppMessage fetches access_token then sends to message/send", async () => {
const { calls, fetchImpl } = fakeWecom();
const result = await sendWecomAppMessage(
["user-a", "user-b"],
"催办内容",
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(result.sent, 2);
assert.equal(result.failed, 0);
assert.equal(result.skipped, false);
const tokenCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/gettoken"),
);
assert.ok(tokenCall, "gettoken was called");
const sendCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/message/send"),
);
assert.ok(sendCall, "message/send was called");
assert.equal(sendCall.url.searchParams.get("access_token"), "token-test");
const body = JSON.parse(String(sendCall.init.body));
assert.equal(body.touser, "user-a|user-b");
assert.equal(body.agentid, 1000001);
assert.equal(body.text.content, "催办内容");
});
test("sendWecomAppMessage skips when no external user ids", async () => {
const { calls, fetchImpl } = fakeWecom();
const result = await sendWecomAppMessage(
[],
"催办",
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(result.skipped, true);
assert.equal(
calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/message/send"),
).length,
0,
);
});
test("sendWecomAppMessage skips when corp credentials are missing", async () => {
const { calls, fetchImpl } = fakeWecom();
const result = await sendWecomAppMessage(
["user-a"],
"催办",
resolveWecomConfig({
...baseBindings,
WECOM_CORP_ID: "",
WECOM_AGENT_ID: "",
WECOM_SECRET: "",
}),
fetchImpl,
);
assert.equal(result.skipped, true);
assert.equal(
calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/gettoken"),
).length,
0,
);
});
test("sendWecomAppMessage reports failed when provider marks invaliduser", async () => {
const { fetchImpl } = fakeWecom({ invalidUser: "user-a" });
const result = await sendWecomAppMessage(
["user-a", "user-b"],
"催办",
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(result.sent, 1);
assert.equal(result.failed, 1);
});
test("access_token is cached across calls", async () => {
const { calls, fetchImpl } = fakeWecom();
const config = resolveWecomConfig(baseBindings);
await sendWecomAppMessage(["u1"], "a", config, fetchImpl);
await sendWecomAppMessage(["u2"], "b", config, fetchImpl);
const tokenCalls = calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/gettoken"),
);
assert.equal(tokenCalls.length, 1, "access_token cached for second call");
});
function fakeExternalContacts() {
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/externalcontact/get_follow_user_list")) {
return Response.json({
errcode: 0,
errmsg: "ok",
follow_user: [{ userid: "owner-a" }, { userid: "owner-b" }],
});
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/list")) {
const userid = url.searchParams.get("userid");
if (userid === "owner-a") {
return Response.json({
errcode: 0,
errmsg: "ok",
external_userid: ["ext-1", "ext-2"],
});
}
if (userid === "owner-b") {
return Response.json({
errcode: 0,
errmsg: "ok",
external_userid: ["ext-3"],
});
}
return Response.json({ errcode: 60020, errmsg: "user not found" });
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/get")) {
const eid = url.searchParams.get("external_userid");
if (eid === "ext-1") {
return Response.json({
errcode: 0,
errmsg: "ok",
external_contact: {
external_userid: "ext-1",
name: "张三",
avatar: "https://example.com/a.png",
corp_fullname: "ACME 公司",
},
});
}
if (eid === "ext-2") {
return Response.json({
errcode: 0,
errmsg: "ok",
external_contact: {
external_userid: "ext-2",
name: "李四",
avatar: "",
corp_fullname: "",
},
});
}
if (eid === "ext-3") {
return Response.json({
errcode: 0,
errmsg: "ok",
external_contact: {
external_userid: "ext-3",
name: "王五",
avatar: "https://example.com/c.png",
corp_fullname: "Other 公司",
},
});
}
return Response.json({ errcode: 60111, errmsg: "not found" });
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl };
}
test("listExternalContacts flattens follow_user → list → get into a single array", async () => {
const { fetchImpl } = fakeExternalContacts();
const contacts = await listExternalContacts(
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(contacts.length, 3);
const first = contacts.find((c) => c.externalUserId === "ext-1");
assert.ok(first);
assert.equal(first.name, "张三");
assert.equal(first.avatar, "https://example.com/a.png");
assert.equal(first.corpName, "ACME 公司");
assert.equal(first.ownerUserId, "owner-a");
const second = contacts.find((c) => c.externalUserId === "ext-2");
assert.ok(second);
assert.equal(second.corpName, "李四");
const third = contacts.find((c) => c.externalUserId === "ext-3");
assert.ok(third);
assert.equal(third.ownerUserId, "owner-b");
});
test("listExternalContacts skips members with non-zero errcode", async () => {
const { fetchImpl } = fakeExternalContacts();
const contacts = await listExternalContacts(
resolveWecomConfig(baseBindings),
fetchImpl,
);
const ownerBIds = contacts
.filter((c) => c.ownerUserId === "owner-b")
.map((c) => c.externalUserId);
assert.deepEqual(ownerBIds, ["ext-3"]);
});
test("listExternalContacts returns empty array when app credentials missing", async () => {
const { calls, fetchImpl } = fakeExternalContacts();
const contacts = await listExternalContacts(
resolveWecomConfig({
...baseBindings,
WECOM_CORP_ID: "",
WECOM_AGENT_ID: "",
WECOM_SECRET: "",
}),
fetchImpl,
);
assert.deepEqual(contacts, []);
assert.equal(calls.length, 0, "no API calls without credentials");
});
function fakeGroupPush(options = {}) {
const calls = [];
const pages =
options.pages ??
[
{
next_cursor: "page-2",
group_chat_list: [
{ chat_id: "wr-normal-1", status: 0 },
{ chat_id: "wr-dissolved", status: 1 },
],
},
{ next_cursor: "", group_chat_list: [{ chat_id: "wr-normal-2", status: 0 }] },
];
const details =
options.details ??
{
"wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 },
"wr-normal-2": { name: "KOC 二群", owner: "lisi", member_count: 7 },
};
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/externalcontact/groupchat/list")) {
const body = JSON.parse(String(init.body ?? "{}"));
const index = body.cursor ? Number(body.cursor.replace("page-", "")) - 1 : 0;
const page = pages[index] ?? { next_cursor: "", group_chat_list: [] };
return Response.json({
errcode: 0,
errmsg: "ok",
next_cursor: page.next_cursor,
group_chat_list: page.group_chat_list,
});
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get")) {
const body = JSON.parse(String(init.body ?? "{}"));
const detail = details[body.chat_id];
if (!detail) {
return Response.json({ errcode: 60111, errmsg: "not found" });
}
return Response.json({ errcode: 0, errmsg: "ok", group_chat: detail });
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template")) {
return Response.json({
errcode: options.templateErrcode ?? 0,
errmsg: options.templateErrmsg ?? "ok",
msgid: options.msgid ?? "msgGTEST",
fail_list: options.failList ?? [],
});
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl };
}
test("listCustomerGroupChats paginates groupchat/list and keeps only normal groups", async () => {
const { calls, fetchImpl } = fakeGroupPush();
const groups = await listCustomerGroupChats(
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(groups.length, 2);
const first = groups.find((g) => g.chatId === "wr-normal-1");
assert.ok(first);
assert.equal(first.name, "KOC 一群");
assert.equal(first.ownerUserId, "zhangsan");
assert.equal(first.memberCount, 42);
const second = groups.find((g) => g.chatId === "wr-normal-2");
assert.ok(second);
assert.equal(second.ownerUserId, "lisi");
assert.equal(
groups.some((g) => g.chatId === "wr-dissolved"),
false,
"non-normal status groups are filtered",
);
const listCalls = calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/list"),
);
assert.equal(listCalls.length, 2, "cursor pagination followed");
const secondBody = JSON.parse(String(listCalls[1].init.body));
assert.equal(secondBody.cursor, "page-2");
});
test("listCustomerGroupChats skips groups whose detail lookup fails", async () => {
const { fetchImpl } = fakeGroupPush({
pages: [
{
next_cursor: "",
group_chat_list: [
{ chat_id: "wr-normal-1", status: 0 },
{ chat_id: "wr-gone", status: 0 },
],
},
],
details: {
"wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 },
},
});
const groups = await listCustomerGroupChats(
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.deepEqual(
groups.map((g) => g.chatId),
["wr-normal-1"],
);
});
test("createGroupMsgTemplate posts group payload with text and link attachment", async () => {
const { calls, fetchImpl } = fakeGroupPush();
const result = await createGroupMsgTemplate(
resolveWecomConfig(baseBindings),
{
sender: "zhangsan",
chatIdList: ["wr-normal-1", "wr-normal-2"],
text: "【新任务】测试任务",
link: {
title: "测试任务",
desc: "品牌 A · 3 份",
url: "https://portal.example.com/?task=tok-1",
},
},
fetchImpl,
);
assert.equal(result.msgid, "msgGTEST");
assert.deepEqual(result.failList, []);
const sendCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
);
assert.ok(sendCall, "add_msg_template was called");
assert.equal(sendCall.url.searchParams.get("access_token"), "token-test");
const body = JSON.parse(String(sendCall.init.body));
assert.equal(body.chat_type, "group");
assert.equal(body.sender, "zhangsan");
assert.deepEqual(body.chat_id_list, ["wr-normal-1", "wr-normal-2"]);
assert.equal(body.text.content, "【新任务】测试任务");
assert.equal(body.attachments.length, 1);
assert.equal(body.attachments[0].msgtype, "link");
assert.equal(body.attachments[0].link.url, "https://portal.example.com/?task=tok-1");
});
test("createGroupMsgTemplate omits text and attachments keys when absent", async () => {
const { calls, fetchImpl } = fakeGroupPush();
await createGroupMsgTemplate(
resolveWecomConfig(baseBindings),
{
sender: "zhangsan",
chatIdList: ["wr-normal-1"],
link: { title: "只有图文", url: "https://portal.example.com/?task=tok-1" },
},
fetchImpl,
);
const sendCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
);
assert.ok(sendCall, "add_msg_template was called");
const body = JSON.parse(String(sendCall.init.body));
assert.equal("text" in body, false);
assert.equal(body.attachments[0].link.title, "只有图文");
assert.equal(body.attachments[0].link.desc, "");
});
test("createGroupMsgTemplate passes through fail_list from provider", async () => {
const { fetchImpl } = fakeGroupPush({
msgid: "msgFAIL",
failList: ["wr-normal-2"],
});
const result = await createGroupMsgTemplate(
resolveWecomConfig(baseBindings),
{
sender: "zhangsan",
chatIdList: ["wr-normal-1", "wr-normal-2"],
text: "内容",
},
fetchImpl,
);
assert.equal(result.msgid, "msgFAIL");
assert.deepEqual(result.failList, ["wr-normal-2"]);
});
test("createGroupMsgTemplate throws WecomClientError on provider error", async () => {
const { fetchImpl } = fakeGroupPush({
templateErrcode: 81053,
templateErrmsg: "user not in visible scope",
});
await assert.rejects(
() =>
createGroupMsgTemplate(
resolveWecomConfig(baseBindings),
{ sender: "zhangsan", chatIdList: ["wr-normal-1"], text: "内容" },
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 502);
assert.match(error.message, /user not in visible scope/);
return true;
},
);
});
test("createGroupMsgTemplate rejects empty text and link before calling API", async () => {
const { calls, fetchImpl } = fakeGroupPush();
await assert.rejects(
() =>
createGroupMsgTemplate(
resolveWecomConfig(baseBindings),
{ sender: "zhangsan", chatIdList: ["wr-normal-1"] },
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 400);
return true;
},
);
assert.equal(
calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
).length,
0,
"no API call for invalid payload",
);
});
test("createGroupMsgTemplate rejects missing sender or empty chat list", async () => {
const { fetchImpl } = fakeGroupPush();
const config = resolveWecomConfig(baseBindings);
await assert.rejects(
() =>
createGroupMsgTemplate(config, {
sender: "",
chatIdList: ["wr-normal-1"],
text: "内容",
}, fetchImpl),
/群主/,
);
await assert.rejects(
() =>
createGroupMsgTemplate(config, {
sender: "zhangsan",
chatIdList: [],
text: "内容",
}, fetchImpl),
/客户群列表为空/,
);
});

View File

@@ -0,0 +1,262 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildTaskGroupPushMessage,
groupChatsByOwner,
pushTaskToGroupChats,
trimToBytes,
} from "../lib/wecom-group-push-service.ts";
import {
clearWecomAccessTokenCacheForTests,
resolveWecomConfig,
WecomClientError,
} from "../lib/wecom-client.ts";
const baseBindings = {
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
};
const baseTask = {
id: "task-1",
name: "测试任务",
brand: "品牌A",
quantity: 3,
due_at: "2026-09-01",
task_type: "content_publish",
platform: "小红书",
content_format: "image_text",
share_token: "tok-123",
};
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
test("trimToBytes cuts on UTF-8 boundaries", () => {
assert.equal(trimToBytes("abc", 10), "abc");
assert.equal(trimToBytes("abcdef", 3), "abc");
// 「汉」占 3 字节
assert.equal(trimToBytes("a汉b", 4), "a汉");
assert.equal(trimToBytes("汉汉汉", 7), "汉汉");
});
test("buildTaskGroupPushMessage composes text and link with claim url", () => {
const message = buildTaskGroupPushMessage(baseTask, "https://portal.example.com/");
assert.match(message.text, /【新任务】测试任务/);
assert.match(message.text, /品牌品牌A数量3 份截止2026-09-01/);
assert.match(message.text, /平台:小红书|形式:图文/);
assert.match(message.text, /领取链接https:\/\/portal\.example\.com\/\?task=tok-123/);
assert.ok(message.link);
assert.equal(message.link.title, "测试任务");
assert.equal(message.link.url, "https://portal.example.com/?task=tok-123");
assert.match(message.link.desc, /品牌A · 3 份 · 截止 2026-09-01/);
});
test("buildTaskGroupPushMessage marks video and screenshot tasks", () => {
const video = buildTaskGroupPushMessage(
{ ...baseTask, content_format: "video" },
"https://portal.example.com",
);
assert.match(video.text, /形式:视频/);
const screenshot = buildTaskGroupPushMessage(
{ ...baseTask, task_type: "screenshot_collect" },
"https://portal.example.com",
);
assert.match(screenshot.text, /形式:截图回收/);
});
test("buildTaskGroupPushMessage drops link when share_token missing", () => {
const message = buildTaskGroupPushMessage(
{ ...baseTask, share_token: null },
"https://portal.example.com",
);
assert.equal(message.link, null);
assert.match(message.text, /领取链接生成失败/);
});
test("buildTaskGroupPushMessage trims long content to byte limits", () => {
const longName = "长".repeat(200);
const longBrand = "牌".repeat(400);
const message = buildTaskGroupPushMessage(
{ ...baseTask, name: longName, brand: longBrand },
"https://portal.example.com",
);
assert.ok(Buffer.byteLength(message.text, "utf8") <= 4000);
assert.ok(Buffer.byteLength(message.link.title, "utf8") <= 128);
assert.ok(Buffer.byteLength(message.link.desc, "utf8") <= 512);
});
test("groupChatsByOwner groups chat ids by owner and skips empty owners", () => {
const grouped = groupChatsByOwner([
{ chat_id: "wr-1", name: "一", owner_user_id: "zhangsan", member_count: 1, status: 0 },
{ chat_id: "wr-2", name: "二", owner_user_id: "lisi", member_count: 2, status: 0 },
{ chat_id: "wr-3", name: "三", owner_user_id: "zhangsan", member_count: 3, status: 0 },
{ chat_id: "wr-4", name: "四", owner_user_id: " ", member_count: 4, status: 0 },
]);
assert.deepEqual(grouped, [
{ ownerUserId: "zhangsan", chatIds: ["wr-1", "wr-3"] },
{ ownerUserId: "lisi", chatIds: ["wr-2"] },
]);
});
function fakeGroupPushDb(taskRow) {
const executed = [];
const groupRows = [
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 10, status: 0 },
{ chat_id: "wr-2", name: "二群", owner_user_id: "lisi", member_count: 5, status: 0 },
];
const makeStatement = (sql, params = []) => ({
sql,
params,
bind(...args) {
return makeStatement(sql, args);
},
async run() {
executed.push({ sql, params });
return {};
},
async first() {
executed.push({ sql, params });
if (/FROM tasks WHERE id = \?/.test(sql)) {
return params[0] === taskRow.id ? taskRow : null;
}
return null;
},
async all() {
executed.push({ sql, params });
if (/FROM wecom_group_chats/.test(sql)) {
return {
results: sql.includes(" IN (")
? groupRows.filter((row) => params.includes(row.chat_id))
: groupRows,
};
}
return { results: [] };
},
});
return {
executed,
prepare(sql) {
return makeStatement(sql);
},
async batch(statements) {
for (const statement of statements) await statement.run();
},
};
}
function fakeTemplateFetch(calls) {
return 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/externalcontact/add_msg_template")) {
return Response.json({ errcode: 0, errmsg: "ok", msgid: "msg-1", fail_list: [] });
}
return new Response("not found", { status: 404 });
};
}
test("pushTaskToGroupChats batches per owner and records each send", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakeGroupPushDb(baseTask);
const calls = [];
const summary = await pushTaskToGroupChats(
db,
{ taskId: "task-1", chatIds: ["wr-2", "wr-1"] },
resolveWecomConfig(baseBindings),
fakeTemplateFetch(calls),
);
assert.equal(summary.results.length, 2);
const senders = summary.results.map((item) => item.sender).sort();
assert.deepEqual(senders, ["lisi", "zhangsan"]);
const templateCalls = calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
);
assert.equal(templateCalls.length, 2, "one call per owner");
const bodies = templateCalls.map((c) => JSON.parse(String(c.init.body)));
const bySender = new Map(bodies.map((body) => [body.sender, body]));
assert.deepEqual(bySender.get("zhangsan").chat_id_list, ["wr-1"]);
assert.deepEqual(bySender.get("lisi").chat_id_list, ["wr-2"]);
assert.match(bySender.get("zhangsan").text.content, /【新任务】测试任务/);
assert.equal(
bySender.get("zhangsan").attachments[0].link.url,
"https://portal.example.com/?task=tok-123",
);
const inserts = db.executed.filter((item) =>
item.sql.includes("INSERT INTO wecom_group_pushes"),
);
assert.equal(inserts.length, 2);
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("pushTaskToGroupChats rejects unknown chat ids", async () => {
const db = fakeGroupPushDb(baseTask);
const calls = [];
await assert.rejects(
() =>
pushTaskToGroupChats(
db,
{ taskId: "task-1", chatIds: ["wr-404"] },
resolveWecomConfig(baseBindings),
fakeTemplateFetch(calls),
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 400);
assert.match(error.message, /未同步/);
return true;
},
);
});
test("pushTaskToGroupChats rejects missing task", async () => {
const db = fakeGroupPushDb(baseTask);
await assert.rejects(
() =>
pushTaskToGroupChats(
db,
{ taskId: "task-404", chatIds: ["wr-1"] },
resolveWecomConfig(baseBindings),
fakeTemplateFetch([]),
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 404);
return true;
},
);
});
test("pushTaskToGroupChats prefers explicit text over template", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakeGroupPushDb(baseTask);
const calls = [];
await pushTaskToGroupChats(
db,
{ taskId: "task-1", chatIds: ["wr-1"], text: "运营手动编辑的文案" },
resolveWecomConfig(baseBindings),
fakeTemplateFetch(calls),
);
const templateCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
);
const body = JSON.parse(String(templateCall.init.body));
assert.equal(body.text.content, "运营手动编辑的文案");
} finally {
delete process.env.KOC_PORTAL_URL;
}
});

View File

@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import { computeDueCutoff } from "../lib/wecom-notifier-service.ts";
test("computeDueCutoff advances by dueDays and crosses month/year", () => {
assert.equal(computeDueCutoff("2026-08-18", 3), "2026-08-21");
assert.equal(computeDueCutoff("2026-08-30", 3), "2026-09-02");
assert.equal(computeDueCutoff("2026-12-30", 3), "2027-01-02");
assert.equal(computeDueCutoff("2026-08-18", 0), "2026-08-18");
assert.equal(computeDueCutoff("2026-08-18", 10), "2026-08-28");
});
test("computeDueCutoff returns input unchanged when malformed", () => {
assert.equal(computeDueCutoff("not-a-date", 3), "not-a-date");
});