Compare commits
4 Commits
codex/self
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9697b5890d | ||
| 8f7ea0558d | |||
|
|
f2ac751c4c | ||
|
|
cac6c5e83b |
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|
||||||
|
# 企业微信通知(临期催办 + 管理员汇总)。群机器人只需 webhook;KOC 侧催办还需 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
2
.gitignore
vendored
@@ -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
132
CLAUDE.md
Normal 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,取值 1–30(可选)
|
||||||
|
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 -->
|
||||||
1235
app/admin-app.tsx
1235
app/admin-app.tsx
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -186,7 +186,12 @@ async function handleGet(request: Request) {
|
|||||||
}
|
}
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
object.writeHttpMetadata(headers);
|
object.writeHttpMetadata(headers);
|
||||||
headers.set("Cache-Control", "private, max-age=3600");
|
const isMutableEvidence =
|
||||||
|
imageKind === "publish" || imageKind === "creator";
|
||||||
|
headers.set(
|
||||||
|
"Cache-Control",
|
||||||
|
isMutableEvidence ? "private, no-store" : "private, max-age=3600",
|
||||||
|
);
|
||||||
if (imageKind === "video") {
|
if (imageKind === "video") {
|
||||||
headers.set("Content-Type", "video/mp4");
|
headers.set("Content-Type", "video/mp4");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
64
app/api/resources-insert/route.ts
Normal file
64
app/api/resources-insert/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
436
app/globals.css
436
app/globals.css
@@ -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;
|
||||||
|
|||||||
30
db/schema.ts
30
db/schema.ts
@@ -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
32
dev.vars_exp
Normal 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
|
||||||
@@ -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,超过则不在催办;可填 1–30 |
|
||||||
|
| `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`(外部联系人 ID),KOC 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 会进入失败列表但不影响其他群。
|
||||||
|
|||||||
@@ -1792,6 +1792,11 @@ footer {
|
|||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
color: var(--green-deep);
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-shell {
|
.loading-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ function statusLabel(item: Assignment, taskType = "content_publish") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TASK_RESULT_SCREENSHOTS = 9;
|
const MAX_TASK_RESULT_SCREENSHOTS = 9;
|
||||||
|
const PUBLISH_BACKFILL_SUCCESS = "这篇笔记的发布记录回填成功啦~";
|
||||||
|
|
||||||
|
function toastClassName(message: string) {
|
||||||
|
return message === PUBLISH_BACKFILL_SUCCESS ? "toast success" : "toast";
|
||||||
|
}
|
||||||
|
|
||||||
function resultScreenshotKeys(value: string | null) {
|
function resultScreenshotKeys(value: string | null) {
|
||||||
const text = String(value ?? "").trim();
|
const text = String(value ?? "").trim();
|
||||||
@@ -594,6 +599,11 @@ export default function Home() {
|
|||||||
index: "1",
|
index: "1",
|
||||||
kind,
|
kind,
|
||||||
});
|
});
|
||||||
|
const evidenceKey =
|
||||||
|
kind === "publish"
|
||||||
|
? item.publish_screenshot_key
|
||||||
|
: item.creator_screenshot_key;
|
||||||
|
if (evidenceKey) params.set("v", evidenceKey);
|
||||||
if (delegationToken) params.set("share", delegationToken);
|
if (delegationToken) params.set("share", delegationToken);
|
||||||
else {
|
else {
|
||||||
params.set("task", taskToken);
|
params.set("task", taskToken);
|
||||||
@@ -1013,7 +1023,7 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}, 120);
|
}, 120);
|
||||||
}
|
}
|
||||||
setToast("这篇笔记已回填,不会与其他笔记错配");
|
setToast(PUBLISH_BACKFILL_SUCCESS);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setToast(reason instanceof Error ? reason.message : "回填失败");
|
setToast(reason instanceof Error ? reason.message : "回填失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1283,7 +1293,7 @@ export default function Home() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className={toastClassName(toast)}>{toast}</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1610,7 +1620,6 @@ export default function Home() {
|
|||||||
value={creatorExposure}
|
value={creatorExposure}
|
||||||
onChange={(event) => setCreatorExposure(event.target.value)}
|
onChange={(event) => setCreatorExposure(event.target.value)}
|
||||||
placeholder="填写截图中的曝光量"
|
placeholder="填写截图中的曝光量"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -1624,7 +1633,6 @@ export default function Home() {
|
|||||||
value={creatorViews}
|
value={creatorViews}
|
||||||
onChange={(event) => setCreatorViews(event.target.value)}
|
onChange={(event) => setCreatorViews(event.target.value)}
|
||||||
placeholder="填写截图中的阅读量"
|
placeholder="填写截图中的阅读量"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1650,7 +1658,7 @@ export default function Home() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className={toastClassName(toast)}>{toast}</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1957,7 +1965,7 @@ export default function Home() {
|
|||||||
? "请保存当前分享链接;完成任务后通过此链接上传截图即可。"
|
? "请保存当前分享链接;完成任务后通过此链接上传截图即可。"
|
||||||
: "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
|
: "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
|
||||||
</div>
|
</div>
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className={toastClassName(toast)}>{toast}</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2104,7 +2112,7 @@ export default function Home() {
|
|||||||
<div><span>3</span><strong>{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}</strong><p>{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}</p></div>
|
<div><span>3</span><strong>{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}</strong><p>{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}</p></div>
|
||||||
</section>
|
</section>
|
||||||
<footer>由 KOC LOOP 提供任务与数据安全保障</footer>
|
<footer>由 KOC LOOP 提供任务与数据安全保障</footer>
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className={toastClassName(toast)}>{toast}</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
|||||||
assert.match(page, /找回领取记录/);
|
assert.match(page, /找回领取记录/);
|
||||||
assert.match(page, /action:\s*"recover"/);
|
assert.match(page, /action:\s*"recover"/);
|
||||||
assert.match(page, /同一任务多次领取会分批展示/);
|
assert.match(page, /同一任务多次领取会分批展示/);
|
||||||
assert.match(page, /这篇笔记已回填,不会与其他笔记错配/);
|
assert.match(page, /这篇笔记的发布记录回填成功啦~/);
|
||||||
|
assert.match(page, /toastClassName\(toast\)/);
|
||||||
|
assert.match(styles, /\.toast\.success/);
|
||||||
assert.match(page, /笔记内容已收起/);
|
assert.match(page, /笔记内容已收起/);
|
||||||
assert.match(page, /展开笔记内容/);
|
assert.match(page, /展开笔记内容/);
|
||||||
assert.match(page, /收起笔记内容/);
|
assert.match(page, /收起笔记内容/);
|
||||||
@@ -92,6 +94,7 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
|||||||
assert.match(page, /creatorViews/);
|
assert.match(page, /creatorViews/);
|
||||||
assert.match(page, /截图仅用于运营核对,不再自动OCR/);
|
assert.match(page, /截图仅用于运营核对,不再自动OCR/);
|
||||||
assert.match(page, /evidenceImageUrl/);
|
assert.match(page, /evidenceImageUrl/);
|
||||||
|
assert.match(page, /params\.set\("v", evidenceKey\)/);
|
||||||
assert.match(page, /evidenceImageUrl\(selected, "publish"\)/);
|
assert.match(page, /evidenceImageUrl\(selected, "publish"\)/);
|
||||||
assert.match(page, /evidenceImageUrl\(selected, "creator"\)/);
|
assert.match(page, /evidenceImageUrl\(selected, "creator"\)/);
|
||||||
assert.match(page, /ImageLightbox/);
|
assert.match(page, /ImageLightbox/);
|
||||||
@@ -100,6 +103,8 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
|||||||
assert.match(page, /creatorScreenshotPreview/);
|
assert.match(page, /creatorScreenshotPreview/);
|
||||||
assert.match(page, /曝光量/);
|
assert.match(page, /曝光量/);
|
||||||
assert.match(page, /阅读量/);
|
assert.match(page, /阅读量/);
|
||||||
|
assert.match(page, /placeholder="填写截图中的曝光量"\s*\/>/);
|
||||||
|
assert.match(page, /placeholder="填写截图中的阅读量"\s*\/>/);
|
||||||
assert.doesNotMatch(page, /recognizeCreatorMetrics/);
|
assert.doesNotMatch(page, /recognizeCreatorMetrics/);
|
||||||
assert.match(page, /note-index \$\{\(isScreenshotTask \? item\.result_submitted_at : item\.publish_url\) \? "done" : ""\}/);
|
assert.match(page, /note-index \$\{\(isScreenshotTask \? item\.result_submitted_at : item\.publish_url\) \? "done" : ""\}/);
|
||||||
assert.match(packageJson, /"fflate":\s*"0\.7\.4"/);
|
assert.match(packageJson, /"fflate":\s*"0\.7\.4"/);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
366
lib/resource-write.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { ensureSchema, getRawDb } from "./mvp-db";
|
||||||
|
import {
|
||||||
|
resolveCollectionMcpConfig,
|
||||||
|
resolveProfileDetailsFromMcp,
|
||||||
|
resolveXhsPublicAccountDetails,
|
||||||
|
type CollectionMcpBindings,
|
||||||
|
} from "./mcp-collection-client";
|
||||||
|
import { getRuntimeEnv } from "./runtime-env";
|
||||||
|
import {
|
||||||
|
mergeCooperationSources,
|
||||||
|
normalizeProfileUrl,
|
||||||
|
resourceImportMissingFields,
|
||||||
|
resourcePlatformUid,
|
||||||
|
type ResourceImportRow,
|
||||||
|
} from "./resource-import";
|
||||||
|
|
||||||
|
export type AccountRow = {
|
||||||
|
id: string;
|
||||||
|
platform: string;
|
||||||
|
platform_uid: string;
|
||||||
|
public_account_id: string;
|
||||||
|
nickname: string;
|
||||||
|
profile_url: string;
|
||||||
|
ip_location: string;
|
||||||
|
followers: number;
|
||||||
|
gender: string;
|
||||||
|
bio: string;
|
||||||
|
tags: string;
|
||||||
|
cooperation_source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalyzedRow = ResourceImportRow & {
|
||||||
|
action: "create" | "update" | "error";
|
||||||
|
accountId: string;
|
||||||
|
platformUid: string;
|
||||||
|
cooperationSource: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RESOURCE_IMPORT_SYNC_ENRICH_ROWS = 100;
|
||||||
|
const RESOURCE_IMPORT_DB_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
export function identityKey(platform: string, value: string) {
|
||||||
|
return `${platform.trim().toLocaleLowerCase("zh-CN")}|${value
|
||||||
|
.trim()
|
||||||
|
.toLocaleLowerCase("zh-CN")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAccounts() {
|
||||||
|
await ensureSchema();
|
||||||
|
return getRawDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, platform, platform_uid, public_account_id, nickname,
|
||||||
|
profile_url, ip_location, followers, gender, bio, tags,
|
||||||
|
cooperation_source
|
||||||
|
FROM accounts`,
|
||||||
|
)
|
||||||
|
.all<AccountRow>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapConcurrent<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
worker: (item: T) => Promise<R>,
|
||||||
|
) {
|
||||||
|
const results = new Array<R>(items.length);
|
||||||
|
let cursor = 0;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const index = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
results[index] = await worker(items[index]);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeExistingFields(
|
||||||
|
rows: ResourceImportRow[],
|
||||||
|
accounts: AccountRow[],
|
||||||
|
) {
|
||||||
|
const existingByProfile = new Map<string, AccountRow>();
|
||||||
|
for (const account of accounts) {
|
||||||
|
const profileUrl = normalizeProfileUrl(account.profile_url || "");
|
||||||
|
if (profileUrl) {
|
||||||
|
existingByProfile.set(identityKey(account.platform, profileUrl), account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.map((row) => {
|
||||||
|
if (
|
||||||
|
row.errors.length > 0 ||
|
||||||
|
!row.profileUrl ||
|
||||||
|
!["小红书", "抖音"].includes(row.platform)
|
||||||
|
) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
|
||||||
|
const existingNickname =
|
||||||
|
existing?.nickname && existing.nickname !== "待识别账号"
|
||||||
|
? existing.nickname
|
||||||
|
: "";
|
||||||
|
const existingIpLocation =
|
||||||
|
existing?.ip_location && existing.ip_location !== "待识别"
|
||||||
|
? existing.ip_location
|
||||||
|
: "";
|
||||||
|
const existingGender: ResourceImportRow["gender"] =
|
||||||
|
existing?.gender === "男" || existing?.gender === "女"
|
||||||
|
? existing.gender
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
nickname: row.nickname || existingNickname,
|
||||||
|
publicAccountId: row.publicAccountId || existing?.public_account_id || "",
|
||||||
|
ipLocation: row.ipLocation || existingIpLocation,
|
||||||
|
followers: row.followersResolved
|
||||||
|
? row.followers
|
||||||
|
: Number(existing?.followers || 0),
|
||||||
|
followersResolved:
|
||||||
|
row.followersResolved || Number(existing?.followers || 0) > 0,
|
||||||
|
gender: row.gender || existingGender,
|
||||||
|
bio: row.bio || existing?.bio || "",
|
||||||
|
tags: row.tags.length > 0
|
||||||
|
? row.tags
|
||||||
|
: (existing?.tags || "")
|
||||||
|
.split(/[,,、;;|]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 5),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichRows(
|
||||||
|
rows: ResourceImportRow[],
|
||||||
|
accounts: AccountRow[],
|
||||||
|
) {
|
||||||
|
const mcpConfig = resolveCollectionMcpConfig(
|
||||||
|
getRuntimeEnv() as unknown as CollectionMcpBindings,
|
||||||
|
);
|
||||||
|
const baselineRows = mergeExistingFields(rows, accounts);
|
||||||
|
return mapConcurrent(baselineRows, 4, async (baseline) => {
|
||||||
|
const row = baseline;
|
||||||
|
if (
|
||||||
|
row.errors.length > 0 ||
|
||||||
|
!row.profileUrl ||
|
||||||
|
!["小红书", "抖音"].includes(row.platform)
|
||||||
|
) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
if (resourceImportMissingFields(baseline).length === 0) {
|
||||||
|
return baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
let details: {
|
||||||
|
nickname: string | null;
|
||||||
|
redId: string | null;
|
||||||
|
followers: number | null;
|
||||||
|
ipLocation: string | null;
|
||||||
|
gender: "" | "男" | "女";
|
||||||
|
bio: string;
|
||||||
|
recentNoteTitles: string[];
|
||||||
|
providerTags: string[];
|
||||||
|
} = await resolveProfileDetailsFromMcp(
|
||||||
|
row.profileUrl,
|
||||||
|
row.platform === "抖音" ? "抖音" : "小红书",
|
||||||
|
mcpConfig,
|
||||||
|
).catch(() => ({
|
||||||
|
nickname: null,
|
||||||
|
redId: null,
|
||||||
|
followers: null,
|
||||||
|
ipLocation: null,
|
||||||
|
gender: "" as const,
|
||||||
|
bio: "",
|
||||||
|
recentNoteTitles: [],
|
||||||
|
providerTags: [],
|
||||||
|
}));
|
||||||
|
const mcpResult = {
|
||||||
|
nickname: baseline.nickname || details.nickname?.trim() || "",
|
||||||
|
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
|
||||||
|
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "",
|
||||||
|
followersResolved: baseline.followersResolved || details.followers !== null,
|
||||||
|
gender: baseline.gender || details.gender,
|
||||||
|
bio: baseline.bio || details.bio,
|
||||||
|
tags: baseline.tags,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
row.platform === "小红书" &&
|
||||||
|
resourceImportMissingFields(mcpResult).length > 0
|
||||||
|
) {
|
||||||
|
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl).catch(
|
||||||
|
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
|
||||||
|
);
|
||||||
|
details = {
|
||||||
|
...details,
|
||||||
|
nickname: details.nickname || publicDetails.nickname,
|
||||||
|
redId: details.redId || publicDetails.redId,
|
||||||
|
followers: details.followers ?? publicDetails.followers,
|
||||||
|
ipLocation: details.ipLocation || publicDetails.ipLocation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseline,
|
||||||
|
nickname: baseline.nickname || details.nickname?.trim() || "待识别账号",
|
||||||
|
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
|
||||||
|
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "待识别",
|
||||||
|
followers: baseline.followersResolved
|
||||||
|
? baseline.followers
|
||||||
|
: (details.followers ?? 0),
|
||||||
|
followersResolved:
|
||||||
|
baseline.followersResolved || details.followers !== null,
|
||||||
|
gender: baseline.gender || details.gender,
|
||||||
|
bio: baseline.bio || details.bio,
|
||||||
|
tags: baseline.tags,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeRows(
|
||||||
|
rows: ResourceImportRow[],
|
||||||
|
accountRows: AccountRow[],
|
||||||
|
) {
|
||||||
|
const profileMap = new Map<string, AccountRow>();
|
||||||
|
const publicIdMap = new Map<string, AccountRow>();
|
||||||
|
const platformUidMap = new Map<string, AccountRow>();
|
||||||
|
for (const account of accountRows) {
|
||||||
|
const profileUrl = normalizeProfileUrl(account.profile_url || "");
|
||||||
|
if (profileUrl) profileMap.set(identityKey(account.platform, profileUrl), account);
|
||||||
|
if (account.public_account_id) {
|
||||||
|
publicIdMap.set(identityKey(account.platform, account.public_account_id), account);
|
||||||
|
}
|
||||||
|
platformUidMap.set(identityKey(account.platform, account.platform_uid), account);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map<AnalyzedRow>((row) => {
|
||||||
|
const platformUid = resourcePlatformUid(row);
|
||||||
|
const profileMatch = row.profileUrl
|
||||||
|
? profileMap.get(identityKey(row.platform, row.profileUrl))
|
||||||
|
: undefined;
|
||||||
|
const publicIdMatch = row.publicAccountId
|
||||||
|
? publicIdMap.get(identityKey(row.platform, row.publicAccountId))
|
||||||
|
: undefined;
|
||||||
|
const uidMatch = platformUidMap.get(identityKey(row.platform, platformUid));
|
||||||
|
const matches = [profileMatch, publicIdMatch, uidMatch].filter(
|
||||||
|
(account): account is AccountRow => Boolean(account),
|
||||||
|
);
|
||||||
|
const matchedIds = [...new Set(matches.map((account) => account.id))];
|
||||||
|
const errors = [...row.errors];
|
||||||
|
if (matchedIds.length > 1) {
|
||||||
|
errors.push("账号主页和账号ID匹配到不同的现有账号,请先核对");
|
||||||
|
}
|
||||||
|
const existing = matchedIds.length === 1 ? matches[0] : undefined;
|
||||||
|
const accountId = existing?.id ?? `account-${crypto.randomUUID().slice(0, 12)}`;
|
||||||
|
const analyzed: AnalyzedRow = {
|
||||||
|
...row,
|
||||||
|
errors,
|
||||||
|
action: errors.length > 0 ? "error" : existing ? "update" : "create",
|
||||||
|
accountId,
|
||||||
|
platformUid: existing?.platform_uid ?? platformUid,
|
||||||
|
cooperationSource: mergeCooperationSources(
|
||||||
|
existing?.cooperation_source ?? "",
|
||||||
|
row.cooperationSource,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (analyzed.action !== "error") {
|
||||||
|
const virtual: AccountRow = {
|
||||||
|
id: accountId,
|
||||||
|
platform: row.platform,
|
||||||
|
platform_uid: analyzed.platformUid,
|
||||||
|
public_account_id: row.publicAccountId || existing?.public_account_id || "",
|
||||||
|
nickname: row.nickname,
|
||||||
|
profile_url: row.profileUrl || existing?.profile_url || "",
|
||||||
|
ip_location: row.ipLocation || existing?.ip_location || "待识别",
|
||||||
|
followers: row.followers || existing?.followers || 0,
|
||||||
|
gender: row.gender || existing?.gender || "",
|
||||||
|
bio: row.bio || existing?.bio || "",
|
||||||
|
tags: (row.tags.length > 0
|
||||||
|
? row.tags
|
||||||
|
: (existing?.tags || "").split(/[,,、;;|]/).filter(Boolean)
|
||||||
|
).slice(0, 5).join(","),
|
||||||
|
cooperation_source: analyzed.cooperationSource,
|
||||||
|
};
|
||||||
|
if (row.profileUrl) profileMap.set(identityKey(row.platform, row.profileUrl), virtual);
|
||||||
|
if (row.publicAccountId) {
|
||||||
|
publicIdMap.set(identityKey(row.platform, row.publicAccountId), virtual);
|
||||||
|
}
|
||||||
|
platformUidMap.set(identityKey(row.platform, analyzed.platformUid), virtual);
|
||||||
|
}
|
||||||
|
return analyzed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function statementForAnalyzedRow(
|
||||||
|
db: ReturnType<typeof getRawDb>,
|
||||||
|
row: AnalyzedRow,
|
||||||
|
) {
|
||||||
|
return row.action === "update"
|
||||||
|
? db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE accounts SET
|
||||||
|
nickname = ?,
|
||||||
|
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
|
||||||
|
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
|
||||||
|
ip_location = CASE
|
||||||
|
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
|
||||||
|
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
|
||||||
|
gender = CASE WHEN ? != '' THEN ? ELSE gender END,
|
||||||
|
bio = CASE WHEN ? != '' THEN ? ELSE bio END,
|
||||||
|
tags = CASE WHEN ? != '' THEN ? ELSE tags END,
|
||||||
|
cooperation_source = ?,
|
||||||
|
last_seen_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
row.nickname || "待识别账号",
|
||||||
|
row.publicAccountId,
|
||||||
|
row.publicAccountId,
|
||||||
|
row.profileUrl,
|
||||||
|
row.profileUrl,
|
||||||
|
row.ipLocation,
|
||||||
|
row.ipLocation,
|
||||||
|
row.ipLocation,
|
||||||
|
row.followersResolved ? 1 : 0,
|
||||||
|
row.followers,
|
||||||
|
row.gender,
|
||||||
|
row.gender,
|
||||||
|
row.bio,
|
||||||
|
row.bio,
|
||||||
|
row.tags.join(","),
|
||||||
|
row.tags.join(","),
|
||||||
|
row.cooperationSource,
|
||||||
|
row.accountId,
|
||||||
|
)
|
||||||
|
: db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO accounts
|
||||||
|
(id, platform, platform_uid, public_account_id, nickname,
|
||||||
|
profile_url, ip_location, followers, post_count, avg_views,
|
||||||
|
gender, bio, tags, cooperation_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
row.accountId,
|
||||||
|
row.platform,
|
||||||
|
row.platformUid,
|
||||||
|
row.publicAccountId,
|
||||||
|
row.nickname || "待识别账号",
|
||||||
|
row.profileUrl,
|
||||||
|
row.ipLocation || "待识别",
|
||||||
|
row.followers,
|
||||||
|
row.gender,
|
||||||
|
row.bio,
|
||||||
|
row.tags.join(","),
|
||||||
|
row.cooperationSource,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAnalyzedRows(rows: AnalyzedRow[]) {
|
||||||
|
const db = getRawDb();
|
||||||
|
const statements = rows
|
||||||
|
.filter((row) => row.action !== "error")
|
||||||
|
.map((row) => statementForAnalyzedRow(db, row));
|
||||||
|
for (let index = 0; index < statements.length; index += RESOURCE_IMPORT_DB_BATCH_SIZE) {
|
||||||
|
await db.batch(statements.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
460
lib/wecom-client.ts
Normal 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;
|
||||||
|
}
|
||||||
326
lib/wecom-group-push-service.ts
Normal file
326
lib/wecom-group-push-service.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import type { DatabaseClient } from "./database";
|
||||||
|
import { getRuntimeEnv } from "./runtime-env";
|
||||||
|
import {
|
||||||
|
createGroupMsgTemplate,
|
||||||
|
listCustomerGroupChats,
|
||||||
|
WecomClientError,
|
||||||
|
type WecomConfig,
|
||||||
|
} from "./wecom-client";
|
||||||
|
|
||||||
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
export const WECOM_GROUP_BATCH_LIMIT = 2000;
|
||||||
|
const TEXT_MAX_BYTES = 4000;
|
||||||
|
const LINK_TITLE_MAX_BYTES = 128;
|
||||||
|
const LINK_DESC_MAX_BYTES = 512;
|
||||||
|
const LINK_URL_MAX_BYTES = 2048;
|
||||||
|
|
||||||
|
export type TaskPushRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
quantity: number;
|
||||||
|
due_at: string;
|
||||||
|
task_type: string;
|
||||||
|
platform: string;
|
||||||
|
content_format: string;
|
||||||
|
share_token: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskPushMessage = {
|
||||||
|
text: string;
|
||||||
|
link: { title: string; desc: string; url: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupChatRow = {
|
||||||
|
chat_id: string;
|
||||||
|
name: string;
|
||||||
|
owner_user_id: string;
|
||||||
|
member_count: number;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupPushResult = {
|
||||||
|
sender: string;
|
||||||
|
msgid: string;
|
||||||
|
chatCount: number;
|
||||||
|
failList: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupPushRecord = {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
msgid: string;
|
||||||
|
sender: string;
|
||||||
|
chatIds: string[];
|
||||||
|
failList: string[];
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function bindingValue(value: unknown) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimToBytes(value: string, maxBytes: number) {
|
||||||
|
let output = "";
|
||||||
|
let used = 0;
|
||||||
|
for (const char of Array.from(value)) {
|
||||||
|
const size = Buffer.byteLength(char, "utf8");
|
||||||
|
if (used + size > maxBytes) break;
|
||||||
|
output += char;
|
||||||
|
used += size;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTaskGroupPushMessage(
|
||||||
|
task: TaskPushRow,
|
||||||
|
portalUrl: string,
|
||||||
|
): TaskPushMessage {
|
||||||
|
const portal = portalUrl.trim().replace(/\/$/, "");
|
||||||
|
const shareToken = bindingValue(task.share_token);
|
||||||
|
const claimUrl = portal && shareToken ? `${portal}/?task=${shareToken}` : "";
|
||||||
|
const formatLabel =
|
||||||
|
task.task_type === "screenshot_collect"
|
||||||
|
? "截图回收"
|
||||||
|
: task.content_format === "video"
|
||||||
|
? "视频"
|
||||||
|
: "图文";
|
||||||
|
const lines = [
|
||||||
|
`【新任务】${task.name}`,
|
||||||
|
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
|
||||||
|
`平台:${task.platform}|形式:${formatLabel}`,
|
||||||
|
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
text: trimToBytes(lines.join("\n"), TEXT_MAX_BYTES),
|
||||||
|
link: claimUrl
|
||||||
|
? {
|
||||||
|
title: trimToBytes(task.name, LINK_TITLE_MAX_BYTES),
|
||||||
|
desc: trimToBytes(
|
||||||
|
`品牌 ${task.brand} · ${task.quantity} 份 · 截止 ${task.due_at}`,
|
||||||
|
LINK_DESC_MAX_BYTES,
|
||||||
|
),
|
||||||
|
url: claimUrl.slice(0, LINK_URL_MAX_BYTES),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupChatsByOwner(chats: GroupChatRow[]) {
|
||||||
|
const grouped = new Map<string, string[]>();
|
||||||
|
for (const chat of chats) {
|
||||||
|
const owner = bindingValue(chat.owner_user_id);
|
||||||
|
if (!owner) continue;
|
||||||
|
grouped.set(owner, [...(grouped.get(owner) ?? []), chat.chat_id]);
|
||||||
|
}
|
||||||
|
return [...grouped.entries()].map(([ownerUserId, chatIds]) => ({
|
||||||
|
ownerUserId,
|
||||||
|
chatIds,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncGroupChats(
|
||||||
|
db: DatabaseClient,
|
||||||
|
config: WecomConfig,
|
||||||
|
fetchImpl: FetchLike = fetch,
|
||||||
|
): Promise<GroupChatRow[]> {
|
||||||
|
if (!(config.corpId && config.agentId && config.secret)) {
|
||||||
|
throw new WecomClientError(
|
||||||
|
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const groups = await listCustomerGroupChats(config, fetchImpl);
|
||||||
|
const syncedAt = new Date().toISOString();
|
||||||
|
await db.prepare("DELETE FROM wecom_group_chats").run();
|
||||||
|
const inserts = groups.map((group) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO wecom_group_chats
|
||||||
|
(chat_id, name, owner_user_id, member_count, status, synced_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
group.chatId,
|
||||||
|
group.name,
|
||||||
|
group.ownerUserId,
|
||||||
|
group.memberCount,
|
||||||
|
group.status,
|
||||||
|
syncedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (inserts.length > 0) await db.batch(inserts);
|
||||||
|
return groups.map((group) => ({
|
||||||
|
chat_id: group.chatId,
|
||||||
|
name: group.name,
|
||||||
|
owner_user_id: group.ownerUserId,
|
||||||
|
member_count: group.memberCount,
|
||||||
|
status: group.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGroupChatRows(db: DatabaseClient) {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT chat_id, name, owner_user_id, member_count, status
|
||||||
|
FROM wecom_group_chats
|
||||||
|
ORDER BY member_count DESC`,
|
||||||
|
)
|
||||||
|
.all<GroupChatRow>();
|
||||||
|
return result.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGroupPushes(
|
||||||
|
db: DatabaseClient,
|
||||||
|
taskId?: string,
|
||||||
|
): Promise<GroupPushRecord[]> {
|
||||||
|
const filter = taskId ? "WHERE task_id = ?" : "";
|
||||||
|
const statement = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, task_id, msgid, sender, chat_ids, fail_list, created_at
|
||||||
|
FROM wecom_group_pushes
|
||||||
|
${filter}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
)
|
||||||
|
.bind(...(taskId ? [taskId] : []));
|
||||||
|
const result = await statement.all<{
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
msgid: string;
|
||||||
|
sender: string;
|
||||||
|
chat_ids: string;
|
||||||
|
fail_list: string;
|
||||||
|
created_at: string;
|
||||||
|
}>();
|
||||||
|
return result.results.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
taskId: row.task_id,
|
||||||
|
msgid: row.msgid,
|
||||||
|
sender: row.sender,
|
||||||
|
chatIds: safeParseArray(row.chat_ids),
|
||||||
|
failList: safeParseArray(row.fail_list),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushTaskToGroupChats(
|
||||||
|
db: DatabaseClient,
|
||||||
|
params: { taskId: string; chatIds: unknown; text?: unknown },
|
||||||
|
config: WecomConfig,
|
||||||
|
fetchImpl: FetchLike = fetch,
|
||||||
|
): Promise<{ results: GroupPushResult[]; linkUrl: string }> {
|
||||||
|
const taskId = bindingValue(params.taskId);
|
||||||
|
if (!taskId) throw new WecomClientError("缺少 taskId", 400);
|
||||||
|
|
||||||
|
const requested = [
|
||||||
|
...new Set(
|
||||||
|
(Array.isArray(params.chatIds) ? params.chatIds : [])
|
||||||
|
.map((id) => bindingValue(id))
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (requested.length === 0) {
|
||||||
|
throw new WecomClientError("请选择要发送的客户群", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, name, brand, quantity, due_at, task_type, platform,
|
||||||
|
content_format, share_token
|
||||||
|
FROM tasks WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(taskId)
|
||||||
|
.first<TaskPushRow>();
|
||||||
|
if (!task) throw new WecomClientError("任务不存在", 404);
|
||||||
|
|
||||||
|
const chatRows: GroupChatRow[] = [];
|
||||||
|
for (let index = 0; index < requested.length; index += 100) {
|
||||||
|
const chunk = requested.slice(index, index + 100);
|
||||||
|
const placeholders = chunk.map(() => "?").join(", ");
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT chat_id, name, owner_user_id, member_count, status
|
||||||
|
FROM wecom_group_chats WHERE chat_id IN (${placeholders})`,
|
||||||
|
)
|
||||||
|
.bind(...chunk)
|
||||||
|
.all<GroupChatRow>();
|
||||||
|
chatRows.push(...result.results);
|
||||||
|
}
|
||||||
|
const byId = new Map(chatRows.map((row) => [row.chat_id, row]));
|
||||||
|
const missing = requested.filter((id) => !byId.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new WecomClientError(
|
||||||
|
`以下客户群未同步到平台,请先同步群列表:${missing.join("、")}`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const noOwner = requested
|
||||||
|
.map((id) => byId.get(id))
|
||||||
|
.filter((row) => row && !bindingValue(row.owner_user_id));
|
||||||
|
if (noOwner.length > 0) {
|
||||||
|
throw new WecomClientError(
|
||||||
|
`群「${noOwner.map((row) => row?.name || row?.chat_id).join("、")}」缺少群主信息,请重新同步群列表`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
|
||||||
|
const fallback = buildTaskGroupPushMessage(task, portalUrl);
|
||||||
|
const text = bindingValue(params.text) || fallback.text;
|
||||||
|
const link = fallback.link;
|
||||||
|
if (!text && !link) {
|
||||||
|
throw new WecomClientError("文本与图文附件不能同时为空", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: GroupPushResult[] = [];
|
||||||
|
const groups = requested.map((id) => byId.get(id)!);
|
||||||
|
for (const { ownerUserId, chatIds } of groupChatsByOwner(groups)) {
|
||||||
|
for (
|
||||||
|
let index = 0;
|
||||||
|
index < chatIds.length;
|
||||||
|
index += WECOM_GROUP_BATCH_LIMIT
|
||||||
|
) {
|
||||||
|
const batch = chatIds.slice(index, index + WECOM_GROUP_BATCH_LIMIT);
|
||||||
|
const sent = await createGroupMsgTemplate(
|
||||||
|
config,
|
||||||
|
{ sender: ownerUserId, chatIdList: batch, text, link: link ?? undefined },
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO wecom_group_pushes
|
||||||
|
(id, task_id, msgid, sender, chat_ids, text_content, link_url, fail_list)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
`wgp-${crypto.randomUUID().slice(0, 12)}`,
|
||||||
|
taskId,
|
||||||
|
sent.msgid,
|
||||||
|
ownerUserId,
|
||||||
|
JSON.stringify(batch),
|
||||||
|
text,
|
||||||
|
link?.url ?? "",
|
||||||
|
JSON.stringify(sent.failList),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
results.push({
|
||||||
|
sender: ownerUserId,
|
||||||
|
msgid: sent.msgid,
|
||||||
|
chatCount: batch.length,
|
||||||
|
failList: sent.failList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { results, linkUrl: link?.url ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseArray(value: string) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
171
lib/wecom-notifier-service.ts
Normal file
171
lib/wecom-notifier-service.ts
Normal 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);
|
||||||
|
}
|
||||||
2
mysql/0008_partner_wecom_external.sql
Normal file
2
mysql/0008_partner_wecom_external.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE partners
|
||||||
|
ADD COLUMN wecom_external_user_id VARCHAR(128) AFTER completed_total;
|
||||||
23
mysql/0009_wecom_group_push.sql
Normal file
23
mysql/0009_wecom_group_push.sql
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ test("issues external task links and supports one-to-one note submissions", asyn
|
|||||||
assert.match(imageRoute, /creator-center\//);
|
assert.match(imageRoute, /creator-center\//);
|
||||||
assert.match(imageRoute, /imageKind === "publish"/);
|
assert.match(imageRoute, /imageKind === "publish"/);
|
||||||
assert.match(imageRoute, /imageKind === "creator"/);
|
assert.match(imageRoute, /imageKind === "creator"/);
|
||||||
|
assert.match(imageRoute, /isMutableEvidence/);
|
||||||
|
assert.match(imageRoute, /"private, no-store"/);
|
||||||
assert.match(imageRoute, /Content-Type", "video\/mp4"/);
|
assert.match(imageRoute, /Content-Type", "video\/mp4"/);
|
||||||
assert.match(imageRoute, /video-\$\{imageIndex\}\.mp4/);
|
assert.match(imageRoute, /video-\$\{imageIndex\}\.mp4/);
|
||||||
assert.match(imageRoute, /downloadRequested \? "attachment" : "inline"/);
|
assert.match(imageRoute, /downloadRequested \? "attachment" : "inline"/);
|
||||||
@@ -508,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"),
|
||||||
@@ -525,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(
|
||||||
@@ -540,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
607
tests/wecom-client.test.mjs
Normal 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),
|
||||||
|
/客户群列表为空/,
|
||||||
|
);
|
||||||
|
});
|
||||||
262
tests/wecom-group-push.test.mjs
Normal file
262
tests/wecom-group-push.test.mjs
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
15
tests/wecom-notifier.test.mjs
Normal file
15
tests/wecom-notifier.test.mjs
Normal 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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user