25 Commits

Author SHA1 Message Date
ABAPPLO
5cb056507d fix: 催办按任务类型分流 + 领取事务加锁防超发
- 临期/超期/自动释放查询按 task_type 区分完成口径:内容任务看 publish_url,截图任务看 result_submitted_at,截图任务不再被永久误判为未发布
- portal 领取改为事务内 SELECT ... FOR UPDATE 锁定可用内容,消除并发领取超发窗口
2026-09-02 13:56:07 +08:00
ABAPPLO
e1005081fe refactor: 任务中心合并任务分发(导航 9→8,任务卡进入分发明细) 2026-09-02 11:25:16 +08:00
ABAPPLO
fb1551a3a4 docs: 企微化流程环境变量清单与部署指南(回调/回流/催办/复盘) 2026-09-01 17:47:51 +08:00
ABAPPLO
4c1ff2d5b8 feat: 复盘报表视图与 CSV 导出(manager 专属) 2026-09-01 17:46:11 +08:00
ABAPPLO
3e4269729c feat: 催办分层(超期升级+交互卡片)与超期自动释放开关 2026-09-01 17:37:35 +08:00
ABAPPLO
bca524417e feat: 数据回流通知(提交/采集结果推运营群,默认关闭) 2026-09-01 17:34:14 +08:00
ABAPPLO
d01281fba9 feat: 任务一键推送企微(总群+KOC个人直达+群发,含推送记录) 2026-09-01 17:30:47 +08:00
ABAPPLO
b243562e60 feat: 企微回调服务基建(加解密+事件分发,默认关闭) 2026-09-01 15:08:00 +08:00
ABAPPLO
ca3810a3d0 feat: 门户接入企微网页授权——企微内静默免输身份领取,浏览器外保留手动输入 2026-08-31 17:17:00 +08:00
ABAPPLO
6e590e3b08 feat: 任务领取/找回支持企微身份票据,领取自动绑定催办通道 2026-08-31 17:13:03 +08:00
ABAPPLO
b83a6a53c9 feat: 新增企微 OAuth 换码路由与 HMAC 身份票据服务 2026-08-31 17:11:20 +08:00
ABAPPLO
7f6c0e0983 feat: wecom-client 新增网页授权能力(授权 URL / code 换身份 / 外部联系人显示名) 2026-08-31 17:10:03 +08:00
ABAPPLO
a6f5a5fd4a feat: 新增项目中心——按项目组织任务/任务群/总群并一键推送
- 新表 projects / project_group_chats,partners 加 partner_type/wecom_userid,tasks 加 project_id(迁移 0010)
- 项目 CRUD、总群成员配置、任务群人工映射(群主→合作方)、任务挂项目
- 推送:总群成员逐人应用消息 + 任务群按群主企业群发,结果分项回显
2026-08-31 16:35:17 +08:00
ABAPPLO
ed13262166 feat: 批量更新账号资料支持勾选字段(含AI打标)
- 更新全部账号ID 升级为 批量更新账号资料:弹窗勾选 账号ID/昵称/粉丝数/IP属地/性别/简介/标签
- 标签勾选时调用 LLM 重新打标(覆盖已有标签),AI_TAGGING_TOOL 未配置自动跳过
- 只写入选中字段,爬取为空的字段跳过不清空;演示账号(xhs- 前缀)不参与
- .env.app.example 为 SEED_DEMO_DATA 补充注释
2026-08-28 14:00:07 +08:00
ABAPPLO
8e4e3de974 feat: KOC资源列表分页展示(每页15个) 2026-08-27 19:17:53 +08:00
ABAPPLO
dfc7dc217d feat: KOC资源页新增一键爬取更新全部账号ID 2026-08-27 18:54:55 +08:00
ABAPPLO
5614a13ebb feat: KOC资源卡片支持手动修改账号ID 2026-08-27 17:50:44 +08:00
ABAPPLO
9c37141399 merge: 合入 main(PR #4 搜索 API + MCP 截图识别),解决 .gitignore 冲突 2026-08-27 16:32:03 +08:00
ABAPPLO
b79117df9a fix: 移除 0001 初始化脚本中与 0006 迁移重复的列定义
gender/bio/tags 由 0006 迁移 ALTER TABLE 添加,0001 基础建表若同时包含
会导致全新安装执行迁移时报重复列错误。
2026-08-27 16:21:15 +08:00
ABAPPLO
db5c7522f7 refactor: 环境变量统一为 .env.app 单文件 + 清理死配置
- 新增 .env.app.example 统一模板(两套 compose 共用,数据库按模式分节注释),删除 .env.self-hosted.example / .env.production.example
- 部署指南与 README 的 --env-file 引用统一为 .env.app;.gitignore / .dockerignore 白名单同步调整
- runtime-env.ts 移除零消费的 MCP_API_KEY / COLLECTION_MCP_URL / COLLECTION_MCP_KEY 遗留声明
- .dockerignore 补充 .dev.vars 排除;mcp-task-server 测试改读新模板
2026-08-27 11:42:02 +08:00
ABAPPLO
cb010b6c5b chore: 根目录图片加入 .gitignore 并移除已入库的调试截图
- .gitignore 新增根目录 png/jpg/jpeg/webp 忽略规则(正式资源在 public/ 与 koc-portal/public/,不受影响)
- git rm --cached 移除 15 张 design-qa 调试截图(本地文件保留)
2026-08-26 17:40:10 +08:00
ABAPPLO
9b9f6a3520 feat: 新增生产环境 compose 编排(直连公共 MySQL)
- 新增 docker-compose.production.yml:仅 app + nginx,数据库连公司公共 MySQL,MYSQL_HOST/MYSQL_PASSWORD 必填校验
- 新增 .env.production.example 环境模板,.gitignore 白名单放行
- 修复 docker-compose.self-hosted.yml 漏转发 AI_TAGGING_TOOL 与 WECOM_* 共 7 个环境变量
- 部署指南补充两套编排文件选用说明与生产启动命令;忽略真实账号数据 CSV 防误提交
2026-08-26 17:28:25 +08:00
ABAPPLO
4a64feee02 feat: KOC 资源导入后自动爬取主页资料并调用 LLM 打标签
- 新增 structured_text_process 打标客户端(文本构造、标签解析清洗、MCP 调用)
- 写库后后台打标(tagUntaggedAccounts),单条新增 / 批量导入 / 每日补全三个入口统一
- 标签只补空,UPDATE 带 tags 为空守卫,不覆盖已有标签
- 新增 AI_TAGGING_TOOL 配置项(留空关闭),更新配置示例与文档
- 新增 16 个打标测试用例,全量 131 个测试通过
2026-08-26 15:21:28 +08:00
7bb4e0c493 Merge pull request 'feat: 新增搜素 API' (#4) from feature/search-api into main
Reviewed-on: #4
2026-08-20 10:44:25 +00:00
ABAPPLO
9697b5890d feat: 任务发布到企微客户群(企业群发)与资源库单条新增
- 任务中心新增「发布到企微群」:同步客户群清单(wecom_group_chats)、
  按群主分组创建企业群发任务(add_msg_template)、发送记录落库
  wecom_group_pushes,迁移 mysql/0009;lib/wecom-client.ts 补
  listCustomerGroupChats/createGroupMsgTemplate
- KOC 资源库支持单条新增:app/api/resources-insert + lib/resource-write,
  拆出资源写入公共逻辑供导入复用;0008 补合作方外部联系人字段
- 环境变量示例补企微凭证与 SEED_DEMO_DATA;next.config 增加
  allowedDevOrigins;CLAUDE.md 补充项目说明
- .gitignore 排除 .codegraph/ 与 .ipynb_checkpoints/
2026-08-20 15:16:18 +08:00
83 changed files with 12272 additions and 1167 deletions

View File

@@ -8,6 +8,20 @@ KOC_MCP_API_KEY=replace-with-a-separate-long-random-secret
AI_TOOL_CENTER_MCP_URL=https://middle-aitool.gbotai.cn/mcp
AI_TOOL_CENTER_MCP_KEY=replace-with-mcp-key
# 账号 LLM 自动打标(可选):工具中心 structured_text_process 工具,留空 = 关闭自动打标
AI_TAGGING_TOOL=
# Feishu custom app credentials. Keep the secret out of source control.
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxxx
FEISHU_APP_SECRET=replace-with-feishu-app-secret
# 企业微信 · 全部选填;不填则临期催办与汇总都跳过,不影响其他功能
WECOM_CORP_ID=
WECOM_AGENT_ID=
WECOM_SECRET=
WECOM_ROBOT_WEBHOOK=
WECOM_NOTIFY_DUE_DAYS=3
WECOM_NOTIFY_ENABLED=true
# 本地测试用:让 /api/bootstrap 自动 seed 测试 partner + distribution;上线设为 false
SEED_DEMO_DATA=false

View File

@@ -9,4 +9,5 @@ koc-portal/node_modules
.data
.env*
!.env.self-hosted.example
.dev.vars
npm-debug.log*

70
.env.app.example Normal file
View File

@@ -0,0 +1,70 @@
# 复制为 .env.app 后填写,两套 compose 编排共用这一份文件(--env-file .env.app)。
# 不要把真实密钥提交到 Git。
# ── 数据库(按部署模式二选一)──────────────────────────────
# 模式 A:公共 MySQL(docker-compose.production.yml,仅 app + nginx)
# 要求 MySQL 8 + utf8mb4,账号需对目标库有建表与读写权限。
MYSQL_HOST=replace-with-shared-mysql-host
MYSQL_PORT=3306
# 模式 B:自带 MySQL 容器(docker-compose.self-hosted.yml,全套)
# 只需填 root 密码;MYSQL_HOST 由 compose 固定为容器内的 mysql,这里无需填写。
MYSQL_ROOT_PASSWORD=replace-with-strong-root-password
# 两种模式共用的库与账号。
MYSQL_DATABASE=koc_loop
MYSQL_USER=koc
MYSQL_PASSWORD=replace-with-strong-app-password
# ── 对外地址 ──────────────────────────────────────────────
APP_ORIGIN=https://koc.example.com
KOC_PORTAL_URL=https://koc.example.com/koc/
# ── 后台账号(仅首次启动创建超管时使用)───────────────────
# 创建成功后建议移除明文密码并重启;后续账号在「用户管理」维护。
SUPER_ADMIN_USERNAME=admin
SUPER_ADMIN_PASSWORD=replace-with-strong-admin-password
ADMIN_INTERNAL_TOKEN=replace-with-random-internal-token
# KOC LOOP 自身 MCP 的访问密钥。
KOC_MCP_API_KEY=replace-with-random-mcp-key
# ── 飞书内容表读取 ────────────────────────────────────────
FEISHU_APP_ID=
FEISHU_APP_SECRET=
# ── 小红书公开数据采集 MCP ────────────────────────────────
AI_TOOL_CENTER_MCP_URL=
AI_TOOL_CENTER_MCP_KEY=
# 账号 LLM 自动打标(可选):AI_TOOL_CENTER 的 structured_text_process 工具,留空 = 关闭自动打标。
AI_TAGGING_TOOL=
# ── 企业微信通知(临期催办 + 管理员汇总,全部选填)─────────
# 只收群汇总:仅填 WECOM_ROBOT_WEBHOOK。
# KOC 侧催办/客户群群发:CORP_ID + AGENT_ID + 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
# 数据回流通知(可选):KOC 提交链接/截图、每日采集摘要推运营群机器人,默认关闭。
WECOM_FEEDBACK_ENABLED=false
# 超期升级催办(可选):超期任务升级运营群机器人 + 项目总群交互卡片,默认关闭。
WECOM_ESCALATION_ENABLED=false
# 超期 N 天自动释放(可选):从 due_at 起算,超期内容自动回到可领取池,0 = 关闭(默认),最大 90。
WECOM_AUTO_RELEASE_DAYS=0
# 企微回调(可选):交互卡片「已处理」回执落库。需在企微后台「接收消息」配置回调 URL
# https://<APP_ORIGIN>/api/wecom-callback + 下方 Token / EncodingAESKey,默认关闭。
WECOM_CALLBACK_ENABLED=false
WECOM_CALLBACK_TOKEN=
WECOM_CALLBACK_ENCODING_AES_KEY=
# ── 调度与入口 ────────────────────────────────────────────
# 每天北京时间 09:00 自动执行采集计划,生产保持 true。
ENABLE_SCHEDULER=true
# 是否在空库时写入演示数据,生产保持 false。
SEED_DEMO_DATA=false
HTTP_PORT=80

View File

@@ -1,39 +0,0 @@
# 复制为 .env.self-hosted 后填写。不要把真实密钥提交到 Git。
MYSQL_ROOT_PASSWORD=replace-with-strong-root-password
MYSQL_DATABASE=koc_loop
MYSQL_USER=koc
MYSQL_PASSWORD=replace-with-strong-app-password
# 对外地址。部署域名确定后替换。
APP_ORIGIN=https://koc.example.com
KOC_PORTAL_URL=https://koc.example.com/koc/
# 后台首次启动会创建唯一超级管理员;账号创建后建议移除明文密码并重启。
SUPER_ADMIN_USERNAME=admin
SUPER_ADMIN_PASSWORD=replace-with-strong-admin-password
ADMIN_INTERNAL_TOKEN=replace-with-random-internal-token
# KOC LOOP 自身 MCP 的访问密钥。
KOC_MCP_API_KEY=replace-with-random-mcp-key
# 飞书内容表读取。
FEISHU_APP_ID=
FEISHU_APP_SECRET=
# 小红书公开数据采集 MCP。
AI_TOOL_CENTER_MCP_URL=
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 自动执行采集计划。
ENABLE_SCHEDULER=true
SEED_DEMO_DATA=false
HTTP_PORT=80

13
.gitignore vendored
View File

@@ -22,6 +22,8 @@
# misc
.DS_Store
*.pem
.codegraph/
.ipynb_checkpoints/
/.idea/
AGENTS.md
CLAUDE.md
@@ -34,9 +36,18 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.self-hosted.example
!.env.app.example
.dev.vars
# 真实 KOC 账号资源数据(含真实主页链接与昵称),仅本地使用,禁止入库
examples/KOC资源汇总*.csv
# 根目录临时图片(UI 调试截图等);正式资源放 public/ 与 koc-portal/public/
/*.png
/*.jpg
/*.jpeg
/*.webp
# vercel
.vercel

138
CLAUDE.md Normal file
View File

@@ -0,0 +1,138 @@
# 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
AI_TAGGING_TOOL — 账号 LLM 自动打标:工具中心 structured_text_process,留空关闭(可选)
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(可选)
WECOM_FEEDBACK_ENABLED — 数据回流通知(提交/采集摘要推运营群),默认 false(可选)
WECOM_ESCALATION_ENABLED — 超期升级催办(运营群+交互卡片),默认 false(可选)
WECOM_AUTO_RELEASE_DAYS — 超期 N 天自动释放,从 due_at 起算,0=关闭(可选)
WECOM_CALLBACK_ENABLED — 企微回调开关(交互卡片回执),默认 false(可选)
WECOM_CALLBACK_TOKEN / WECOM_CALLBACK_ENCODING_AES_KEY — 企微回调密钥(/api/wecom-callback)
```
## 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 -->

View File

@@ -16,7 +16,7 @@ npm run dev
npm run build
```
复制 `.env.self-hosted.example` 为 `.env.self-hosted`,设置 `DATABASE_URL` 或 `MYSQL_*` 连接信息。飞书动态导入需要:
复制 `.env.app.example` 为 `.env.app`,设置 `DATABASE_URL` 或 `MYSQL_*` 连接信息。飞书动态导入需要:
- `FEISHU_APP_ID`
- `FEISHU_APP_SECRET`

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import {
ensureSchema,
getDashboardData,
getRawDb,
getTaskReportData,
uid,
} from "../../../lib/mvp-db";
import {
@@ -41,11 +42,45 @@ import {
resolveWecomConfig,
sendWecomAppMessage,
sendWecomRobotMessage,
listExternalContacts,
WecomClientError,
type WecomBindings,
} from "../../../lib/wecom-client";
import {
runDueSoonWecomNotifications,
runOverdueAutoRelease,
runOverdueEscalation,
} from "../../../lib/wecom-notifier-service";
import {
listGroupChatRows,
listGroupPushes,
pushTaskToGroupChats,
syncGroupChats,
} from "../../../lib/wecom-group-push-service";
import { isManagerRequest } from "../../../lib/user-auth";
import { extractPublishUrl } from "../../../lib/publish-url";
import {
getAccountFieldRefreshStatus,
startAccountFieldRefresh,
} from "../../../lib/account-field-refresh-service";
import {
deleteProject,
getProjectDetail,
listGroupOwnerPartners,
listProjects,
ProjectError,
saveGroupOwnerPartner,
saveProject,
setProjectGroupChats,
} from "../../../lib/project-service";
import { pushProjectTask } from "../../../lib/project-push-service";
import {
findTodayTaskPushLog,
listBoundWecomPartners,
listTaskPushLogs,
OneClickPushError,
oneClickPushTask,
} from "../../../lib/task-one-click-push-service";
type ActionBody = {
action?: string;
@@ -107,6 +142,7 @@ export async function POST(request: Request) {
dueAt,
platform,
contentFormat,
projectId: String(body.projectId ?? "").trim(),
},
env as unknown as FeishuBindings,
);
@@ -119,6 +155,7 @@ export async function POST(request: Request) {
instructions: String(body.instructions ?? "").trim(),
quantity: numberValue(body.quantity),
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
projectId: String(body.projectId ?? "").trim(),
});
} else if (body.action === "claim") {
const partnerId = String(body.partnerId ?? "");
@@ -546,21 +583,32 @@ export async function POST(request: Request) {
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 },
);
}
await db
.prepare(
"UPDATE partners SET wecom_external_user_id = ? WHERE id = ?",
)
.bind(externalId || null, partnerId)
.run();
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(
@@ -601,6 +649,174 @@ export async function POST(request: Request) {
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 === "trigger_wecom_overdue_escalation") {
if (!isManagerRequest(request)) return adminForbidden();
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const escalation = await runOverdueEscalation(db, wecomConfig);
const autoRelease = await runOverdueAutoRelease(db, wecomConfig);
return Response.json({ escalation, autoRelease });
} 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 === "project_list") {
return Response.json({ projects: await listProjects(db) });
} else if (body.action === "project_detail") {
return Response.json(await getProjectDetail(db, String(body.projectId ?? "")));
} else if (body.action === "project_save") {
return Response.json({
project: await saveProject(db, {
id: body.id,
name: body.name,
brand: body.brand,
mainGroupMemberIds: body.mainGroupMemberIds,
note: body.note,
}),
});
} else if (body.action === "project_delete") {
await deleteProject(db, String(body.projectId ?? ""));
return Response.json({ ok: true });
} else if (body.action === "project_set_group_chats") {
const detail = await setProjectGroupChats(
db,
String(body.projectId ?? ""),
Array.isArray(body.items) ? body.items : [],
);
return Response.json(detail);
} else if (body.action === "partner_save") {
return Response.json({
partner: await saveGroupOwnerPartner(db, {
id: body.id,
name: body.name,
wecomUserid: body.wecomUserid,
}),
});
} else if (body.action === "partner_group_owners") {
return Response.json({ partners: await listGroupOwnerPartners(db) });
} else if (body.action === "project_push_task") {
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
const outcome = await pushProjectTask(
db,
{
taskId: body.taskId,
sendMainGroup: body.sendMainGroup,
sendGroupChats: body.sendGroupChats,
text: body.text,
},
wecomConfig,
);
const appFailed = outcome.appResults.filter((item) => !item.ok);
return Response.json({
...outcome,
appSent: outcome.appResults.length - appFailed.length,
appFailedCount: appFailed.length,
hint: "任务群走企业群发,群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群",
});
} else if (body.action === "wecom_bound_partners") {
const partners = await listBoundWecomPartners(db);
const taskId = String(body.taskId ?? "").trim();
const pushedToday = taskId
? await findTodayTaskPushLog(db, taskId, "task_open")
: null;
return Response.json({ partners, pushedToday });
} else if (body.action === "task_one_click_push") {
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
const outcome = await oneClickPushTask(
db,
{
taskId: body.taskId,
sendMainGroup: body.sendMainGroup,
sendKoc: body.sendKoc,
createGroupTemplate: body.createGroupTemplate,
selectedKocPartnerIds: body.selectedKocPartnerIds,
force: body.force,
text: body.text,
},
wecomConfig,
);
return Response.json({
...outcome,
hint: "KOC 直达消息已即时送达;任务群走企业群发,群主需在企微客户端「群发助手」点击发送后才会送达客户群",
});
} else if (body.action === "task_push_logs") {
const logs = await listTaskPushLogs(db, String(body.taskId ?? "").trim());
return Response.json({ logs });
} else if (body.action === "task_report_list") {
if (!isManagerRequest(request)) return adminForbidden();
return Response.json({ reports: await getTaskReportData(db) });
} else if (body.action === "refresh_account_fields") {
const status = startAccountFieldRefresh(
db,
resolveCollectionMcpConfig(env as unknown as CollectionMcpBindings),
body.fields,
);
return Response.json({ refresh: status });
} else if (body.action === "account_field_refresh_status") {
return Response.json({ refresh: getAccountFieldRefreshStatus() });
} else if (body.action === "wecom_group_chats") {
const groups = await listGroupChatRows(db);
return Response.json({ groups });
} else {
return Response.json({ error: "不支持的操作" }, { status: 400 });
}
@@ -613,7 +829,9 @@ export async function POST(request: Request) {
status:
error instanceof FeishuSourceError ||
error instanceof DistributionReleaseError ||
error instanceof WecomClientError
error instanceof WecomClientError ||
error instanceof ProjectError ||
error instanceof OneClickPushError
? error.status
: 500,
},

View File

@@ -23,6 +23,19 @@ import {
} from "../../../lib/partner-utils";
import { extractPublishUrl } from "../../../lib/publish-url";
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
import {
resolveWecomClaimant,
verifyWecomIdentityTicket,
type WecomIdentity,
} from "../../../lib/wecom-oauth-service";
import {
isWecomFeedbackEnabled,
notifyPartnerSubmitted,
} from "../../../lib/wecom-feedback-service";
import {
resolveWecomConfig,
type WecomBindings,
} from "../../../lib/wecom-client";
import {
partnerOptions,
withPartnerCors,
@@ -39,6 +52,7 @@ type PartnerBody = {
delegationBundleId?: string;
claimantIdentifier?: string;
claimantName?: string;
identityTicket?: string;
quantity?: number;
publishUrl?: string;
exposure?: number | string;
@@ -446,13 +460,27 @@ async function handlePost(request: Request) {
}
const db = getRawDb();
let wecomIdentity: WecomIdentity | null = null;
if (body.identityTicket) {
try {
wecomIdentity = await verifyWecomIdentityTicket(
textValue(body.identityTicket, 512),
);
} catch {
wecomIdentity = null;
}
}
if (body.action === "recover") {
const identifierInput = textValue(
body.claimantIdentifier ?? body.claimantName,
64,
);
const claimantIdentifier = parseClaimantIdentifier(identifierInput);
if (!claimantIdentifier && identifierInput.length < 2) {
const wecomPartnerId = wecomIdentity
? (await resolveWecomClaimant(db, wecomIdentity.externalUserId)).partnerId
: null;
if (!claimantIdentifier && identifierInput.length < 2 && !wecomPartnerId) {
return Response.json(
{ error: "请输入领取时使用的正确微信号或手机号" },
{ status: 400 },
@@ -466,6 +494,11 @@ async function handlePost(request: Request) {
`claimant:${claimantIdentifier.canonical}`,
)}`
: legacyPartnerId;
const partnerCandidates = [
...new Set(
wecomPartnerId ? [wecomPartnerId] : [partnerId, legacyPartnerId],
),
];
const recovered = await db
.prepare(
`SELECT
@@ -481,12 +514,14 @@ async function handlePost(request: Request) {
FROM claims c
LEFT JOIN distributions d ON d.claim_id = c.id
LEFT JOIN contents co ON co.id = d.content_id
WHERE c.task_id = ? AND c.partner_id IN (?, ?)
WHERE c.task_id = ? AND c.partner_id IN (${partnerCandidates
.map(() => "?")
.join(", ")})
GROUP BY c.id, c.claim_token, c.quantity, c.created_at
ORDER BY c.created_at DESC, c.id DESC
LIMIT 20`,
)
.bind(task.task_type, task.task_type, task.id, partnerId, legacyPartnerId)
.bind(task.task_type, task.task_type, task.id, ...partnerCandidates)
.all<{
claim_token: string;
quantity: number;
@@ -522,92 +557,118 @@ async function handlePost(request: Request) {
);
const claimantIdentifier = parseClaimantIdentifier(identifierInput);
const quantity = quantityValue(body.quantity);
if (!claimantIdentifier) {
if (!claimantIdentifier && !wecomIdentity) {
return Response.json(
{ error: "请输入正确的微信号或手机号" },
{ status: 400 },
);
}
const available = await db
.prepare(
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY COALESCE(source_row, 999999), created_at, id
LIMIT ${quantity}`,
)
.bind(task.id)
.all<{ id: string }>();
if (available.results.length === 0) {
return Response.json({ error: "当前任务已领完" }, { status: 409 });
}
const partnerId = `partner-ext-${hashText(
`claimant:${claimantIdentifier.canonical}`,
)}`;
const claimantName = claimantIdentifier.display;
const partnerId = wecomIdentity
? (await resolveWecomClaimant(db, wecomIdentity.externalUserId)).partnerId
: `partner-ext-${hashText(
`claimant:${claimantIdentifier?.canonical}`,
)}`;
const claimantName = wecomIdentity
? wecomIdentity.displayName
: (claimantIdentifier?.display ?? "");
const claimId = uid("claim");
const claimToken = crypto.randomUUID().replaceAll("-", "");
const statements: DatabaseStatement[] = [
db
const claimResult = await db.transaction(async (tx) => {
const available = await tx
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total)
VALUES (?, ?, ?, '外部KOC', 0, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wecom_name = excluded.wecom_name`,
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY COALESCE(source_row, 999999), created_at, id
LIMIT ${quantity}
FOR UPDATE`,
)
.bind(partnerId, claimantName, claimantName),
db
.prepare(
`INSERT INTO claims
(id, task_id, partner_id, claimant_name, claim_token, quantity)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
claimId,
task.id,
partnerId,
claimantName,
claimToken,
available.results.length,
),
];
for (const content of available.results) {
.bind(task.id)
.all<{ id: string }>();
if (available.results.length === 0) return null;
const statements: DatabaseStatement[] = [
wecomIdentity
? db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total,
wecom_external_user_id)
VALUES (?, ?, ?, '外部KOC', 0, 0, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wecom_name = excluded.wecom_name,
wecom_external_user_id = excluded.wecom_external_user_id`,
)
.bind(
partnerId,
claimantName,
claimantName,
wecomIdentity.externalUserId,
)
: db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total)
VALUES (?, ?, ?, '外部KOC', 0, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wecom_name = excluded.wecom_name`,
)
.bind(partnerId, claimantName, claimantName),
db
.prepare(
`INSERT INTO claims
(id, task_id, partner_id, claimant_name, claim_token, quantity)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
claimId,
task.id,
partnerId,
claimantName,
claimToken,
available.results.length,
),
];
for (const content of available.results) {
statements.push(
db
.prepare(
`INSERT INTO distributions
(id, task_id, content_id, partner_id, claim_id, status)
VALUES (?, ?, ?, ?, ?, 'claimed')`,
)
.bind(uid("dist"), task.id, content.id, partnerId, claimId),
db
.prepare(
`UPDATE contents SET status = 'allocated'
WHERE id = ? AND status = 'available'`,
)
.bind(content.id),
);
}
statements.push(
db
.prepare(
`INSERT INTO distributions
(id, task_id, content_id, partner_id, claim_id, status)
VALUES (?, ?, ?, ?, ?, 'claimed')`,
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
WHERE id = ?`,
)
.bind(uid("dist"), task.id, content.id, partnerId, claimId),
.bind(available.results.length, task.id),
db
.prepare(
`UPDATE contents SET status = 'allocated'
WHERE id = ? AND status = 'available'`,
`UPDATE partners SET claimed_total = claimed_total + ?
WHERE id = ?`,
)
.bind(content.id),
.bind(available.results.length, partnerId),
);
}
statements.push(
db
.prepare(
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
WHERE id = ?`,
)
.bind(available.results.length, task.id),
db
.prepare(
`UPDATE partners SET claimed_total = claimed_total + ?
WHERE id = ?`,
)
.bind(available.results.length, partnerId),
);
await db.batch(statements);
return Response.json({
claimToken,
claimedCount: available.results.length,
for (const statement of statements) {
await tx.execute(statement.sql, statement.values());
}
return { claimToken, claimedCount: available.results.length };
});
if (!claimResult) {
return Response.json({ error: "当前任务已领完" }, { status: 409 });
}
return Response.json(claimResult);
}
if (body.action === "create_delegation") {
@@ -1014,6 +1075,14 @@ async function handlePost(request: Request) {
),
).catch(() => undefined);
runInBackground(enrichment, "distribution account enrichment");
if (isWecomFeedbackEnabled()) {
const feedback = notifyPartnerSubmitted(
db,
{ distributionId: assignment.id, kind: "publish" },
resolveWecomConfig(env as unknown as WecomBindings),
).catch(() => undefined);
runInBackground(feedback, "wecom publish feedback");
}
return Response.json({ ok: true });
}
@@ -1107,6 +1176,14 @@ async function handlePost(request: Request) {
);
}
await db.batch(statements);
if (isWecomFeedbackEnabled()) {
const feedback = notifyPartnerSubmitted(
db,
{ distributionId: assignment.id, kind: "screenshot" },
resolveWecomConfig(env as unknown as WecomBindings),
).catch(() => undefined);
runInBackground(feedback, "wecom screenshot feedback");
}
return Response.json({ submitted: true });
}

View File

@@ -1,290 +1,25 @@
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { runInBackground } from "../../../lib/background";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import {
resolveCollectionMcpConfig,
resolveProfileDetailsFromMcp,
resolveXhsPublicAccountDetails,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import {
mergeCooperationSources,
normalizeProfileUrl,
parseResourceImportFile,
RESOURCE_IMPORT_MAX_BYTES,
RESOURCE_IMPORT_MAX_ROWS,
resourcePlatformUid,
resourceImportMissingFields,
needsResourceEnrichment,
type ResourceImportRow,
} from "../../../lib/resource-import";
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;
};
type AnalyzedRow = ResourceImportRow & {
action: "create" | "update" | "error";
accountId: string;
platformUid: string;
cooperationSource: string;
};
import {
analyzeRows,
enrichRows,
loadAccounts,
mergeExistingFields,
tagUntaggedAccounts,
writeAnalyzedRows,
RESOURCE_IMPORT_SYNC_ENRICH_ROWS,
type AnalyzedRow,
type AccountRow,
} from "../../../lib/resource-write";
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[]) {
return {
@@ -310,87 +45,12 @@ 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[]) {
return rows.filter(
(row) =>
row.errors.length === 0 &&
row.profileUrl &&
resourceImportMissingFields(row).length > 0,
needsResourceEnrichment(row),
).length;
}
@@ -401,7 +61,7 @@ async function enrichImportedRowsInBackground(rows: ResourceImportRow[]) {
(row) =>
row.errors.length === 0 &&
row.profileUrl &&
resourceImportMissingFields(row).length > 0,
needsResourceEnrichment(row),
);
if (missingRows.length === 0) return;
const enriched = await enrichRows(missingRows, accounts.results);
@@ -415,7 +75,6 @@ async function enrichImportedRowsInBackground(rows: ResourceImportRow[]) {
export async function POST(request: Request) {
if (!(await isManagerRequest(request))) return managerForbidden();
try {
await ensureSchema();
const form = await request.formData();
const file = form.get("file");
const mode = String(form.get("mode") ?? "preview");
@@ -427,11 +86,12 @@ export async function POST(request: Request) {
}
const rows = parseResourceImportFile(file.name, new Uint8Array(await file.arrayBuffer()));
const accounts = await loadAccounts();
const accountRows: AccountRow[] = accounts.results;
const shouldEnrichSynchronously = rows.length <= RESOURCE_IMPORT_SYNC_ENRICH_ROWS;
const preparedRows = shouldEnrichSynchronously
? await enrichRows(rows, accounts.results)
: mergeExistingFields(rows, accounts.results);
const analyzed = analyzeRows(preparedRows, accounts.results);
? await enrichRows(rows, accountRows)
: mergeExistingFields(rows, accountRows);
const analyzed = analyzeRows(preparedRows, accountRows);
const summary = summarize(analyzed);
const importableRows = analyzed.filter((row) => row.action !== "error");
const importableRowNumbers = new Set(
@@ -473,6 +133,10 @@ export async function POST(request: Request) {
);
}
await writeAnalyzedRows(importableRows);
runInBackground(
tagUntaggedAccounts(importableRows),
"resource account tagging",
);
if (deferredEnrichment > 0) {
const importableSourceRows = rows.filter((row) =>
importableRowNumbers.has(row.rowNumber),

View File

@@ -0,0 +1,70 @@
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { runInBackground } from "../../../lib/background";
import { buildManualRow, type ManualResourceInput } from "../../../lib/resource-import";
import {
analyzeRows,
enrichRows,
loadAccounts,
tagUntaggedAccounts,
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);
runInBackground(
tagUntaggedAccounts(analyzed),
"resource account tagging",
);
const result = analyzed[0];
const outcome: InsertResult = {
action: result.action === "update" ? "update" : "create",
message:
result.action === "update"
? `已更新账号 ${row.nickname || row.publicAccountId || "资料"}`
: `已新增账号 ${row.nickname || row.publicAccountId || "资料"}`,
};
return Response.json(outcome);
} catch (error) {
const message = error instanceof Error ? error.message : "保存失败";
return Response.json({ error: message }, { status: 400 });
}
}

View File

@@ -0,0 +1,29 @@
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { buildTaskReportCsv } from "../../../lib/task-report-csv";
import {
ensureSchema,
getRawDb,
getTaskReportData,
} from "../../../lib/mvp-db";
export async function GET(request: Request) {
try {
if (!(await isAdminRequest(request))) return adminForbidden();
await ensureSchema();
const rows = await getTaskReportData(getRawDb());
const fileName = `任务复盘报表.csv`;
return new Response(buildTaskReportCsv(rows), {
headers: {
"Cache-Control": "private, no-store",
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="koc-task-report.csv"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "导出失败" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,131 @@
import {
decryptWecomCallback,
verifyWecomCallbackSignature,
wecomSignaturesMatch,
WecomCallbackCryptoError,
} from "../../../lib/wecom-callback-crypto";
import {
extractWecomEncryptField,
handleWecomCallbackEvent,
parseWecomCallbackXml,
} from "../../../lib/wecom-callback-events";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import { isEnabled, getRuntimeEnv } from "../../../lib/runtime-env";
type CallbackConfig = {
token: string;
encodingAesKey: string;
corpId: string;
};
function resolveCallbackConfig(): CallbackConfig | null {
const env = getRuntimeEnv();
if (!isEnabled(env.WECOM_CALLBACK_ENABLED, false)) return null;
const token = String(env.WECOM_CALLBACK_TOKEN ?? "").trim();
const encodingAesKey = String(
env.WECOM_CALLBACK_ENCODING_AES_KEY ?? "",
).trim();
if (!token || !encodingAesKey) return null;
return {
token,
encodingAesKey,
corpId: String(env.WECOM_CORP_ID ?? "").trim(),
};
}
function readCallbackQuery(request: Request) {
const url = new URL(request.url);
return {
msgSignature: url.searchParams.get("msg_signature") ?? "",
timestamp: url.searchParams.get("timestamp") ?? "",
nonce: url.searchParams.get("nonce") ?? "",
echostr: url.searchParams.get("echostr") ?? "",
};
}
export async function GET(request: Request) {
const config = resolveCallbackConfig();
if (!config) {
return new Response("企业微信回调未启用", { status: 403 });
}
const { msgSignature, timestamp, nonce, echostr } =
readCallbackQuery(request);
if (!msgSignature || !timestamp || !nonce || !echostr) {
return new Response("缺少回调校验参数", { status: 400 });
}
const expected = verifyWecomCallbackSignature(
config.token,
timestamp,
nonce,
echostr,
);
if (!wecomSignaturesMatch(expected, msgSignature)) {
return new Response("签名校验失败", { status: 403 });
}
try {
const { message } = decryptWecomCallback(echostr, config.encodingAesKey);
return new Response(message, {
status: 200,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
} catch (error) {
if (error instanceof WecomCallbackCryptoError) {
console.warn("[KOC LOOP] wecom callback echostr decrypt failed", error);
return new Response(error.message, { status: 400 });
}
throw error;
}
}
export async function POST(request: Request) {
const config = resolveCallbackConfig();
if (!config) {
return new Response("企业微信回调未启用", { status: 403 });
}
const { msgSignature, timestamp, nonce } = readCallbackQuery(request);
try {
const body = await request.text();
const encrypt = extractWecomEncryptField(body);
if (!encrypt) {
return new Response("回调报文缺少 Encrypt 字段", { status: 400 });
}
if (!msgSignature || !timestamp || !nonce) {
return new Response("缺少回调校验参数", { status: 400 });
}
const expected = verifyWecomCallbackSignature(
config.token,
timestamp,
nonce,
encrypt,
);
if (!wecomSignaturesMatch(expected, msgSignature)) {
return new Response("签名校验失败", { status: 403 });
}
const { message, receiveId } = decryptWecomCallback(
encrypt,
config.encodingAesKey,
);
if (config.corpId && receiveId !== config.corpId) {
console.warn(
"[KOC LOOP] wecom callback receiveid mismatch",
{ expected: config.corpId },
);
return new Response("receiveid 校验失败", { status: 403 });
}
const fields = parseWecomCallbackXml(message);
try {
await ensureSchema();
await handleWecomCallbackEvent(getRawDb(), fields);
} catch (eventError) {
console.error("[KOC LOOP] wecom callback event handling failed", eventError);
}
return new Response("", { status: 200 });
} catch (error) {
if (error instanceof WecomCallbackCryptoError) {
console.warn("[KOC LOOP] wecom callback decrypt failed", error);
return new Response(error.message, { status: 400 });
}
console.error("[KOC LOOP] wecom callback failed", error);
return new Response("", { status: 200 });
}
}

View File

@@ -0,0 +1,65 @@
import { getRuntimeEnv } from "../../../lib/runtime-env";
import {
exchangeWecomOAuthCode,
fetchWecomExternalContactName,
resolveWecomConfig,
WecomClientError,
type WecomBindings,
} from "../../../lib/wecom-client";
import { signWecomIdentityTicket } from "../../../lib/wecom-oauth-service";
import { partnerOptions, withPartnerCors } from "../../../lib/partner-cors";
const env = getRuntimeEnv();
export function OPTIONS(request: Request) {
return partnerOptions(request);
}
async function handlePost(request: Request) {
try {
const body = (await request.json().catch(() => ({}))) as { code?: string };
const code = String(body.code ?? "").trim();
const config = resolveWecomConfig(env as unknown as WecomBindings);
if (!config.corpId || !config.agentId || !config.secret) {
return Response.json(
{ error: "未配置企业微信授权(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)" },
{ status: 501 },
);
}
if (!code) {
return Response.json({ error: "缺少授权 code" }, { status: 400 });
}
const identity = await exchangeWecomOAuthCode(config, code);
if (identity.externalUserId) {
const displayName =
(await fetchWecomExternalContactName(config, identity.externalUserId)) ??
"企微客户";
const { ticket, expiresIn } = await signWecomIdentityTicket(
identity.externalUserId,
displayName,
);
return Response.json({
userType: "external",
identityTicket: ticket,
displayName,
expiresIn,
});
}
return Response.json({
userType: "internal",
userId: identity.userId ?? "",
});
} catch (error) {
const status = error instanceof WecomClientError ? error.status : 500;
return Response.json(
{ error: error instanceof Error ? error.message : "授权失败" },
{ status },
);
}
}
export async function POST(request: Request) {
const response = await handlePost(request);
response.headers.set("Cache-Control", "private, no-store");
return withPartnerCors(request, response);
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,11 @@ export const partners = mysqlTable("partners", {
owner: varchar("owner", { length: 255 }).notNull().default("运营组"),
claimedTotal: int("claimed_total").notNull().default(0),
completedTotal: int("completed_total").notNull().default(0),
wecomExternalUserId: varchar("wecom_external_user_id", { length: 128 }),
partnerType: varchar("partner_type", { length: 32 })
.notNull()
.default("external_koc"),
wecomUserid: varchar("wecom_userid", { length: 128 }).notNull().default(""),
createdAt: timestamp("created_at"),
});
@@ -52,6 +57,7 @@ export const tasks = mysqlTable(
mode: "string",
fsp: 3,
}),
projectId: varchar("project_id", { length: 64 }).notNull().default(""),
createdAt: timestamp("created_at"),
},
(table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)],
@@ -252,6 +258,78 @@ export const mcpExportTokens = mysqlTable(
(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 wecomPushLogs = mysqlTable(
"wecom_push_logs",
{
id: varchar("id", { length: 64 }).primaryKey(),
taskId: varchar("task_id", { length: 64 }).notNull(),
projectId: varchar("project_id", { length: 64 }).notNull().default(""),
pushKind: varchar("push_kind", { length: 32 }).notNull().default("task_open"),
mainGroupCount: int("main_group_count").notNull().default(0),
kocUserids: text("koc_userids").notNull(),
kocOkCount: int("koc_ok_count").notNull().default(0),
kocFailList: text("koc_fail_list").notNull(),
groupPushId: varchar("group_push_id", { length: 128 }).notNull().default(""),
detail: text("detail").notNull(),
createdAt: timestamp("created_at"),
},
(table) => [index("wecom_push_logs_task_idx").on(table.taskId, table.pushKind)],
);
export const projects = mysqlTable("projects", {
id: varchar("id", { length: 64 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
brand: varchar("brand", { length: 255 }).notNull().default(""),
mainGroupMemberIds: text("main_group_member_ids").notNull(),
status: varchar("status", { length: 32 }).notNull().default("active"),
note: text("note").notNull(),
createdAt: timestamp("created_at"),
});
export const projectGroupChats = mysqlTable(
"project_group_chats",
{
id: varchar("id", { length: 64 }).primaryKey(),
projectId: varchar("project_id", { length: 64 }).notNull(),
chatId: varchar("chat_id", { length: 128 }).notNull(),
partnerId: varchar("partner_id", { length: 64 }).notNull().default(""),
createdAt: timestamp("created_at"),
},
(table) => [
uniqueIndex("project_group_chats_uniq").on(table.projectId, table.chatId),
index("project_group_chats_project_idx").on(table.projectId),
],
);
export const backgroundJobs = mysqlTable(
"background_jobs",
{

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

42
dev.vars_exp Normal file
View File

@@ -0,0 +1,42 @@
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
# 数据回流通知(可选):KOC 提交链接/截图、采集摘要推运营群机器人,默认关闭
WECOM_FEEDBACK_ENABLED=false
# 超期升级催办(可选):超期任务升级运营群 + 项目总群交互卡片,默认关闭
WECOM_ESCALATION_ENABLED=false
# 超期 N 天自动释放(可选):从 due_at 起算,0 = 关闭(默认),最大 90
WECOM_AUTO_RELEASE_DAYS=0
# 企微回调(可选):交互卡片「已处理」回执落库;回调 URL 为 /api/wecom-callback,默认关闭
WECOM_CALLBACK_ENABLED=false
WECOM_CALLBACK_TOKEN=
WECOM_CALLBACK_ENCODING_AES_KEY=
# 本地测试:让 /api/bootstrap 自动 seed 3 个测试 partner + 5 条待发布 distribution
# 上线后改为 false
SEED_DEMO_DATA=false

View File

@@ -0,0 +1,57 @@
# 生产环境编排:只包含 KOC 服务与入口 Nginx,数据库直连公司公共 MySQL。
# 没有公共 MySQL 可用时,请改用 docker-compose.self-hosted.yml(自带数据库容器)。
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
environment:
MYSQL_HOST: ${MYSQL_HOST:?请在环境文件中设置公共 MySQL 主机 MYSQL_HOST}
MYSQL_PORT: ${MYSQL_PORT:-3306}
MYSQL_DATABASE: ${MYSQL_DATABASE:-koc_loop}
MYSQL_USER: ${MYSQL_USER:-koc}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?请在环境文件中设置 MYSQL_PASSWORD}
UPLOAD_DIR: /data/koc/uploads
APP_ORIGIN: ${APP_ORIGIN}
KOC_PORTAL_URL: ${KOC_PORTAL_URL}
SUPER_ADMIN_USERNAME: ${SUPER_ADMIN_USERNAME}
SUPER_ADMIN_PASSWORD: ${SUPER_ADMIN_PASSWORD}
ADMIN_INTERNAL_TOKEN: ${ADMIN_INTERNAL_TOKEN}
KOC_MCP_API_KEY: ${KOC_MCP_API_KEY}
FEISHU_APP_ID: ${FEISHU_APP_ID:-}
FEISHU_APP_SECRET: ${FEISHU_APP_SECRET:-}
AI_TOOL_CENTER_MCP_URL: ${AI_TOOL_CENTER_MCP_URL:-}
AI_TOOL_CENTER_MCP_KEY: ${AI_TOOL_CENTER_MCP_KEY:-}
AI_TAGGING_TOOL: ${AI_TAGGING_TOOL:-}
WECOM_CORP_ID: ${WECOM_CORP_ID:-}
WECOM_AGENT_ID: ${WECOM_AGENT_ID:-}
WECOM_SECRET: ${WECOM_SECRET:-}
WECOM_ROBOT_WEBHOOK: ${WECOM_ROBOT_WEBHOOK:-}
WECOM_NOTIFY_DUE_DAYS: ${WECOM_NOTIFY_DUE_DAYS:-}
WECOM_NOTIFY_ENABLED: ${WECOM_NOTIFY_ENABLED:-}
ENABLE_SCHEDULER: ${ENABLE_SCHEDULER:-true}
SEED_DEMO_DATA: ${SEED_DEMO_DATA:-false}
TZ: Asia/Shanghai
volumes:
- upload_data:/data/koc/uploads
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))\""]
interval: 15s
timeout: 5s
retries: 10
start_period: 45s
nginx:
build:
context: .
dockerfile: deploy/nginx/Dockerfile
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
depends_on:
app:
condition: service_healthy
volumes:
upload_data:

View File

@@ -43,6 +43,13 @@ services:
FEISHU_APP_SECRET: ${FEISHU_APP_SECRET:-}
AI_TOOL_CENTER_MCP_URL: ${AI_TOOL_CENTER_MCP_URL:-}
AI_TOOL_CENTER_MCP_KEY: ${AI_TOOL_CENTER_MCP_KEY:-}
AI_TAGGING_TOOL: ${AI_TAGGING_TOOL:-}
WECOM_CORP_ID: ${WECOM_CORP_ID:-}
WECOM_AGENT_ID: ${WECOM_AGENT_ID:-}
WECOM_SECRET: ${WECOM_SECRET:-}
WECOM_ROBOT_WEBHOOK: ${WECOM_ROBOT_WEBHOOK:-}
WECOM_NOTIFY_DUE_DAYS: ${WECOM_NOTIFY_DUE_DAYS:-}
WECOM_NOTIFY_ENABLED: ${WECOM_NOTIFY_ENABLED:-}
ENABLE_SCHEDULER: ${ENABLE_SCHEDULER:-true}
SEED_DEMO_DATA: ${SEED_DEMO_DATA:-false}
TZ: Asia/Shanghai

View File

@@ -19,6 +19,13 @@
第一版按单实例 KOC 服务设计。MySQL 和上传目录均持久化。以后需要横向扩容时,可以把上传卷换成共享 NAS;对象存储接口已与业务代码分离。
仓库提供两套编排文件,按数据库归属选用,共用同一份环境文件 `.env.app`(模板 `.env.app.example`):
- `docker-compose.self-hosted.yml`:自带 MySQL 容器与数据卷,适合独立服务器或没有公共数据库的环境;
- `docker-compose.production.yml`:只包含 KOC 服务与入口 Nginx,直连公司公共 MySQL(要求 MySQL 8 + utf8mb4,账号需对目标库有建表与读写权限)。
两套文件的入口 Nginx 都建议保留:它除了反向代理,还托管 `/koc/` 领取页静态文件、`/api/mcp` 长连接规则与 85 MB 上传限制,公司网关只需把域名转发到容器的 `HTTP_PORT`。
## 2. 服务器要求
- Linux 服务器一台,建议至少 4 核、8 GB 内存、100 GB 数据盘;
@@ -36,10 +43,10 @@
git clone ssh://git@gta.gbotai.cn:42001/wufengping/koc-loop.git
cd koc-loop
git checkout main
cp .env.self-hosted.example .env.self-hosted
cp .env.app.example .env.app
```
编辑 `.env.self-hosted`。必须替换所有 `replace-with-*` 占位值:
编辑 `.env.app`。必须替换所有 `replace-with-*` 占位值(公共 MySQL 模式还需把 `MYSQL_HOST` 指向公司实例):
| 变量 | 用途 |
| --- | --- |
@@ -54,6 +61,17 @@ cp .env.self-hosted.example .env.self-hosted
| `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | 读取飞书内容表和配图 |
| `AI_TOOL_CENTER_MCP_URL` / `AI_TOOL_CENTER_MCP_KEY` | 小红书公开数据采集服务 |
| `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` |
| `WECOM_FEEDBACK_ENABLED` | 数据回流通知,默认 `false`;开启后 KOC 提交链接/截图与每日采集摘要推送运营群机器人 |
| `WECOM_ESCALATION_ENABLED` | 超期升级催办,默认 `false`;开启后超期任务升级运营群机器人 + 项目总群交互卡片 |
| `WECOM_AUTO_RELEASE_DAYS` | 超期 N 天自动释放内容回可领取池,从 `due_at` 起算,`0` = 关闭(默认),最大 90 |
| `WECOM_CALLBACK_ENABLED` | 企微回调开关,默认 `false`;开启后支持交互卡片「已处理」回执落库,需配置回调 URL 与密钥 |
| `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_ENCODING_AES_KEY` | 企微后台「接收消息」里生成的回调 Token 与 EncodingAESKey |
企业微信变量全部选填:不填则临期催办与汇总都跳过,不影响其他功能。所有变量都可以在「合作方」页面顶部「企业微信连通性」面板查看就绪状态,并用「测试群机器人」「测试发送」按钮验证。
密钥必须由密码管理器生成,禁止提交到 Git、聊天、部署日志或 URL。三个业务密钥 `ADMIN_INTERNAL_TOKEN`、`KOC_MCP_API_KEY`、`AI_TOOL_CENTER_MCP_KEY` 不得复用。
@@ -62,14 +80,21 @@ cp .env.self-hosted.example .env.self-hosted
## 4. 首次启动
```bash
docker compose --env-file .env.self-hosted \
docker compose --env-file .env.app \
-f docker-compose.self-hosted.yml up -d --build
```
应用容器启动时会先执行同一套数据库迁移脚本,成功后才启动 KOC 服务。查看状态:
生产环境使用公司公共 MySQL 时改用:
```bash
docker compose --env-file .env.self-hosted \
docker compose --env-file .env.app \
-f docker-compose.production.yml up -d --build
```
应用容器启动时会先执行同一套数据库迁移脚本,成功后才启动 KOC 服务。查看状态(生产模式把 `-f` 换成对应编排文件即可):
```bash
docker compose --env-file .env.app \
-f docker-compose.self-hosted.yml ps
curl -fsS http://127.0.0.1:${HTTP_PORT:-80}/api/health
@@ -183,7 +208,7 @@ UPLOAD_DIR=/data/koc/uploads \
```bash
git pull
docker compose --env-file .env.self-hosted \
docker compose --env-file .env.app \
-f docker-compose.self-hosted.yml up -d --build
```
@@ -206,3 +231,90 @@ docker compose --env-file .env.self-hosted \
| 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 |
生产日志不得打印数据库密码、飞书 Secret、MCP key 或完整带 key 的采集服务 URL。
## 11. 企业微信自建应用与群机器人配置
KOC LOOP 的企业微信通知默认是**单向外发**:每天 09:00 与定时采集一同触发,给合作方绑定的外部联系人推送临期催办,再向群机器人发送当日汇总。基础催办不需要回调,也不需要拉通讯录;交互卡片回执等进阶能力见第 12 章。
### 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.app`(重启服务),再回到该页面点「刷新状态」。
### 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 会进入失败列表但不影响其他群。
## 12. 以企微为中心的业务流程(一键推送 / 回流通知 / 分层催办 / 回调卡片 / 复盘报表)
在 11.2 自建应用与 11.5 企业群发的基础上,以下能力全部由环境变量独立控制,默认关闭,不配置则对现有流程零影响。推送记录统一落库 `wecom_push_logs`(迁移 `mysql/0011_wecom_push_logs.sql`,`npm run db:migrate` 自动执行)。
### 12.1 任务一键推送
后台「任务中心」→ 任务行「一键推送」:一次把任务送达三类对象 —— ①项目总群成员应用消息(复用项目推送)②已绑定企微的 KOC 个人直达(弹层默认全选,可搜索/取消勾选)③可选创建任务群企业群发。同一任务当日重复推送会先提示,确认后可强制再推。KOC 消息里的领取链接在企微内置浏览器打开时自动完成 OAuth 静默登录,KOC 无需输任何身份。
### 12.2 数据回流通知(`WECOM_FEEDBACK_ENABLED`)
开启后:KOC 提交发布链接 / 截图 → 运营群机器人收到提醒(合作方 / 任务 / 标题 / 链接);每天 09:00 定时采集结束后推送批次摘要,采集失败会附告警清单。通知失败不影响提交与采集主流程。
### 12.3 超期升级与自动释放(`WECOM_ESCALATION_ENABLED` / `WECOM_AUTO_RELEASE_DAYS`)
催办分三层:临期(默认 3 天内)直达合作方本人(原有逻辑不变)→ 超期(`due_at` 已过)升级运营群机器人汇总 + 项目总群成员收到 template_card 交互卡片(按钮「查看任务」跳转、「已处理」回执落库)→ 超期 N 天(`WECOM_AUTO_RELEASE_DAYS`,从 `due_at` 起算)自动把仍未发布的 distribution 释放回可领取池,已提交截图或已回填链接的不释放。两层开关独立;也可在「合作方」页点「跑一次超期升级」手动触发。
### 12.4 企微回调配置(`WECOM_CALLBACK_ENABLED`)
交互卡片「已处理」按钮需要公网回调才能回执。步骤:
1. 登录企业微信管理后台 → 应用管理 → 自建 → 打开 11.2 的应用;
2. 「接收消息」→ 设置 API 接收 → 回调 URL 填 `https://<APP_ORIGIN>/api/wecom-callback`(必须公网 80/443 可达,与 APP_ORIGIN 一致);
3. Token 与 EncodingAESKey 随机生成后,分别填入 `WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY`,保存时企微会向该 URL 发 GET 验证,服务端自动应答;
4. 「企业可信 IP」(开发者接口)加入服务器出口 IP,否则 access_token 类接口全部返回 `60020`;
5. 应用「可见范围」包含接收卡片的运营成员;
6. `WECOM_CALLBACK_ENABLED=true` 并重启服务;关闭时回调路由直接 403,可随时回退。
回执写入 `wecom_push_logs`(`push_kind = card_ack`),在任务推送记录里可见「卡片确认」条目。回调地址、Token 或密钥配错时企微保存验证不通过,逐项核对第 2、3 步。
### 12.5 复盘报表
后台「复盘报表」(管理员可见):按任务汇总领取 / 发布 / 完成数、完成率、曝光阅读与 D2/D5/D7/最新互动数据,支持状态、类型、关键词筛选,一键导出带 BOM 的 CSV(Excel 直接打开不乱码)。无需环境变量。

View File

@@ -0,0 +1,9 @@
账号链接,账号昵称,小红书号,IP属地,粉丝数,性别,简介,标签,合作来源
https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008401,小红薯爱分享,xhs_88012345,上海,1.2万,女,爱美食爱生活的上海小姐姐,合作私信~,"美食,探店,生活方式",媒介推荐
https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008402,阿杰的穿搭日记,xhs_7712ab,广东,8600,男,男生穿搭 | 平价好物 | 每周更新,"穿搭,男装,平价好物",历史合作
https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008403,琳琳妈妈育儿记,xhs_6633cd,浙江,3.5万,女,二胎妈妈,分享育儿干货和母婴好物,"母婴,育儿,好物分享",蒲公英
https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008404,健身教练大凯,xhs_5544ef,北京,10万+,男,NSCA认证教练,运动补剂科普,"健身,运动,补剂",自荐
https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008405,甜品研究社,xhs_4455gh,四川,2.1万,女,烘焙教程 | 甜品测评 | 成都探店,"烘焙,甜品,探店",媒介推荐
https://www.douyin.com/user/ms4y9x873kq1,数码测评君,dy_3321ij,江苏,5.8万,男,手机数码深度测评,避坑指南,"数码,测评,科技",星图
https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008407,旅行摄影的小舟,xhs_2266mn,云南,6400,女,风光摄影 | 旅行vlog | 云南旅拍,"旅行,摄影",历史合作
https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008408,美妆小课堂Amy,xhs_1177op,上海,15万,女,化妆品成分党,专注国货美妆测评,"美妆,护肤,国货",蒲公英
1 账号链接 账号昵称 小红书号 IP属地 粉丝数 性别 简介 标签 合作来源
2 https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008401 小红薯爱分享 xhs_88012345 上海 1.2万 女 爱美食爱生活的上海小姐姐,合作私信~ 美食,探店,生活方式 媒介推荐
3 https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008402 阿杰的穿搭日记 xhs_7712ab 广东 8600 男 男生穿搭 | 平价好物 | 每周更新 穿搭,男装,平价好物 历史合作
4 https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008403 琳琳妈妈育儿记 xhs_6633cd 浙江 3.5万 女 二胎妈妈,分享育儿干货和母婴好物 母婴,育儿,好物分享 蒲公英
5 https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008404 健身教练大凯 xhs_5544ef 北京 10万+ 男 NSCA认证教练,运动补剂科普 健身,运动,补剂 自荐
6 https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008405 甜品研究社 xhs_4455gh 四川 2.1万 女 烘焙教程 | 甜品测评 | 成都探店 烘焙,甜品,探店 媒介推荐
7 https://www.douyin.com/user/ms4y9x873kq1 数码测评君 dy_3321ij 江苏 5.8万 男 手机数码深度测评,避坑指南 数码,测评,科技 星图
8 https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008407 旅行摄影的小舟 xhs_2266mn 云南 6400 女 风光摄影 | 旅行vlog | 云南旅拍 旅行,摄影 历史合作
9 https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008408 美妆小课堂Amy xhs_1177op 上海 15万 女 化妆品成分党,专注国货美妆测评 美妆,护肤,国货 蒲公英

View File

@@ -0,0 +1,8 @@
{"profileUrl": "https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008401", "nickname": "小红薯爱分享", "publicAccountId": "xhs_88012345", "ipLocation": "上海", "followers": "1.2万", "gender": "女", "bio": "爱美食爱生活的上海小姐姐,合作私信~", "tags": "美食,探店,生活方式", "cooperationSource": "媒介推荐"}
{"profileUrl": "https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008402", "nickname": "阿杰的穿搭日记", "publicAccountId": "xhs_7712ab", "ipLocation": "广东", "followers": "8600", "gender": "男", "bio": "男生穿搭 | 平价好物 | 每周更新", "tags": "穿搭,男装,平价好物", "cooperationSource": "历史合作"}
{"profileUrl": "https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008403", "nickname": "琳琳妈妈育儿记", "publicAccountId": "xhs_6633cd", "ipLocation": "浙江", "followers": "3.5万", "gender": "女", "bio": "二胎妈妈,分享育儿干货和母婴好物", "tags": "母婴,育儿,好物分享", "cooperationSource": "蒲公英"}
{"profileUrl": "https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008404", "nickname": "健身教练大凯", "publicAccountId": "xhs_5544ef", "ipLocation": "北京", "followers": "10万+", "gender": "男", "bio": "NSCA认证教练,运动补剂科普", "tags": "健身,运动,补剂", "cooperationSource": "自荐"}
{"profileUrl": "https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008405", "nickname": "甜品研究社", "publicAccountId": "xhs_4455gh", "ipLocation": "四川", "followers": "2.1万", "gender": "女", "bio": "烘焙教程 | 甜品测评 | 成都探店", "tags": "烘焙,甜品,探店", "cooperationSource": "媒介推荐"}
{"profileUrl": "https://www.douyin.com/user/ms4y9x873kq1", "nickname": "数码测评君", "publicAccountId": "dy_3321ij", "ipLocation": "江苏", "followers": "5.8万", "gender": "男", "bio": "手机数码深度测评,避坑指南", "tags": "数码,测评,科技", "cooperationSource": "星图"}
{"profileUrl": "https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008407", "nickname": "旅行摄影的小舟", "publicAccountId": "xhs_2266mn", "ipLocation": "云南", "followers": "6400", "gender": "女", "bio": "风光摄影 | 旅行vlog | 云南旅拍", "tags": "旅行,摄影", "cooperationSource": "历史合作"}
{"profileUrl": "https://www.xiaohongshu.com/user/profile/5ff0e6410000000001008408", "nickname": "美妆小课堂Amy", "publicAccountId": "xhs_1177op", "ipLocation": "上海", "followers": "15万", "gender": "女", "bio": "化妆品成分党,专注国货美妆测评", "tags": "美妆,护肤,国货", "cooperationSource": "蒲公英"}

View File

@@ -547,6 +547,40 @@ input:focus {
line-height: 1.65;
}
.wecom-identity-chip {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--green-soft);
}
.wecom-identity-chip b {
color: var(--green-deep);
font-size: 11px;
}
.wecom-identity-chip span {
color: var(--ink);
font-size: 11px;
font-weight: 600;
}
.wecom-identity-chip button {
margin-left: auto;
border: none;
background: none;
color: var(--muted);
font-size: 10px;
text-decoration: underline;
cursor: pointer;
padding: 0;
}
.recovery-list {
display: flex;
flex-direction: column;

View File

@@ -107,11 +107,135 @@ function partnerApi(
path:
| "/api/partner"
| "/api/partner-upload"
| "/api/partner-batch-workbook",
| "/api/partner-batch-workbook"
| "/api/wecom-oauth",
) {
return `${resolveAdminOrigin()}${path}`;
}
const WECOM_CORP_ID = process.env.NEXT_PUBLIC_WECOM_CORP_ID ?? "";
const WECOM_AGENT_ID = process.env.NEXT_PUBLIC_WECOM_AGENT_ID ?? "";
const WECOM_OAUTH_ENABLED = Boolean(WECOM_CORP_ID && WECOM_AGENT_ID);
const WECOM_PORTAL_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "/koc";
const WECOM_IDENTITY_SESSION_KEY = "koc-wecom-identity";
const WECOM_AUTH_FAILED_KEY = "koc-wecom-auth-failed";
const WECOM_OAUTH_SID_PREFIX = "koc-wecom-oauth-sid";
const WECOM_OAUTH_STATE_LIMIT = 120;
type WecomPortalIdentity = {
ticket: string;
name: string;
expiresAt: number;
};
type PortalParams = {
task: string;
claim: string;
share: string;
note: string;
};
function readSessionValue<T>(key: string): T | null {
try {
const raw = window.sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function writeSessionValue(key: string, value: unknown): boolean {
try {
window.sessionStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}
function markSessionFlag(key: string) {
try {
window.sessionStorage.setItem(key, "1");
} catch {
// 隐私模式等场景下忽略,最多少一次自动授权
}
}
function hasSessionFlag(key: string) {
try {
return window.sessionStorage.getItem(key) === "1";
} catch {
return false;
}
}
function loadWecomIdentity(): WecomPortalIdentity | null {
const identity = readSessionValue<WecomPortalIdentity>(
WECOM_IDENTITY_SESSION_KEY,
);
if (!identity?.ticket || typeof identity.expiresAt !== "number") return null;
if (Date.now() > identity.expiresAt) return null;
return identity;
}
function isWecomEmbeddedBrowser() {
const userAgent = window.navigator.userAgent;
return /wxwork/i.test(userAgent) || /MicroMessenger/i.test(userAgent);
}
function buildWecomAuthorizeUrl(state: string) {
const query = new URLSearchParams({
appid: WECOM_CORP_ID,
redirect_uri: `${window.location.origin}${WECOM_PORTAL_BASE_PATH}/`,
response_type: "code",
scope: "snsapi_base",
state,
agentid: WECOM_AGENT_ID,
});
return `https://open.weixin.qq.com/connect/oauth2/authorize?${query}#wechat_redirect`;
}
function buildWecomOAuthState(params: PortalParams): string {
const compact = new URLSearchParams();
if (params.task) compact.set("t", params.task);
if (params.claim) compact.set("c", params.claim);
if (params.share) compact.set("s", params.share);
if (params.note) compact.set("n", params.note);
if (compact.toString().length <= WECOM_OAUTH_STATE_LIMIT) {
return compact.toString();
}
const sid = crypto.randomUUID().replaceAll("-", "").slice(0, 12);
if (writeSessionValue(`${WECOM_OAUTH_SID_PREFIX}:${sid}`, params)) {
return `sid=${sid}`;
}
const fallback = new URLSearchParams();
if (params.task) fallback.set("t", params.task);
if (params.share) fallback.set("s", params.share);
return fallback.toString();
}
function restoreParamsFromWecomState(state: string): PortalParams {
const parsed = new URLSearchParams(state);
const sid = parsed.get("sid");
if (sid) {
const key = `${WECOM_OAUTH_SID_PREFIX}:${sid}`;
const stored = readSessionValue<PortalParams>(key);
try {
window.sessionStorage.removeItem(key);
} catch {
// 忽略清理失败
}
if (stored?.task || stored?.share) return stored;
return { task: "", claim: "", share: "", note: "" };
}
return {
task: parsed.get("t") ?? "",
claim: parsed.get("c") ?? "",
share: parsed.get("s") ?? "",
note: parsed.get("n") ?? "",
};
}
function isMobilePortalViewport() {
return (
typeof window !== "undefined" &&
@@ -325,6 +449,10 @@ export default function Home() {
const [creatorViews, setCreatorViews] = useState("");
const [entryMode, setEntryMode] = useState<"claim" | "recover">("claim");
const [recoveredClaims, setRecoveredClaims] = useState<RecoveredClaim[]>([]);
const [wecomIdentity, setWecomIdentity] = useState<WecomPortalIdentity | null>(
null,
);
const [manualEntry, setManualEntry] = useState(false);
const [adminOrigin, setAdminOrigin] = useState("");
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
const [batchDownloading, setBatchDownloading] = useState(false);
@@ -401,10 +529,79 @@ export default function Home() {
}
}, []);
const exchangeWecomIdentity = useCallback(
async (code: string, restored: PortalParams) => {
try {
const response = await fetch(partnerApi("/api/wecom-oauth"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
const result = (await response.json()) as {
userType?: string;
identityTicket?: string;
displayName?: string;
expiresIn?: number;
error?: string;
};
if (
response.ok &&
result.userType === "external" &&
result.identityTicket
) {
const identity: WecomPortalIdentity = {
ticket: result.identityTicket,
name: result.displayName || "企微客户",
expiresAt:
Date.now() +
Math.max(60, (result.expiresIn ?? 1800) - 60) * 1_000,
};
writeSessionValue(WECOM_IDENTITY_SESSION_KEY, identity);
setWecomIdentity(identity);
setManualEntry(false);
setToast(`企微身份已确认:${identity.name}`);
} else {
markSessionFlag(WECOM_AUTH_FAILED_KEY);
setToast(
result.userType === "internal"
? "当前为企业内部成员身份,请手动输入微信号领取"
: result.error || "企微授权失败,请手动输入微信号",
);
}
} catch {
markSessionFlag(WECOM_AUTH_FAILED_KEY);
setToast("企微授权失败,请手动输入微信号");
}
setTaskToken(restored.task);
setClaimToken(restored.claim);
setDelegationToken(restored.share);
setSelectedId(restored.note);
if (!restored.task && !restored.share) {
setLoading(false);
setError("任务链接不完整,请向运营重新获取");
return;
}
await loadTask(restored.task, restored.claim, restored.share);
},
[loadTask],
);
useEffect(() => {
const timer = window.setTimeout(() => {
setAdminOrigin(resolveAdminOrigin());
const params = new URLSearchParams(window.location.search);
const oauthCode = params.get("code") ?? "";
if (oauthCode) {
const restored = restoreParamsFromWecomState(params.get("state") ?? "");
const cleaned = new URL(window.location.href);
cleaned.searchParams.delete("code");
cleaned.searchParams.delete("state");
window.history.replaceState({}, "", cleaned);
void exchangeWecomIdentity(oauthCode, restored);
return;
}
const cachedIdentity = loadWecomIdentity();
if (cachedIdentity) setWecomIdentity(cachedIdentity);
const task = params.get("task") ?? "";
const claim = params.get("claim") ?? "";
const delegation = params.get("share") ?? "";
@@ -418,10 +615,28 @@ export default function Home() {
setError("任务链接不完整,请向运营重新获取");
return;
}
if (
WECOM_OAUTH_ENABLED &&
!cachedIdentity &&
!hasSessionFlag(WECOM_AUTH_FAILED_KEY) &&
isWecomEmbeddedBrowser()
) {
window.location.replace(
buildWecomAuthorizeUrl(
buildWecomOAuthState({
task,
claim,
share: delegation,
note,
}),
),
);
return;
}
void loadTask(task, claim, delegation);
}, 0);
return () => window.clearTimeout(timer);
}, [loadTask]);
}, [loadTask, exchangeWecomIdentity]);
useEffect(() => {
if (!toast) return;
@@ -464,6 +679,9 @@ export default function Home() {
action: "claim",
taskToken,
claimantIdentifier: claimantName,
...(wecomIdentity && !manualEntry
? { identityTicket: wecomIdentity.ticket }
: {}),
quantity,
}),
});
@@ -512,6 +730,9 @@ export default function Home() {
action: "recover",
taskToken,
claimantIdentifier: claimantName,
...(wecomIdentity && !manualEntry
? { identityTicket: wecomIdentity.ticket }
: {}),
}),
});
const result = (await response.json()) as {
@@ -2031,25 +2252,36 @@ export default function Home() {
找回领取记录
</button>
</div>
{wecomIdentity && !manualEntry && (
<div className="wecom-identity-chip">
<b>企微身份已确认</b>
<span>{wecomIdentity.name}</span>
<button type="button" onClick={() => setManualEntry(true)}>
切换为手动输入
</button>
</div>
)}
{entryMode === "claim" ? (
<form onSubmit={claimNotes}>
<div>
<p className="micro">01 · 确认领取</p>
<h2>你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?</h2>
</div>
<label>
<span>微信号 / 手机号</span>
<input
value={claimantName}
onChange={(event) => setClaimantName(event.target.value)}
placeholder="请输入微信号或手机号"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={64}
required
/>
</label>
{(!wecomIdentity || manualEntry) && (
<label>
<span>微信号 / 手机号</span>
<input
value={claimantName}
onChange={(event) => setClaimantName(event.target.value)}
placeholder="请输入微信号或手机号"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={64}
required
/>
</label>
)}
<div className="quantity-control">
<button type="button" onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button>
<div><strong>{quantity}</strong><span>{payload.task.type === "screenshot_collect" ? "份" : "篇"}</span></div>
@@ -2080,22 +2312,26 @@ export default function Home() {
<p className="micro">找回历史记录</p>
<h2>继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2>
<p className="recover-intro">
输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。
{wecomIdentity && !manualEntry
? "已用企微身份查找,同一任务多次领取会分批展示。"
: "输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。"}
</p>
</div>
<label>
<span>领取时的微信号 / 手机号</span>
<input
value={claimantName}
onChange={(event) => setClaimantName(event.target.value)}
placeholder="需与领取时完全一致"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={64}
required
/>
</label>
{(!wecomIdentity || manualEntry) && (
<label>
<span>领取时的微信号 / 手机号</span>
<input
value={claimantName}
onChange={(event) => setClaimantName(event.target.value)}
placeholder="需与领取时完全一致"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={64}
required
/>
</label>
)}
<button className="claim-primary" disabled={working}>
{working ? "正在查找…" : "查找我的领取记录"}
</button>

View File

@@ -9,7 +9,7 @@
"dev": "next dev -p 3001",
"build": "next build",
"start": "npx serve out -l 3001",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"test": "npm run build && node --import tsx --test tests/rendered-html.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern out"
},
"dependencies": {

View File

@@ -170,3 +170,29 @@ test("creates anonymous delegation bundles and reuses one-to-one backfill", asyn
assert.match(styles, /\.share-checkbox/);
assert.match(styles, /\.delegation-history/);
});
test("wires silent wecom oauth with full manual fallback", async () => {
const [page, styles] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(page, /NEXT_PUBLIC_WECOM_CORP_ID/);
assert.match(page, /NEXT_PUBLIC_WECOM_AGENT_ID/);
assert.match(page, /snsapi_base/);
assert.match(page, /connect\/oauth2\/authorize/);
assert.match(page, /wxwork\/i\.test\(userAgent\)/);
assert.match(page, /MicroMessenger\/i\.test\(userAgent\)/);
assert.match(page, /"koc-wecom-identity"/);
assert.match(page, /"koc-wecom-auth-failed"/);
assert.match(page, /\/api\/wecom-oauth/);
assert.match(page, /restoreParamsFromWecomState/);
assert.match(page, /buildWecomOAuthState/);
assert.match(page, /identityTicket: wecomIdentity\.ticket/);
assert.match(page, /企微身份已确认/);
assert.match(page, /切换为手动输入/);
assert.match(page, /window\.location\.replace\(/);
assert.match(styles, /\.wecom-identity-chip/);
assert.match(page, /\(!wecomIdentity \|\| manualEntry\)/);
assert.match(page, /claimantIdentifier:\s*claimantName/);
});

View File

@@ -1,9 +1,12 @@
import {
generateAccountTagsFromMcp,
resolveXhsPublicAccountDetails,
resolveAccountProfileFromMcp,
resolveProfileDetailsFromMcp,
type AccountTaggingEnv,
type CollectionMcpConfig,
} from "./mcp-collection-client";
import { getRuntimeEnv } from "./runtime-env";
import { hashText } from "./mvp-db";
import type { DatabaseClient } from "./database";
@@ -260,6 +263,7 @@ export async function backfillAccountProfiles(
mcpConfig: CollectionMcpConfig,
limit = 10,
) {
const taggingEnv: AccountTaggingEnv = getRuntimeEnv();
const rows = await db
.prepare(
`SELECT
@@ -334,6 +338,7 @@ export async function backfillAccountProfiles(
const isDemoAccount = row.platform_uid?.startsWith("xhs-");
let attemptedThisRow = false;
let publicProfileUpdated = false;
let tagsUpdated = false;
if (
row.platform === "小红书" &&
!isDemoAccount &&
@@ -341,7 +346,8 @@ export async function backfillAccountProfiles(
(!row.public_account_id ||
Number(row.followers ?? 0) === 0 ||
!row.gender ||
!row.bio)
!row.bio ||
!row.tags?.trim())
) {
attempted += 1;
attemptedThisRow = true;
@@ -402,6 +408,39 @@ export async function backfillAccountProfiles(
.run();
publicProfileUpdated = true;
}
if (
row.account_id &&
!row.tags?.trim() &&
(details.bio ||
details.providerTags.length > 0 ||
details.recentNoteTitles.length > 0)
) {
const tags = await generateAccountTagsFromMcp(
{
platform: "小红书",
nickname: row.nickname || details.nickname || "",
bio: details.bio,
gender: details.gender,
ipLocation: details.ipLocation ?? "",
followers: details.followers,
providerTags: details.providerTags,
recentNoteTitles: details.recentNoteTitles,
},
mcpConfig,
taggingEnv,
).catch(() => []);
if (tags.length > 0) {
await db
.prepare(
`UPDATE accounts
SET tags = CASE WHEN ? != '' THEN ? ELSE tags END
WHERE id = ?`,
)
.bind(tags.join(","), tags.join(","), row.account_id)
.run();
tagsUpdated = true;
}
}
if (
(details.redId || row.public_account_id) &&
Number(details.followers ?? row.followers ?? 0) > 0
@@ -410,7 +449,7 @@ export async function backfillAccountProfiles(
continue;
}
if (attempted >= Math.max(1, Math.min(25, limit))) {
if (publicProfileUpdated) updated += 1;
if (publicProfileUpdated || tagsUpdated) updated += 1;
continue;
}
}
@@ -430,7 +469,7 @@ export async function backfillAccountProfiles(
row.platform_uid?.startsWith("pending-") ||
Boolean(noteId && row.platform_uid === noteId));
if (!needsEnrichment || !row.publish_url) {
if (publicProfileUpdated) updated += 1;
if (publicProfileUpdated || tagsUpdated) updated += 1;
continue;
}
@@ -443,9 +482,9 @@ export async function backfillAccountProfiles(
row.nickname || "待识别账号",
mcpConfig,
);
if (result.updated || publicProfileUpdated) updated += 1;
if (result.updated || publicProfileUpdated || tagsUpdated) updated += 1;
} catch {
if (publicProfileUpdated) updated += 1;
if (publicProfileUpdated || tagsUpdated) updated += 1;
else failed += 1;
}
}

View File

@@ -0,0 +1,253 @@
import { runInBackground } from "./background";
import {
generateAccountTagsFromMcp,
resolveProfileDetailsFromMcp,
type AccountTaggingEnv,
type CollectionMcpConfig,
} from "./mcp-collection-client";
import { getRuntimeEnv } from "./runtime-env";
import type { DatabaseClient } from "./database";
export const ACCOUNT_REFRESH_FIELDS = [
"publicAccountId",
"nickname",
"followers",
"ipLocation",
"gender",
"bio",
"tags",
] as const;
export type AccountRefreshField = (typeof ACCOUNT_REFRESH_FIELDS)[number];
export const ACCOUNT_REFRESH_FIELD_LABELS: Record<AccountRefreshField, string> = {
publicAccountId: "账号ID(小红书号/抖音号)",
nickname: "昵称",
followers: "粉丝数",
ipLocation: "IP属地",
gender: "性别",
bio: "简介",
tags: "标签(AI 重新打标,覆盖已有标签)",
};
const FIELD_COLUMNS: Record<AccountRefreshField, string> = {
publicAccountId: "public_account_id",
nickname: "nickname",
followers: "followers",
ipLocation: "ip_location",
gender: "gender",
bio: "bio",
tags: "tags",
};
export function sanitizeRefreshFields(value: unknown): AccountRefreshField[] {
const requested = Array.isArray(value) ? value : [];
const fields = ACCOUNT_REFRESH_FIELDS.filter((field) =>
requested.includes(field),
);
return fields.length > 0 ? fields : ["publicAccountId"];
}
export type AccountFieldRefreshStatus = {
status: "running" | "done";
fields: AccountRefreshField[];
total: number;
processed: number;
updated: number;
failed: number;
skipped: number;
startedAt: string;
finishedAt: string | null;
};
type RefreshTargetRow = {
id: string;
platform: string;
platform_uid: string;
profile_url: string;
};
export type ScrapedProfile = {
nickname: string | null;
redId: string | null;
followers: number | null;
ipLocation: string | null;
gender: string;
bio: string;
recentNoteTitles: string[];
providerTags: string[];
};
export type ProfileScraper = (
profileUrl: string,
platform: "小红书" | "抖音",
) => Promise<ScrapedProfile>;
export type TagGenerator = (input: {
platform: string;
nickname: string;
bio: string;
gender: string;
ipLocation: string;
followers: number | null;
providerTags: string[];
recentNoteTitles: string[];
}) => Promise<string[]>;
function scrapedFieldValues(
details: ScrapedProfile,
): Partial<Record<AccountRefreshField, string | number>> {
const values: Partial<Record<AccountRefreshField, string | number>> = {};
const redId = details.redId?.trim();
if (redId) values.publicAccountId = redId;
const nickname = details.nickname?.trim();
if (nickname) values.nickname = nickname;
if (details.followers !== null && Number.isFinite(details.followers)) {
values.followers = details.followers;
}
const ipLocation = details.ipLocation?.trim();
if (ipLocation) values.ipLocation = ipLocation;
if (details.gender === "男" || details.gender === "女") {
values.gender = details.gender;
}
const bio = details.bio?.trim();
if (bio) values.bio = bio;
return values;
}
let currentJob: AccountFieldRefreshStatus | null = null;
export function getAccountFieldRefreshStatus(): AccountFieldRefreshStatus | null {
return currentJob ? { ...currentJob } : null;
}
export function startAccountFieldRefresh(
db: DatabaseClient,
config: CollectionMcpConfig,
requestedFields: unknown,
): AccountFieldRefreshStatus {
if (currentJob?.status === "running") {
return { ...currentJob };
}
const fields = sanitizeRefreshFields(requestedFields);
const job: AccountFieldRefreshStatus = {
status: "running",
fields,
total: 0,
processed: 0,
updated: 0,
failed: 0,
skipped: 0,
startedAt: new Date().toISOString(),
finishedAt: null,
};
currentJob = job;
const scraper: ProfileScraper = (profileUrl, platform) =>
resolveProfileDetailsFromMcp(profileUrl, platform, config);
const tagger: TagGenerator = (input) =>
generateAccountTagsFromMcp(
input,
config,
getRuntimeEnv() as AccountTaggingEnv,
);
runInBackground(
executeAccountFieldRefresh(job, db, scraper, tagger),
"account field refresh",
);
return { ...job };
}
async function executeAccountFieldRefresh(
job: AccountFieldRefreshStatus,
db: DatabaseClient,
scraper: ProfileScraper,
tagger: TagGenerator,
) {
const rows = await db
.prepare(
`SELECT id, platform, platform_uid, profile_url
FROM accounts
WHERE profile_url IS NOT NULL AND profile_url != ''
AND platform IN ('小红书', '抖音')`,
)
.all<RefreshTargetRow>();
const targets = rows.results.filter(
(row) => !row.platform_uid.startsWith("xhs-"),
);
job.total = targets.length;
const fields = job.fields;
const wantTags = fields.includes("tags");
let cursor = 0;
const worker = async () => {
while (cursor < targets.length) {
const row = targets[cursor];
cursor += 1;
try {
const details = await scraper(
row.profile_url,
row.platform === "抖音" ? "抖音" : "小红书",
);
const values = scrapedFieldValues(details);
if (wantTags) {
const tags = await tagger({
platform: row.platform,
nickname: details.nickname?.trim() || "",
bio: details.bio,
gender: details.gender,
ipLocation: details.ipLocation ?? "",
followers: details.followers,
providerTags: details.providerTags,
recentNoteTitles: details.recentNoteTitles,
}).catch(() => []);
if (tags.length > 0) values.tags = tags.join(",");
}
const updates = fields.filter(
(field) => values[field] !== undefined,
);
if (updates.length === 0) {
job.skipped += 1;
} else {
const assignments = updates
.map((field) => `${FIELD_COLUMNS[field]} = ?`)
.join(", ");
await db
.prepare(`UPDATE accounts SET ${assignments} WHERE id = ?`)
.bind(
...updates.map((field) => values[field]),
row.id,
)
.run();
job.updated += 1;
}
} catch {
job.failed += 1;
}
job.processed += 1;
}
};
await Promise.all(Array.from({ length: Math.min(4, targets.length) }, worker));
job.status = "done";
job.finishedAt = new Date().toISOString();
}
export async function runAccountFieldRefresh(
db: DatabaseClient,
scraper: ProfileScraper,
tagger: TagGenerator,
fields: AccountRefreshField[],
): Promise<AccountFieldRefreshStatus> {
const job: AccountFieldRefreshStatus = {
status: "running",
fields: sanitizeRefreshFields(fields),
total: 0,
processed: 0,
updated: 0,
failed: 0,
skipped: 0,
startedAt: new Date().toISOString(),
finishedAt: null,
};
await executeAccountFieldRefresh(job, db, scraper, tagger);
return job;
}

View File

@@ -1573,3 +1573,154 @@ export async function resolveXhsProfileDetailsFromMcp(
? lastError
: new Error("账号主页数据采集服务暂时不可用");
}
export type AccountTaggingInput = {
platform: string;
nickname: string;
bio: string;
gender: string;
ipLocation: string;
followers: number | null;
providerTags: string[];
recentNoteTitles: string[];
};
export type AccountTaggingEnv = {
AI_TAGGING_TOOL?: string;
};
export function buildAccountTagText(input: AccountTaggingInput): string {
const facts: string[] = [`平台:${input.platform.trim() || "小红书"}`];
if (input.nickname.trim()) facts.push(`昵称:${input.nickname.trim()}`);
if (input.bio.trim()) facts.push(`简介:${input.bio.trim()}`);
if (input.gender.trim()) facts.push(`性别:${input.gender.trim()}`);
if (input.ipLocation.trim()) {
facts.push(`IP属地:${input.ipLocation.trim()}`);
}
if (input.followers !== null && input.followers > 0) {
facts.push(`粉丝数:${input.followers}`);
}
if (input.providerTags.length > 0) {
facts.push(`平台标签:${input.providerTags.slice(0, 5).join("、")}`);
}
if (input.recentNoteTitles.length > 0) {
const titles = input.recentNoteTitles
.slice(0, 20)
.map((title) => `《${title}》`)
.join("");
facts.push(`最近笔记标题:${titles}`);
}
return facts.join("\n");
}
function sanitizeTagList(items: unknown[]): string[] {
const tags: string[] = [];
for (const item of items) {
if (typeof item !== "string") continue;
const tag = item.trim().replace(/^#+|#+$/g, "").trim();
if (!tag || tag.length > 12) continue;
if (!tags.includes(tag)) tags.push(tag);
}
return tags.slice(0, 5);
}
export function parseAccountTags(text: string): string[] {
const stripped = text
.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```\s*$/, "");
const candidates = [stripped];
const arrayMatch = stripped.match(/\[[\s\S]*\]/);
if (arrayMatch) candidates.push(arrayMatch[0]);
for (const candidate of candidates) {
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
continue;
}
if (!Array.isArray(parsed)) continue;
const tags = sanitizeTagList(parsed);
if (tags.length > 0) return tags;
}
return [];
}
function parseTagsFromToolPayload(payload: unknown, depth = 0): string[] {
if (typeof payload === "string") return parseAccountTags(payload);
if (Array.isArray(payload)) {
const direct = sanitizeTagList(payload);
if (direct.length > 0) return direct;
if (depth < 2) {
for (const item of payload) {
const tags = parseTagsFromToolPayload(item, depth + 1);
if (tags.length > 0) return tags;
}
}
return [];
}
const record = asRecord(payload);
if (record && depth < 2) {
for (const key of [
"tags",
"labels",
"result",
"data",
"response",
"output",
"text",
"content",
"message",
]) {
if (key in record) {
const tags = parseTagsFromToolPayload(record[key], depth + 1);
if (tags.length > 0) return tags;
}
}
}
return [];
}
export async function generateAccountTagsFromMcp(
input: AccountTaggingInput,
config: CollectionMcpConfig,
taggingEnv: AccountTaggingEnv,
fetchImpl: typeof fetch = fetch,
): Promise<string[]> {
const toolName = String(taggingEnv.AI_TAGGING_TOOL ?? "").trim();
if (!toolName || !config.endpoint.trim()) return [];
try {
const endpoint = buildMcpUrl(config);
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 60_000);
const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs);
const result = await invokeMcpTool(
fetchImpl,
endpoint,
sessionId,
timeoutMs,
toolName,
{
objective: "为KOC账号生成运营标签",
text: buildAccountTagText(input),
rules: [
"最多生成 5 个标签",
"每个标签不超过 8 个字",
"从内容领域、人设风格、受众画像等维度概括",
"只依据文本中给到的资料,不要编造",
],
output_schema: {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
},
required: ["tags"],
additionalProperties: false,
},
},
);
if (result.isError) return [];
return parseTagsFromToolPayload(result.payload);
} catch {
return [];
}
}

View File

@@ -366,6 +366,11 @@ export async function ensureSchema(database?: DatabaseClient) {
"last_collection_day",
"last_collection_day INTEGER",
);
await ensureColumn(
"partners",
"wecom_external_user_id",
"wecom_external_user_id TEXT",
);
const tasksWithoutShare = await db
.prepare(
@@ -867,6 +872,79 @@ export async function getDashboardData() {
};
}
export type TaskReportRow = {
id: string;
name: string;
task_type: string;
brand: string;
platform: string;
status: string;
due_at: string;
created_at: string;
quantity: number;
project_name: string | null;
claimed_count: number;
published_count: number;
completed_count: number;
exposure_total: number | null;
views_total: number | null;
d2_likes: number | null;
d2_comments: number | null;
d2_collects: number | null;
d5_likes: number | null;
d5_comments: number | null;
d5_collects: number | null;
d7_likes: number | null;
d7_comments: number | null;
d7_collects: number | null;
latest_likes: number | null;
latest_comments: number | null;
latest_collects: number | null;
latest_shares: number | null;
};
export async function getTaskReportData(db: DatabaseClient) {
const result = await db
.prepare(
`SELECT
t.id,
t.name,
t.task_type,
t.brand,
t.platform,
t.status,
t.due_at,
t.created_at,
t.quantity,
p.name AS project_name,
COUNT(d.id) AS claimed_count,
SUM(CASE WHEN TRIM(COALESCE(d.publish_url, '')) != '' THEN 1 ELSE 0 END) AS published_count,
SUM(CASE WHEN d.status = 'complete' THEN 1 ELSE 0 END) AS completed_count,
SUM(d.exposure) AS exposure_total,
SUM(d.views) AS views_total,
SUM(d.d2_likes) AS d2_likes,
SUM(d.d2_comments) AS d2_comments,
SUM(d.d2_collects) AS d2_collects,
SUM(d.d5_likes) AS d5_likes,
SUM(d.d5_comments) AS d5_comments,
SUM(d.d5_collects) AS d5_collects,
SUM(d.d7_likes) AS d7_likes,
SUM(d.d7_comments) AS d7_comments,
SUM(d.d7_collects) AS d7_collects,
SUM(d.latest_likes) AS latest_likes,
SUM(d.latest_comments) AS latest_comments,
SUM(d.latest_collects) AS latest_collects,
SUM(d.latest_shares) AS latest_shares
FROM tasks t
LEFT JOIN distributions d ON d.task_id = t.id
LEFT JOIN projects p ON p.id = t.project_id
GROUP BY t.id
ORDER BY t.created_at DESC`,
)
.all<TaskReportRow>();
return result.results;
}
export function uid(prefix: string) {
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
}

160
lib/project-push-service.ts Normal file
View File

@@ -0,0 +1,160 @@
import type { DatabaseClient } from "./database";
import { getRuntimeEnv } from "./runtime-env";
import {
sendWecomAppMessage,
WecomClientError,
type WecomConfig,
} from "./wecom-client";
import {
pushTaskToGroupChats,
type GroupPushResult,
type TaskPushRow,
} from "./wecom-group-push-service";
import { getProjectDetail, ProjectError } from "./project-service";
type FetchLike = typeof fetch;
export type AppMessageResult = {
userid: string;
ok: boolean;
error: string;
};
export type ProjectPushOutcome = {
appResults: AppMessageResult[];
groupResults: GroupPushResult[];
groupChatCount: number;
skippedReasons: string[];
linkUrl: string;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export function buildProjectAppMessage(
task: Pick<
TaskPushRow,
| "name"
| "brand"
| "quantity"
| "due_at"
| "task_type"
| "platform"
| "content_format"
| "share_token"
>,
portalUrl: string,
): string {
const portal = text(portalUrl).replace(/\/$/, "");
const claimUrl =
portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
const formatLabel =
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
return [
`【项目任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
`平台:${task.platform}|形式:${formatLabel}`,
"请把任务转发到你负责的项目任务群,并跟进群内成员领取与发布。",
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
].join("\n");
}
export async function pushProjectTask(
db: DatabaseClient,
params: {
taskId: unknown;
sendMainGroup?: unknown;
sendGroupChats?: unknown;
text?: unknown;
},
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<ProjectPushOutcome> {
const taskId = text(params.taskId);
if (!taskId) throw new ProjectError("缺少 taskId");
const sendMainGroup = params.sendMainGroup !== false;
const sendGroupChats = params.sendGroupChats !== false;
if (!sendMainGroup && !sendGroupChats) {
throw new ProjectError("请至少选择一个发送目标(项目总群或任务群)");
}
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 & { project_id: string }>();
if (!task) throw new ProjectError("任务不存在", 404);
if (!text(task.project_id)) {
throw new ProjectError("该任务未挂项目,请先在任务中心关联项目");
}
const detail = await getProjectDetail(db, task.project_id);
const portalUrl = text(getRuntimeEnv().KOC_PORTAL_URL);
const fallback = buildProjectAppMessage(task, portalUrl);
const message = text(params.text) || fallback;
const skippedReasons: string[] = [];
const appResults: AppMessageResult[] = [];
if (sendMainGroup) {
const members = detail.project.mainGroupMemberIds;
if (members.length === 0) {
skippedReasons.push("项目总群未配置成员");
} else {
for (const userid of members) {
try {
await sendWecomAppMessage([userid], message, config, fetchImpl);
appResults.push({ userid, ok: true, error: "" });
} catch (error) {
appResults.push({
userid,
ok: false,
error: error instanceof Error ? error.message : "发送失败",
});
}
}
}
}
let groupResults: GroupPushResult[] = [];
let groupChatCount = 0;
let linkUrl =
task.share_token && portalUrl
? `${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`
: "";
if (sendGroupChats) {
const chatIds = detail.groupChats
.filter((chat) => !chat.missing)
.map((chat) => chat.chatId);
if (chatIds.length === 0) {
skippedReasons.push("项目没有可用的任务群");
} else {
const summary = await pushTaskToGroupChats(
db,
{ taskId, chatIds, text: message },
config,
fetchImpl,
);
groupResults = summary.results;
groupChatCount = chatIds.length;
linkUrl = summary.linkUrl;
}
}
return {
appResults,
groupResults,
groupChatCount,
skippedReasons,
linkUrl,
};
}
export { WecomClientError };

385
lib/project-service.ts Normal file
View File

@@ -0,0 +1,385 @@
import type { DatabaseClient } from "./database";
export type ProjectRow = {
id: string;
name: string;
brand: string;
main_group_member_ids: string;
status: string;
note: string;
created_at: string;
};
export type ProjectSummary = {
id: string;
name: string;
brand: string;
status: string;
note: string;
createdAt: string;
mainGroupMemberIds: string[];
taskCount: number;
groupChatCount: number;
};
export type ProjectTaskRow = {
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
task_type: string;
platform: string;
content_format: string;
share_token: string | null;
};
export type ProjectGroupChat = {
chatId: string;
name: string;
ownerUserId: string;
memberCount: number;
status: number;
partnerId: string;
partnerName: string;
missing: boolean;
};
export type ProjectDetail = {
project: ProjectSummary;
tasks: ProjectTaskRow[];
groupChats: ProjectGroupChat[];
};
export type GroupOwnerPartnerRow = {
id: string;
name: string;
wecom_userid: string;
partner_type: string;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export class ProjectError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.status = status;
}
}
export function parseMemberIds(value: unknown): string[] {
const raw = Array.isArray(value)
? value.map((item) => text(item))
: text(value).split(/[\s,;,;]+/);
const seen = new Set<string>();
for (const item of raw) {
if (item) seen.add(item);
}
return [...seen];
}
function parseJsonArray(value: string): string[] {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
} catch {
return [];
}
}
export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]> {
const result = await db
.prepare(
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.status, p.note, p.created_at,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count,
(SELECT COUNT(*) FROM project_group_chats g WHERE g.project_id = p.id) AS group_chat_count
FROM projects p
ORDER BY p.created_at DESC`,
)
.all<ProjectRow & { task_count: number; group_chat_count: number }>();
return result.results.map((row) => ({
id: row.id,
name: row.name,
brand: row.brand,
status: row.status,
note: row.note,
createdAt: row.created_at,
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
taskCount: Number(row.task_count),
groupChatCount: Number(row.group_chat_count),
}));
}
export async function getProjectDetail(
db: DatabaseClient,
projectId: string,
): Promise<ProjectDetail> {
const id = text(projectId);
const project = await db
.prepare(
`SELECT id, name, brand, main_group_member_ids, status, note, created_at
FROM projects WHERE id = ?`,
)
.bind(id)
.first<ProjectRow>();
if (!project) throw new ProjectError("项目不存在", 404);
const tasks = await db
.prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, platform, content_format, share_token
FROM tasks WHERE project_id = ?
ORDER BY created_at DESC`,
)
.bind(id)
.all<ProjectTaskRow>();
const chats = await db
.prepare(
`SELECT g.chat_id, g.partner_id,
c.name AS chat_name, c.owner_user_id, c.member_count, c.status AS chat_status,
p.name AS partner_name
FROM project_group_chats g
LEFT JOIN wecom_group_chats c ON c.chat_id = g.chat_id
LEFT JOIN partners p ON p.id = g.partner_id
WHERE g.project_id = ?
ORDER BY g.created_at`,
)
.bind(id)
.all<{
chat_id: string;
partner_id: string;
chat_name: string | null;
owner_user_id: string | null;
member_count: number | null;
chat_status: number | null;
partner_name: string | null;
}>();
return {
project: {
id: project.id,
name: project.name,
brand: project.brand,
status: project.status,
note: project.note,
createdAt: project.created_at,
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
taskCount: tasks.results.length,
groupChatCount: chats.results.length,
},
tasks: tasks.results,
groupChats: chats.results.map((row) => ({
chatId: row.chat_id,
name: row.chat_name ?? "",
ownerUserId: row.owner_user_id ?? "",
memberCount: Number(row.member_count ?? 0),
status: Number(row.chat_status ?? 0),
partnerId: row.partner_id,
partnerName: row.partner_name ?? "",
missing: row.chat_name === null,
})),
};
}
export type SaveProjectInput = {
id?: unknown;
name?: unknown;
brand?: unknown;
mainGroupMemberIds?: unknown;
note?: unknown;
};
export async function saveProject(
db: DatabaseClient,
input: SaveProjectInput,
): Promise<ProjectSummary> {
const id = text(input.id);
const name = text(input.name);
const brand = text(input.brand);
const note = text(input.note ?? "");
const memberIds = parseMemberIds(input.mainGroupMemberIds);
if (!name) throw new ProjectError("请填写项目名称");
const now = new Date().toISOString();
let projectId = id;
if (id) {
const result = await db
.prepare(
`UPDATE projects
SET name = ?, brand = ?, main_group_member_ids = ?, note = ?
WHERE id = ?`,
)
.bind(name, brand, JSON.stringify(memberIds), note, id)
.run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
} else {
projectId = `proj-${crypto.randomUUID().slice(0, 12)}`;
await db
.prepare(
`INSERT INTO projects
(id, name, brand, main_group_member_ids, status, note, created_at)
VALUES (?, ?, ?, ?, 'active', ?, ?)`,
)
.bind(
projectId,
name,
brand,
JSON.stringify(memberIds),
note,
now,
)
.run();
}
const detail = await getProjectDetail(db, projectId);
return detail.project;
}
export async function deleteProject(
db: DatabaseClient,
projectId: string,
): Promise<void> {
const id = text(projectId);
if (!id) throw new ProjectError("缺少项目 ID");
const result = await db
.prepare("DELETE FROM projects WHERE id = ?")
.bind(id)
.run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
await db.batch([
db.prepare("DELETE FROM project_group_chats WHERE project_id = ?").bind(id),
db
.prepare("UPDATE tasks SET project_id = '' WHERE project_id = ?")
.bind(id),
]);
}
export type ProjectGroupChatInput = {
chatId: string;
partnerId?: string;
};
export async function setProjectGroupChats(
db: DatabaseClient,
projectId: string,
items: ProjectGroupChatInput[],
): Promise<ProjectDetail> {
const id = text(projectId);
await getProjectDetail(db, id);
const seen = new Set<string>();
const rows: ProjectGroupChatInput[] = [];
for (const item of Array.isArray(items) ? items : []) {
const chatId = text(item?.chatId);
if (!chatId || seen.has(chatId)) continue;
seen.add(chatId);
rows.push({ chatId, partnerId: text(item?.partnerId) });
}
const statements = [
db.prepare("DELETE FROM project_group_chats WHERE project_id = ?").bind(id),
...rows.map((row) =>
db
.prepare(
`INSERT INTO project_group_chats
(id, project_id, chat_id, partner_id, created_at)
VALUES (?, ?, ?, ?, ?)`,
)
.bind(
`pgc-${crypto.randomUUID().slice(0, 12)}`,
id,
row.chatId,
row.partnerId ?? "",
new Date().toISOString(),
),
),
];
await db.batch(statements);
return getProjectDetail(db, id);
}
export type ProjectPushTargets = {
project: { id: string; name: string; mainGroupMemberIds: string[] };
chatIds: string[];
};
export async function getProjectPushTargets(
db: DatabaseClient,
projectId: string,
): Promise<ProjectPushTargets> {
const detail = await getProjectDetail(db, projectId);
return {
project: {
id: detail.project.id,
name: detail.project.name,
mainGroupMemberIds: detail.project.mainGroupMemberIds,
},
chatIds: detail.groupChats.filter((chat) => !chat.missing).map((chat) => chat.chatId),
};
}
export async function listGroupOwnerPartners(
db: DatabaseClient,
): Promise<GroupOwnerPartnerRow[]> {
const result = await db
.prepare(
`SELECT id, name, wecom_userid, partner_type
FROM partners
WHERE partner_type = 'group_owner'
ORDER BY created_at DESC`,
)
.all<GroupOwnerPartnerRow>();
return result.results;
}
export type SavePartnerInput = {
id?: unknown;
name?: unknown;
wecomUserid?: unknown;
};
export async function saveGroupOwnerPartner(
db: DatabaseClient,
input: SavePartnerInput,
): Promise<GroupOwnerPartnerRow> {
const id = text(input.id);
const name = text(input.name);
const wecomUserid = text(input.wecomUserid);
if (!name) throw new ProjectError("请填写合作方名称");
if (!wecomUserid) throw new ProjectError("请填写企业微信 userid");
let partnerId = id;
if (id) {
const result = await db
.prepare(
`UPDATE partners
SET name = ?, wecom_userid = ?, partner_type = 'group_owner'
WHERE id = ?`,
)
.bind(name, wecomUserid, id)
.run();
if (result.meta.changes === 0) throw new ProjectError("合作方不存在", 404);
} else {
partnerId = `partner-go-${crypto.randomUUID().slice(0, 12)}`;
await db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total,
partner_type, wecom_userid, created_at)
VALUES (?, ?, '', '运营组', 0, 0, 'group_owner', ?, ?)`,
)
.bind(partnerId, name, wecomUserid, new Date().toISOString())
.run();
}
const row = await db
.prepare(
"SELECT id, name, wecom_userid, partner_type FROM partners WHERE id = ?",
)
.bind(partnerId)
.first<GroupOwnerPartnerRow>();
if (!row) throw new ProjectError("合作方保存失败", 500);
return row;
}

View File

@@ -279,6 +279,21 @@ export function resourceImportMissingFields(
return missing;
}
export function needsResourceEnrichment(
row: Pick<
ResourceImportRow,
| "nickname"
| "publicAccountId"
| "ipLocation"
| "followersResolved"
| "gender"
| "bio"
| "tags"
>,
) {
return resourceImportMissingFields(row).length > 0 || row.tags.length === 0;
}
function normalizeRows(rows: string[][]) {
const header = findHeader(rows);
if (!header) {
@@ -337,6 +352,59 @@ function normalizeRows(rows: string[][]) {
return result;
}
export type ManualResourceInput = {
profileUrl: string;
nickname?: string;
publicAccountId?: string;
ipLocation?: string;
followers?: string;
gender?: string;
bio?: string;
tags?: string | string[];
cooperationSource?: string;
};
export function buildManualRow(input: ManualResourceInput): ResourceImportRow {
const rawProfileUrl = (input.profileUrl ?? "").trim();
const profileUrl = normalizeProfileUrl(rawProfileUrl);
const platform = platformFromProfileUrl(profileUrl);
const parsedFollowers = parseResourceFollowers(input.followers ?? "");
const parsedGender = normalizeResourceGender(input.gender ?? "");
const tags = normalizeResourceTags(input.tags ?? "");
const ipLocation = (input.ipLocation ?? "").trim();
const errors: string[] = [];
if (!rawProfileUrl) errors.push("账号主页不能为空");
else if (!profileUrl) errors.push("账号主页链接格式不正确");
else if (!platform) errors.push("当前自动解析仅支持小红书或抖音账号主页");
if (!parsedFollowers.valid) {
errors.push("粉丝数格式不正确,请填写数字或如 1.3万、10+");
}
if (!parsedGender.valid) {
errors.push("性别格式不正确,请填写男、女或留空");
}
if (tags.length > 5) {
errors.push("单个账号最多填写 5 个标签,请用逗号分隔");
}
if (ipLocation && /^\d+$/.test(ipLocation)) {
errors.push("IP属地格式不正确,请填写省份、地区或国家名称");
}
return {
rowNumber: 1,
platform,
nickname: (input.nickname ?? "").trim(),
publicAccountId: (input.publicAccountId ?? "").trim(),
profileUrl,
ipLocation,
followers: parsedFollowers.value,
followersResolved: parsedFollowers.resolved,
gender: parsedGender.value,
bio: (input.bio ?? "").trim(),
tags: tags.slice(0, 5),
cooperationSource: (input.cooperationSource ?? "").trim(),
errors,
};
}
export function parseResourceImportFile(fileName: string, bytes: Uint8Array) {
const extension = fileName.toLocaleLowerCase().split(".").pop();
const workbooks =

450
lib/resource-write.ts Normal file
View File

@@ -0,0 +1,450 @@
import { ensureSchema, getRawDb } from "./mvp-db";
import {
generateAccountTagsFromMcp,
resolveCollectionMcpConfig,
resolveProfileDetailsFromMcp,
resolveXhsPublicAccountDetails,
type AccountTaggingEnv,
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 env = getRuntimeEnv() as unknown as CollectionMcpBindings;
const mcpConfig = resolveCollectionMcpConfig(env);
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,
};
});
}
type UntaggedAccountRow = {
id: string;
platform: string;
platform_uid: string;
profile_url: string;
nickname: string;
};
export async function tagUntaggedAccounts(
rows: Array<Pick<AnalyzedRow, "platform" | "platformUid">>,
) {
const env = getRuntimeEnv() as unknown as CollectionMcpBindings &
AccountTaggingEnv;
const toolName = String(env.AI_TAGGING_TOOL ?? "").trim();
const refs = new Set(
rows
.filter((row) => row.platformUid)
.map((row) => `${row.platform}:${row.platformUid}`),
);
if (!toolName || refs.size === 0) return;
const db = getRawDb();
const uids = [...new Set([...refs].map((key) => key.slice(key.indexOf(":") + 1)))];
const candidates: UntaggedAccountRow[] = [];
for (let index = 0; index < uids.length; index += 100) {
const chunk = uids.slice(index, index + 100);
const placeholders = chunk.map(() => "?").join(", ");
const result = await db
.prepare(
`SELECT id, platform, platform_uid, profile_url, nickname
FROM accounts
WHERE (tags = '' OR tags IS NULL)
AND profile_url IS NOT NULL AND profile_url != ''
AND platform_uid IN (${placeholders})`,
)
.bind(...chunk)
.all<UntaggedAccountRow>();
candidates.push(...result.results);
}
const targets = candidates.filter((account) =>
refs.has(`${account.platform}:${account.platform_uid}`),
);
if (targets.length === 0) return;
const mcpConfig = resolveCollectionMcpConfig(env);
await mapConcurrent(targets, 4, async (account) => {
const details = await resolveProfileDetailsFromMcp(
account.profile_url,
account.platform === "抖音" ? "抖音" : "小红书",
mcpConfig,
).catch(() => null);
if (
!details ||
(!details.bio &&
details.providerTags.length === 0 &&
details.recentNoteTitles.length === 0)
) {
return;
}
const tags = await generateAccountTagsFromMcp(
{
platform: account.platform,
nickname: details.nickname?.trim() || account.nickname || "",
bio: details.bio,
gender: details.gender,
ipLocation: details.ipLocation ?? "",
followers: details.followers,
providerTags: details.providerTags,
recentNoteTitles: details.recentNoteTitles,
},
mcpConfig,
{ AI_TAGGING_TOOL: toolName },
).catch(() => []);
if (tags.length === 0) return;
await db
.prepare(
`UPDATE accounts SET tags = ? WHERE id = ? AND (tags = '' OR tags IS NULL)`,
)
.bind(tags.join(","), account.id)
.run();
});
}
export function analyzeRows(
rows: ResourceImportRow[],
accountRows: AccountRow[],
) {
const profileMap = new Map<string, AccountRow>();
const publicIdMap = new Map<string, AccountRow>();
const platformUidMap = new Map<string, AccountRow>();
for (const account of accountRows) {
const profileUrl = normalizeProfileUrl(account.profile_url || "");
if (profileUrl) profileMap.set(identityKey(account.platform, profileUrl), account);
if (account.public_account_id) {
publicIdMap.set(identityKey(account.platform, account.public_account_id), account);
}
platformUidMap.set(identityKey(account.platform, account.platform_uid), account);
}
return rows.map<AnalyzedRow>((row) => {
const platformUid = resourcePlatformUid(row);
const profileMatch = row.profileUrl
? profileMap.get(identityKey(row.platform, row.profileUrl))
: undefined;
const publicIdMatch = row.publicAccountId
? publicIdMap.get(identityKey(row.platform, row.publicAccountId))
: undefined;
const uidMatch = platformUidMap.get(identityKey(row.platform, platformUid));
const matches = [profileMatch, publicIdMatch, uidMatch].filter(
(account): account is AccountRow => Boolean(account),
);
const matchedIds = [...new Set(matches.map((account) => account.id))];
const errors = [...row.errors];
if (matchedIds.length > 1) {
errors.push("账号主页和账号ID匹配到不同的现有账号,请先核对");
}
const existing = matchedIds.length === 1 ? matches[0] : undefined;
const accountId = existing?.id ?? `account-${crypto.randomUUID().slice(0, 12)}`;
const analyzed: AnalyzedRow = {
...row,
errors,
action: errors.length > 0 ? "error" : existing ? "update" : "create",
accountId,
platformUid: existing?.platform_uid ?? platformUid,
cooperationSource: mergeCooperationSources(
existing?.cooperation_source ?? "",
row.cooperationSource,
),
};
if (analyzed.action !== "error") {
const virtual: AccountRow = {
id: accountId,
platform: row.platform,
platform_uid: analyzed.platformUid,
public_account_id: row.publicAccountId || existing?.public_account_id || "",
nickname: row.nickname,
profile_url: row.profileUrl || existing?.profile_url || "",
ip_location: row.ipLocation || existing?.ip_location || "待识别",
followers: row.followers || existing?.followers || 0,
gender: row.gender || existing?.gender || "",
bio: row.bio || existing?.bio || "",
tags: (row.tags.length > 0
? row.tags
: (existing?.tags || "").split(/[,,、;;|]/).filter(Boolean)
).slice(0, 5).join(","),
cooperation_source: analyzed.cooperationSource,
};
if (row.profileUrl) profileMap.set(identityKey(row.platform, row.profileUrl), virtual);
if (row.publicAccountId) {
publicIdMap.set(identityKey(row.platform, row.publicAccountId), virtual);
}
platformUidMap.set(identityKey(row.platform, analyzed.platformUid), virtual);
}
return analyzed;
});
}
function statementForAnalyzedRow(
db: ReturnType<typeof getRawDb>,
row: AnalyzedRow,
) {
return row.action === "update"
? db
.prepare(
`UPDATE accounts SET
nickname = ?,
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
ip_location = CASE
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
gender = CASE WHEN ? != '' THEN ? ELSE gender END,
bio = CASE WHEN ? != '' THEN ? ELSE bio END,
tags = CASE WHEN ? != '' THEN ? ELSE tags END,
cooperation_source = ?,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
row.nickname || "待识别账号",
row.publicAccountId,
row.publicAccountId,
row.profileUrl,
row.profileUrl,
row.ipLocation,
row.ipLocation,
row.ipLocation,
row.followersResolved ? 1 : 0,
row.followers,
row.gender,
row.gender,
row.bio,
row.bio,
row.tags.join(","),
row.tags.join(","),
row.cooperationSource,
row.accountId,
)
: db
.prepare(
`INSERT INTO accounts
(id, platform, platform_uid, public_account_id, nickname,
profile_url, ip_location, followers, post_count, avg_views,
gender, bio, tags, cooperation_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?)`,
)
.bind(
row.accountId,
row.platform,
row.platformUid,
row.publicAccountId,
row.nickname || "待识别账号",
row.profileUrl,
row.ipLocation || "待识别",
row.followers,
row.gender,
row.bio,
row.tags.join(","),
row.cooperationSource,
);
}
export async function writeAnalyzedRows(rows: AnalyzedRow[]) {
const db = getRawDb();
const statements = rows
.filter((row) => row.action !== "error")
.map((row) => statementForAnalyzedRow(db, row));
for (let index = 0; index < statements.length; index += RESOURCE_IMPORT_DB_BATCH_SIZE) {
await db.batch(statements.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE));
}
}

View File

@@ -13,19 +13,23 @@ export type RuntimeEnv = {
ADMIN_INTERNAL_TOKEN?: string;
KOC_MCP_API_KEY?: string;
KOC_LOOP_MCP_API_KEY?: string;
MCP_API_KEY?: string;
FEISHU_APP_ID?: string;
FEISHU_APP_SECRET?: string;
AI_TOOL_CENTER_MCP_URL?: string;
AI_TOOL_CENTER_MCP_KEY?: string;
COLLECTION_MCP_URL?: string;
COLLECTION_MCP_KEY?: string;
AI_TAGGING_TOOL?: 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;
WECOM_CALLBACK_TOKEN?: string;
WECOM_CALLBACK_ENCODING_AES_KEY?: string;
WECOM_CALLBACK_ENABLED?: string;
WECOM_FEEDBACK_ENABLED?: string;
WECOM_ESCALATION_ENABLED?: string;
WECOM_AUTO_RELEASE_DAYS?: string;
SEED_DEMO_DATA?: string;
ENABLE_SCHEDULER?: string;
};

View File

@@ -12,7 +12,15 @@ import {
resolveWecomConfig,
type WecomBindings,
} from "./wecom-client";
import { runDueSoonWecomNotifications } from "./wecom-notifier-service";
import {
runDueSoonWecomNotifications,
runOverdueAutoRelease,
runOverdueEscalation,
} from "./wecom-notifier-service";
import {
isWecomFeedbackEnabled,
notifyCollectionFinished,
} from "./wecom-feedback-service";
declare global {
var __kocLoopScheduler: ScheduledTask | undefined;
@@ -28,25 +36,49 @@ async function runDailyJob() {
);
const collections = await runScheduledCollections(db, Date.now(), config);
const accounts = await backfillAccountProfiles(db, config, 10);
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
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);
}
let escalation: Awaited<ReturnType<typeof runOverdueEscalation>> | null =
null;
let autoRelease: Awaited<ReturnType<typeof runOverdueAutoRelease>> | null =
null;
if (
isEnabled(env.WECOM_NOTIFY_ENABLED, true) &&
(wecomConfig.robotWebhook ||
(wecomConfig.corpId && wecomConfig.agentId && wecomConfig.secret))
) {
try {
wecom = await runDueSoonWecomNotifications(db, wecomConfig);
} catch (error) {
console.error("[KOC LOOP] wecom notify failed", error);
}
}
if (isEnabled(env.WECOM_ESCALATION_ENABLED, false)) {
try {
escalation = await runOverdueEscalation(db, wecomConfig);
} catch (error) {
console.error("[KOC LOOP] wecom overdue escalation failed", error);
}
}
try {
autoRelease = await runOverdueAutoRelease(db, wecomConfig);
} catch (error) {
console.error("[KOC LOOP] wecom overdue auto release failed", error);
}
if (isWecomFeedbackEnabled()) {
try {
await notifyCollectionFinished(collections, wecomConfig);
} catch (error) {
console.error("[KOC LOOP] wecom collection feedback failed", error);
}
}
console.info("[KOC LOOP] daily scheduler completed", {
collections,
accounts,
wecom,
escalation,
autoRelease,
});
});
}

View File

@@ -0,0 +1,355 @@
import type { DatabaseClient } from "./database";
import { getRuntimeEnv } from "./runtime-env";
import {
sendWecomAppMessage,
type WecomConfig,
} from "./wecom-client";
import { pushProjectTask, type ProjectPushOutcome } from "./project-push-service";
import type { TaskPushRow } from "./wecom-group-push-service";
type FetchLike = typeof fetch;
export class OneClickPushError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "OneClickPushError";
this.status = status;
}
}
export type BoundWecomPartner = {
id: string;
name: string;
wecomExternalUserId: string;
};
export type KocPushResult = {
partnerId: string;
partnerName: string;
externalUserId: string;
ok: boolean;
error: string;
};
export type TaskPushLogRow = {
id: string;
task_id: string;
project_id: string;
push_kind: string;
main_group_count: number;
koc_userids: string;
koc_ok_count: number;
koc_fail_list: string;
group_push_id: string;
detail: string;
created_at: string;
};
export type OneClickPushOutcome = {
linkUrl: string;
projectOutcome: ProjectPushOutcome | null;
kocResults: KocPushResult[];
kocSent: number;
kocFailed: number;
skippedReasons: string[];
logId: string;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export function buildKocDirectMessage(
task: Pick<
TaskPushRow,
| "name"
| "brand"
| "quantity"
| "due_at"
| "task_type"
| "platform"
| "content_format"
| "share_token"
>,
portalUrl: string,
): string {
const portal = text(portalUrl).replace(/\/$/, "");
const claimUrl =
portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
const formatLabel =
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
return [
`【新任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
`平台:${task.platform}|形式:${formatLabel}`,
"数量有限,先到先得,点击链接直接领取(企微内打开免输身份):",
claimUrl || "领取链接生成失败,请联系管理员",
].join("\n");
}
export async function listBoundWecomPartners(
db: DatabaseClient,
): Promise<BoundWecomPartner[]> {
const result = await db
.prepare(
`SELECT id, name, wecom_external_user_id
FROM partners
WHERE wecom_external_user_id IS NOT NULL
AND wecom_external_user_id != ''
ORDER BY name ASC`,
)
.all<{
id: string;
name: string;
wecom_external_user_id: string;
}>();
return result.results.map((row) => ({
id: row.id,
name: row.name,
wecomExternalUserId: row.wecom_external_user_id,
}));
}
export async function findTodayTaskPushLog(
db: DatabaseClient,
taskId: string,
pushKind = "task_open",
): Promise<TaskPushLogRow | null> {
const result = await db
.prepare(
`SELECT id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id,
detail, created_at
FROM wecom_push_logs
WHERE task_id = ? AND push_kind = ?
AND DATE(created_at) = CURDATE()
ORDER BY created_at DESC
LIMIT 1`,
)
.bind(taskId, pushKind)
.first<TaskPushLogRow>();
return result ?? null;
}
export async function listTaskPushLogs(
db: DatabaseClient,
taskId: string,
): Promise<TaskPushLogRow[]> {
const filter = taskId ? "WHERE task_id = ?" : "";
const statement = db
.prepare(
`SELECT id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id,
detail, created_at
FROM wecom_push_logs
${filter}
ORDER BY created_at DESC
LIMIT 50`,
)
.bind(...(taskId ? [taskId] : []));
const result = await statement.all<TaskPushLogRow>();
return result.results;
}
export async function recordCardAck(
db: DatabaseClient,
params: { cardTaskId: string; btnKey: string; operator: string },
) {
const taskId = /^task:([^:]+):/.exec(params.cardTaskId)?.[1] ?? "";
await db
.prepare(
`INSERT INTO wecom_push_logs
(id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
`wpl-${crypto.randomUUID().slice(0, 12)}`,
taskId || params.cardTaskId,
"",
"card_ack",
0,
"[]",
0,
"[]",
"",
JSON.stringify({
cardTaskId: params.cardTaskId,
btnKey: params.btnKey,
operator: params.operator,
}),
)
.run();
}
export async function oneClickPushTask(
db: DatabaseClient,
params: {
taskId: unknown;
sendMainGroup?: unknown;
sendKoc?: unknown;
createGroupTemplate?: unknown;
selectedKocPartnerIds?: unknown;
force?: unknown;
text?: unknown;
},
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<OneClickPushOutcome> {
const taskId = text(params.taskId);
if (!taskId) throw new OneClickPushError("缺少 taskId");
const sendMainGroup = params.sendMainGroup !== false;
const sendKoc = params.sendKoc !== false;
const createGroupTemplate = params.createGroupTemplate === true;
if (!sendMainGroup && !sendKoc && !createGroupTemplate) {
throw new OneClickPushError("请至少选择一个推送目标");
}
const task = await db
.prepare(
`SELECT id, name, brand, quantity, due_at, task_type, platform,
content_format, share_token, project_id
FROM tasks WHERE id = ?`,
)
.bind(taskId)
.first<TaskPushRow & { project_id: string }>();
if (!task) throw new OneClickPushError("任务不存在", 404);
if (!text(task.share_token)) {
throw new OneClickPushError("任务尚未生成领取链接,请先开放领取", 400);
}
if (params.force !== true) {
const existing = await findTodayTaskPushLog(db, taskId, "task_open");
if (existing) {
throw new OneClickPushError(
"今天已推送过该任务;如确需再次推送,请勾选「仍要推送」",
409,
);
}
}
const skippedReasons: string[] = [];
const wantsProjectTargets = sendMainGroup || createGroupTemplate;
let projectOutcome: ProjectPushOutcome | null = null;
if (wantsProjectTargets) {
if (!text(task.project_id)) {
skippedReasons.push("任务未挂项目,已跳过项目总群与任务群");
} else {
projectOutcome = await pushProjectTask(
db,
{
taskId,
sendMainGroup,
sendGroupChats: createGroupTemplate,
text: params.text,
},
config,
fetchImpl,
);
skippedReasons.push(...projectOutcome.skippedReasons);
}
}
const portalUrl = text(getRuntimeEnv().KOC_PORTAL_URL);
const linkUrl = task.share_token
? `${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`
: "";
const kocResults: KocPushResult[] = [];
if (sendKoc) {
const bound = await listBoundWecomPartners(db);
const selected = Array.isArray(params.selectedKocPartnerIds)
? params.selectedKocPartnerIds.map((id) => text(id)).filter(Boolean)
: null;
const targets = selected
? bound.filter((partner) => selected.includes(partner.id))
: bound;
if (targets.length === 0) {
skippedReasons.push("没有可直达的合作方(未勾选或均未绑定企微身份)");
} else {
const message = buildKocDirectMessage(task, portalUrl);
for (const partner of targets) {
try {
const sent = await sendWecomAppMessage(
[partner.wecomExternalUserId],
message,
config,
fetchImpl,
);
const failed = sent.sent === 0;
kocResults.push({
partnerId: partner.id,
partnerName: partner.name,
externalUserId: partner.wecomExternalUserId,
ok: !failed,
error: failed ? "企业微信未送达(invaliduser)" : "",
});
} catch (error) {
kocResults.push({
partnerId: partner.id,
partnerName: partner.name,
externalUserId: partner.wecomExternalUserId,
ok: false,
error: error instanceof Error ? error.message : "发送失败",
});
}
}
}
}
const kocSent = kocResults.filter((item) => item.ok).length;
const kocFailed = kocResults.length - kocSent;
const logId = `wpl-${crypto.randomUUID().slice(0, 12)}`;
const groupPushId =
projectOutcome?.groupResults.map((item) => item.msgid).join(",") ?? "";
await db
.prepare(
`INSERT INTO wecom_push_logs
(id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
logId,
taskId,
text(task.project_id),
"task_open",
projectOutcome ? projectOutcome.appResults.length : 0,
JSON.stringify(
kocResults.map((item) => ({
partnerId: item.partnerId,
name: item.partnerName,
externalUserId: item.externalUserId,
ok: item.ok,
error: item.error,
})),
),
kocSent,
JSON.stringify(kocResults.filter((item) => !item.ok)),
groupPushId,
JSON.stringify({
skippedReasons,
createGroupTemplate,
linkUrl,
}),
)
.run();
return {
linkUrl,
projectOutcome,
kocResults,
kocSent,
kocFailed,
skippedReasons,
logId,
};
}

94
lib/task-report-csv.ts Normal file
View File

@@ -0,0 +1,94 @@
import type { TaskReportRow } from "./mvp-db";
const CSV_HEADERS = [
"任务ID",
"任务名称",
"任务类型",
"品牌",
"平台",
"所属项目",
"任务状态",
"计划数量",
"领取数",
"发布数",
"完成数",
"完成率",
"截止日期",
"创建时间",
"曝光量合计",
"阅读量合计",
"D2点赞",
"D2评论",
"D2收藏",
"D5点赞",
"D5评论",
"D5收藏",
"D7点赞",
"D7评论",
"D7收藏",
"最新点赞",
"最新评论",
"最新收藏",
"最新转发",
];
function taskTypeLabel(value: string) {
return value === "screenshot_collect" ? "截图采集" : "内容发布";
}
function taskStatusLabel(value: string) {
return value === "active" ? "进行中" : value === "completed" ? "已完结" : value;
}
function completionRate(row: TaskReportRow) {
if (!row.claimed_count) return "";
return `${Math.round((row.completed_count / row.claimed_count) * 100)}%`;
}
function csvCell(value: string | number | null | undefined) {
const text = value === null || value === undefined ? "" : String(value);
if (/[",\r\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`;
return text;
}
export function buildTaskReportCsv(rows: TaskReportRow[]) {
const lines = [CSV_HEADERS.join(",")];
for (const row of rows) {
lines.push(
[
row.id,
row.name,
taskTypeLabel(row.task_type),
row.brand,
row.platform,
row.project_name ?? "",
taskStatusLabel(row.status),
row.quantity,
row.claimed_count,
row.published_count,
row.completed_count,
completionRate(row),
row.due_at,
row.created_at,
row.exposure_total,
row.views_total,
row.d2_likes,
row.d2_comments,
row.d2_collects,
row.d5_likes,
row.d5_comments,
row.d5_collects,
row.d7_likes,
row.d7_comments,
row.d7_collects,
row.latest_likes,
row.latest_comments,
row.latest_collects,
row.latest_shares,
]
.map(csvCell)
.join(","),
);
}
return `\uFEFF${lines.join("\r\n")}`;
}

View File

@@ -12,6 +12,7 @@ export type CreateDistributionTaskInput = {
dueAt: string;
platform?: "小红书" | "抖音";
contentFormat?: "image_text" | "video";
projectId?: string;
};
export type CreateScreenshotTaskInput = {
@@ -22,6 +23,7 @@ export type CreateScreenshotTaskInput = {
instructions: string;
quantity: number;
exampleImageKey?: string;
projectId?: string;
};
export type DistributionTaskCreation = {
@@ -140,10 +142,10 @@ async function insertTaskFromSource(
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
platform, content_format,
platform, content_format, project_id,
source_url, source_sheet_id, source_sheet_name, source_synced_at,
share_token)
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
taskId,
@@ -153,6 +155,7 @@ async function insertTaskFromSource(
input.dueAt,
input.platform ?? "小红书",
input.contentFormat ?? "image_text",
input.projectId,
source.url,
source.sheetId,
source.sheetName,
@@ -220,6 +223,7 @@ export async function createDistributionTask(
dueAt: normalizedDueDate(rawInput.dueAt),
platform: rawInput.platform === "抖音" ? "抖音" : "小红书",
contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text",
projectId: normalizedValue(rawInput.projectId ?? ""),
};
if (!input.feishuUrl || !input.name || !input.brand) {
throw new Error("请补全飞书链接、任务名称和品牌/项目");
@@ -281,6 +285,7 @@ export async function createScreenshotTask(
instructions: normalizedValue(rawInput.instructions),
quantity: Math.floor(Number(rawInput.quantity)),
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
projectId: normalizedValue(rawInput.projectId ?? ""),
};
if (!input.name || !input.brand || !input.keyword || !input.instructions) {
throw new Error("请补全任务名称、品牌/项目、搜索关键词和任务说明");
@@ -304,9 +309,9 @@ export async function createScreenshotTask(
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, source_url, source_sheet_id, source_sheet_name,
task_type, project_id, source_url, source_sheet_id, source_sheet_name,
share_token, collection_days)
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', '', '', '', ?, '[]')`,
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, '', '', '', ?, '[]')`,
)
.bind(
taskId,
@@ -314,6 +319,7 @@ export async function createScreenshotTask(
input.brand,
input.quantity,
input.dueAt,
input.projectId,
shareToken,
)
.run();

View File

@@ -0,0 +1,97 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
timingSafeEqual,
} from "node:crypto";
export class WecomCallbackCryptoError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "WecomCallbackCryptoError";
this.status = status;
}
}
export function resolveWecomCallbackAesKey(encodingAesKey: string): Buffer {
const key = String(encodingAesKey ?? "").trim();
if (!/^[A-Za-z0-9]{43}$/.test(key)) {
throw new WecomCallbackCryptoError(
"企业微信回调 EncodingAESKey 非法(需 43 位字母数字)",
500,
);
}
return Buffer.from(`${key}=`, "base64");
}
export function verifyWecomCallbackSignature(
token: string,
timestamp: string,
nonce: string,
encrypt: string,
): string {
return createHash("sha1")
.update([token, timestamp, nonce, encrypt].sort().join(""))
.digest("hex");
}
export function wecomSignaturesMatch(
expected: string,
received: string,
): boolean {
const a = Buffer.from(String(expected ?? ""));
const b = Buffer.from(String(received ?? ""));
if (a.length !== b.length || a.length === 0) return false;
return timingSafeEqual(a, b);
}
export function encryptWecomCallback(
plainMessage: string,
receiveId: string,
encodingAesKey: string,
): string {
const key = resolveWecomCallbackAesKey(encodingAesKey);
const random = randomBytes(16);
const message = Buffer.from(String(plainMessage ?? ""), "utf8");
const receive = Buffer.from(String(receiveId ?? ""), "utf8");
const length = Buffer.alloc(4);
length.writeUInt32BE(message.length, 0);
const cipher = createCipheriv("aes-256-cbc", key, key.subarray(0, 16));
const encrypted = Buffer.concat([
cipher.update(Buffer.concat([random, length, message, receive])),
cipher.final(),
]);
return encrypted.toString("base64");
}
export function decryptWecomCallback(
encrypted: string,
encodingAesKey: string,
): { message: string; receiveId: string } {
const key = resolveWecomCallbackAesKey(encodingAesKey);
const data = Buffer.from(String(encrypted ?? "").trim(), "base64");
if (data.length === 0 || data.length % 16 !== 0) {
throw new WecomCallbackCryptoError("企业微信回调密文长度非法");
}
let raw: Buffer;
try {
const decipher = createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
raw = Buffer.concat([decipher.update(data), decipher.final()]);
} catch {
throw new WecomCallbackCryptoError("企业微信回调密文解密失败");
}
if (raw.length < 20) {
throw new WecomCallbackCryptoError("企业微信回调明文长度非法");
}
const messageLength = raw.readUInt32BE(16);
if (messageLength <= 0 || 20 + messageLength > raw.length) {
throw new WecomCallbackCryptoError("企业微信回调消息长度字段非法");
}
return {
message: raw.subarray(20, 20 + messageLength).toString("utf8"),
receiveId: raw.subarray(20 + messageLength).toString("utf8"),
};
}

View File

@@ -0,0 +1,56 @@
import type { DatabaseClient } from "./database";
import { recordCardAck } from "./task-one-click-push-service";
export type WecomCallbackFields = Record<string, string>;
export type WecomCallbackEventResult = {
recognized: boolean;
action: string;
taskId: string;
btnKey: string;
operator: string;
};
export function parseWecomCallbackXml(xml: string): WecomCallbackFields {
const fields: WecomCallbackFields = {};
const source = String(xml ?? "");
const pattern =
/<([A-Za-z][A-Za-z0-9_]*)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
const tag = match[1];
const value = match[2] ?? match[3] ?? "";
if (!(tag in fields)) fields[tag] = value.trim();
}
return fields;
}
export function extractWecomEncryptField(body: string): string {
const fields = parseWecomCallbackXml(body);
return fields.Encrypt ?? "";
}
export async function handleWecomCallbackEvent(
db: DatabaseClient,
fields: WecomCallbackFields,
): Promise<WecomCallbackEventResult> {
const event = fields.Event ?? "";
const taskId = fields.TaskId ?? "";
const btnKey = fields.BtnKey ?? "";
const operator = fields.FromUserName ?? "";
if (fields.MsgType === "event" && event === "template_card_event") {
if (btnKey === "mark_handled") {
await recordCardAck(db, { cardTaskId: taskId, btnKey, operator });
console.info("[KOC LOOP] wecom card ack recorded", { taskId, operator });
return { recognized: true, action: "card_ack", taskId, btnKey, operator };
}
return { recognized: true, action: "ignored", taskId, btnKey, operator };
}
console.info("[KOC LOOP] wecom callback event ignored", {
msgType: fields.MsgType ?? "",
event,
});
return { recognized: false, action: "ignored", taskId, btnKey, operator };
}

View File

@@ -25,6 +25,32 @@ type WecomEnvelope = {
access_token?: string;
expires_in?: number;
invaliduser?: string;
userid?: string;
follow_user?: Array<{ userid: string }>;
external_userid?: string[];
external_contact?: {
external_userid?: string;
name?: string;
avatar?: string;
corp_fullname?: string;
};
next_cursor?: string;
group_chat_list?: Array<{ chat_id?: string; status?: number }>;
group_chat?: {
name?: string;
owner?: string;
member_count?: number;
};
fail_list?: string[];
msgid?: string;
};
export type WecomExternalContact = {
externalUserId: string;
name: string;
avatar: string;
corpName: string;
ownerUserId: string;
};
type CachedAccessToken = {
@@ -217,3 +243,395 @@ export async function sendWecomAppMessage(
export function clearWecomAccessTokenCacheForTests() {
cachedAccessToken = null;
}
export type WecomTemplateCardButton = {
key: string;
name: string;
url?: string;
};
export async function sendWecomTemplateCard(
config: WecomConfig,
params: {
touser: string[];
taskId: string;
title: string;
description: string;
url: string;
buttons: WecomTemplateCardButton[];
},
fetchImpl: FetchLike = fetch,
): Promise<{ sent: number; failed: number; skipped: boolean }> {
const normalized = params.touser
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (normalized.length === 0) {
return { sent: 0, failed: 0, skipped: true };
}
if (!hasAppCredentials(config)) {
return { sent: 0, failed: 0, skipped: true };
}
const taskId = bindingValue(params.taskId);
if (!taskId) {
throw new WecomClientError("模板卡片缺少 task_id(回调识别用)", 400);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
touser: normalized.join("|"),
msgtype: "template_card",
agentid: Number(config.agentId),
template_card: {
card_type: "text_notice",
source: {
desc: "KOC LOOP",
desc_color: 0,
},
main_title: {
title: safeMessage(params.title),
desc: safeMessage(params.description).slice(0, 120),
},
card_action: {
type: 1,
url: bindingValue(params.url),
},
button_list: params.buttons.map((button) => ({
type: button.url ? 1 : 0,
text: safeMessage(button.name),
key: bindingValue(button.key),
...(button.url ? { url: button.url } : {}),
})),
task_id: taskId,
},
}),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "企业微信模板卡片推送失败");
if (!response.ok) {
throw new WecomClientError(
`企业微信模板卡片推送失败(HTTP ${response.status})`,
502,
);
}
ensureOk(payload, "企业微信模板卡片推送失败");
const invalid = bindingValue(payload.invaliduser).split("|").filter(Boolean);
return {
sent: Math.max(0, normalized.length - invalid.length),
failed: invalid.length,
skipped: false,
};
}
export type WecomGroupChat = {
chatId: string;
name: string;
ownerUserId: string;
memberCount: number;
status: number;
};
export async function listCustomerGroupChats(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<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;
}
export function buildWecomOAuthUrl(params: {
corpId: string;
agentId: string;
redirectUri: string;
state: string;
}): string {
const query = new URLSearchParams({
appid: params.corpId,
redirect_uri: params.redirectUri,
response_type: "code",
scope: "snsapi_base",
state: params.state,
agentid: params.agentId,
});
return `https://open.weixin.qq.com/connect/oauth2/authorize?${query.toString()}#wechat_redirect`;
}
export type WecomOAuthIdentity = {
userId?: string;
externalUserId?: string;
};
export async function exchangeWecomOAuthCode(
config: WecomConfig,
code: string,
fetchImpl: FetchLike = fetch,
): Promise<WecomOAuthIdentity> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const authorizationCode = bindingValue(code);
if (!authorizationCode) {
throw new WecomClientError("缺少企业微信授权 code", 400);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/auth/getuserinfo?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ code: authorizationCode }),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "企业微信授权身份换取失败");
if (!response.ok) {
throw new WecomClientError(
`企业微信授权身份换取失败(HTTP ${response.status})`,
502,
);
}
if (Number(payload.errcode ?? 0) === 40029) {
throw new WecomClientError("企业微信授权 code 无效或已过期", 400);
}
ensureOk(payload, "企业微信授权身份换取失败");
const userId = bindingValue(payload.userid);
const externalUserId =
typeof payload.external_userid === "string"
? bindingValue(payload.external_userid)
: "";
if (!userId && !externalUserId) {
throw new WecomClientError("企业微信未返回有效身份", 400);
}
return {
...(userId ? { userId } : {}),
...(externalUserId ? { externalUserId } : {}),
};
}
export async function fetchWecomExternalContactName(
config: WecomConfig,
externalUserId: string,
fetchImpl: FetchLike = fetch,
): Promise<string | null> {
if (!hasAppCredentials(config)) return null;
const externalId = bindingValue(externalUserId);
if (!externalId) return null;
try {
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get?access_token=${encodeURIComponent(token)}&external_userid=${encodeURIComponent(externalId)}`;
const response = await fetchImpl(url, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "获取外部联系人详情失败");
if (!response.ok) return null;
if (Number(payload.errcode ?? 0) !== 0) return null;
const name = bindingValue(payload.external_contact?.name);
return name || null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,107 @@
import type { DatabaseClient } from "./database";
import { getRuntimeEnv, isEnabled } from "./runtime-env";
import { sendWecomRobotMessage, type WecomConfig } from "./wecom-client";
type FetchLike = typeof fetch;
export function isWecomFeedbackEnabled() {
return isEnabled(getRuntimeEnv().WECOM_FEEDBACK_ENABLED, false);
}
type SubmittedRow = {
claimant_name: string | null;
partner_name: string | null;
task_name: string;
content_title: string | null;
publish_url: string | null;
};
function truncate(value: string, max: number) {
return value.length > max ? `${value.slice(0, max)}…` : value;
}
export function buildPublishSubmittedMessage(
row: SubmittedRow,
kind: "publish" | "screenshot",
): string {
const partner = row.partner_name || row.claimant_name || "未知合作方";
if (kind === "screenshot") {
return [
"【KOC 已提交截图】",
`合作方:${partner}`,
`任务:${truncate(row.task_name, 24)}`,
`内容:${truncate(row.content_title ?? "", 24)}`,
"请到后台「数据回收」页查看与审核。",
].join("\n");
}
return [
"【KOC 已提交发布】",
`合作方:${partner}`,
`任务:${truncate(row.task_name, 24)}`,
`内容:${truncate(row.content_title ?? "", 24)}`,
`链接:${row.publish_url ?? ""}`,
"链接已入库,采集计划将按日自动执行。",
].join("\n");
}
export async function notifyPartnerSubmitted(
db: DatabaseClient,
params: { distributionId: string; kind: "publish" | "screenshot" },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<boolean> {
if (!config.robotWebhook) return false;
const distributionId = String(params.distributionId ?? "").trim();
if (!distributionId) return false;
const row = await db
.prepare(
`SELECT d.claimant_name, d.publish_url, t.name AS task_name,
c.title AS content_title, p.name AS partner_name
FROM distributions d
JOIN tasks t ON t.id = d.task_id
LEFT JOIN contents c ON c.id = d.content_id
LEFT JOIN partners p ON p.id = d.partner_id
WHERE d.id = ?`,
)
.bind(distributionId)
.first<SubmittedRow>();
if (!row) return false;
const message = buildPublishSubmittedMessage(row, params.kind);
await sendWecomRobotMessage(message, config, fetchImpl);
return true;
}
export function buildCollectionFinishedMessage(summary: {
currentDate: string;
dueTasks: number;
attempted: number;
succeeded: number;
failed: number;
}): string {
const lines = [
`【数据采集日报】${summary.currentDate}`,
`应采集任务 ${summary.dueTasks} 个,执行 ${summary.attempted} 条:成功 ${summary.succeeded}、失败 ${summary.failed}。`,
];
if (summary.failed > 0) {
lines.push("有采集失败,请到后台「数据回收」页查看并重试。");
}
return lines.join("\n");
}
export async function notifyCollectionFinished(
summary: {
currentDate: string;
dueTasks: number;
attempted: number;
succeeded: number;
failed: number;
},
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<boolean> {
if (!config.robotWebhook) return false;
if (summary.attempted <= 0 && summary.failed <= 0) return false;
const message = buildCollectionFinishedMessage(summary);
await sendWecomRobotMessage(message, config, fetchImpl);
return true;
}

View File

@@ -0,0 +1,326 @@
import type { DatabaseClient } from "./database";
import { getRuntimeEnv } from "./runtime-env";
import {
createGroupMsgTemplate,
listCustomerGroupChats,
WecomClientError,
type WecomConfig,
} from "./wecom-client";
type FetchLike = typeof fetch;
export const WECOM_GROUP_BATCH_LIMIT = 2000;
const TEXT_MAX_BYTES = 4000;
const LINK_TITLE_MAX_BYTES = 128;
const LINK_DESC_MAX_BYTES = 512;
const LINK_URL_MAX_BYTES = 2048;
export type TaskPushRow = {
id: string;
name: string;
brand: string;
quantity: number;
due_at: string;
task_type: string;
platform: string;
content_format: string;
share_token: string | null;
};
export type TaskPushMessage = {
text: string;
link: { title: string; desc: string; url: string } | null;
};
export type GroupChatRow = {
chat_id: string;
name: string;
owner_user_id: string;
member_count: number;
status: number;
};
export type GroupPushResult = {
sender: string;
msgid: string;
chatCount: number;
failList: string[];
};
export type GroupPushRecord = {
id: string;
taskId: string;
msgid: string;
sender: string;
chatIds: string[];
failList: string[];
createdAt: string;
};
function bindingValue(value: unknown) {
return String(value ?? "").trim();
}
export function trimToBytes(value: string, maxBytes: number) {
let output = "";
let used = 0;
for (const char of Array.from(value)) {
const size = Buffer.byteLength(char, "utf8");
if (used + size > maxBytes) break;
output += char;
used += size;
}
return output;
}
export function buildTaskGroupPushMessage(
task: TaskPushRow,
portalUrl: string,
): TaskPushMessage {
const portal = portalUrl.trim().replace(/\/$/, "");
const shareToken = bindingValue(task.share_token);
const claimUrl = portal && shareToken ? `${portal}/?task=${shareToken}` : "";
const formatLabel =
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
const lines = [
`【新任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
`平台:${task.platform}|形式:${formatLabel}`,
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
];
return {
text: trimToBytes(lines.join("\n"), TEXT_MAX_BYTES),
link: claimUrl
? {
title: trimToBytes(task.name, LINK_TITLE_MAX_BYTES),
desc: trimToBytes(
`品牌 ${task.brand} · ${task.quantity} 份 · 截止 ${task.due_at}`,
LINK_DESC_MAX_BYTES,
),
url: claimUrl.slice(0, LINK_URL_MAX_BYTES),
}
: null,
};
}
export function groupChatsByOwner(chats: GroupChatRow[]) {
const grouped = new Map<string, string[]>();
for (const chat of chats) {
const owner = bindingValue(chat.owner_user_id);
if (!owner) continue;
grouped.set(owner, [...(grouped.get(owner) ?? []), chat.chat_id]);
}
return [...grouped.entries()].map(([ownerUserId, chatIds]) => ({
ownerUserId,
chatIds,
}));
}
export async function syncGroupChats(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<GroupChatRow[]> {
if (!(config.corpId && config.agentId && config.secret)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const groups = await listCustomerGroupChats(config, fetchImpl);
const syncedAt = new Date().toISOString();
await db.prepare("DELETE FROM wecom_group_chats").run();
const inserts = groups.map((group) =>
db
.prepare(
`INSERT INTO wecom_group_chats
(chat_id, name, owner_user_id, member_count, status, synced_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
group.chatId,
group.name,
group.ownerUserId,
group.memberCount,
group.status,
syncedAt,
),
);
if (inserts.length > 0) await db.batch(inserts);
return groups.map((group) => ({
chat_id: group.chatId,
name: group.name,
owner_user_id: group.ownerUserId,
member_count: group.memberCount,
status: group.status,
}));
}
export async function listGroupChatRows(db: DatabaseClient) {
const result = await db
.prepare(
`SELECT chat_id, name, owner_user_id, member_count, status
FROM wecom_group_chats
ORDER BY member_count DESC`,
)
.all<GroupChatRow>();
return result.results;
}
export async function listGroupPushes(
db: DatabaseClient,
taskId?: string,
): Promise<GroupPushRecord[]> {
const filter = taskId ? "WHERE task_id = ?" : "";
const statement = db
.prepare(
`SELECT id, task_id, msgid, sender, chat_ids, fail_list, created_at
FROM wecom_group_pushes
${filter}
ORDER BY created_at DESC
LIMIT 50`,
)
.bind(...(taskId ? [taskId] : []));
const result = await statement.all<{
id: string;
task_id: string;
msgid: string;
sender: string;
chat_ids: string;
fail_list: string;
created_at: string;
}>();
return result.results.map((row) => ({
id: row.id,
taskId: row.task_id,
msgid: row.msgid,
sender: row.sender,
chatIds: safeParseArray(row.chat_ids),
failList: safeParseArray(row.fail_list),
createdAt: row.created_at,
}));
}
export async function pushTaskToGroupChats(
db: DatabaseClient,
params: { taskId: string; chatIds: unknown; text?: unknown },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<{ results: GroupPushResult[]; linkUrl: string }> {
const taskId = bindingValue(params.taskId);
if (!taskId) throw new WecomClientError("缺少 taskId", 400);
const requested = [
...new Set(
(Array.isArray(params.chatIds) ? params.chatIds : [])
.map((id) => bindingValue(id))
.filter(Boolean),
),
];
if (requested.length === 0) {
throw new WecomClientError("请选择要发送的客户群", 400);
}
const task = await db
.prepare(
`SELECT id, name, brand, quantity, due_at, task_type, platform,
content_format, share_token
FROM tasks WHERE id = ?`,
)
.bind(taskId)
.first<TaskPushRow>();
if (!task) throw new WecomClientError("任务不存在", 404);
const chatRows: GroupChatRow[] = [];
for (let index = 0; index < requested.length; index += 100) {
const chunk = requested.slice(index, index + 100);
const placeholders = chunk.map(() => "?").join(", ");
const result = await db
.prepare(
`SELECT chat_id, name, owner_user_id, member_count, status
FROM wecom_group_chats WHERE chat_id IN (${placeholders})`,
)
.bind(...chunk)
.all<GroupChatRow>();
chatRows.push(...result.results);
}
const byId = new Map(chatRows.map((row) => [row.chat_id, row]));
const missing = requested.filter((id) => !byId.has(id));
if (missing.length > 0) {
throw new WecomClientError(
`以下客户群未同步到平台,请先同步群列表:${missing.join("、")}`,
400,
);
}
const noOwner = requested
.map((id) => byId.get(id))
.filter((row) => row && !bindingValue(row.owner_user_id));
if (noOwner.length > 0) {
throw new WecomClientError(
`群「${noOwner.map((row) => row?.name || row?.chat_id).join("、")}」缺少群主信息,请重新同步群列表`,
400,
);
}
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
const fallback = buildTaskGroupPushMessage(task, portalUrl);
const text = bindingValue(params.text) || fallback.text;
const link = fallback.link;
if (!text && !link) {
throw new WecomClientError("文本与图文附件不能同时为空", 400);
}
const results: GroupPushResult[] = [];
const groups = requested.map((id) => byId.get(id)!);
for (const { ownerUserId, chatIds } of groupChatsByOwner(groups)) {
for (
let index = 0;
index < chatIds.length;
index += WECOM_GROUP_BATCH_LIMIT
) {
const batch = chatIds.slice(index, index + WECOM_GROUP_BATCH_LIMIT);
const sent = await createGroupMsgTemplate(
config,
{ sender: ownerUserId, chatIdList: batch, text, link: link ?? undefined },
fetchImpl,
);
await db
.prepare(
`INSERT INTO wecom_group_pushes
(id, task_id, msgid, sender, chat_ids, text_content, link_url, fail_list)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
`wgp-${crypto.randomUUID().slice(0, 12)}`,
taskId,
sent.msgid,
ownerUserId,
JSON.stringify(batch),
text,
link?.url ?? "",
JSON.stringify(sent.failList),
)
.run();
results.push({
sender: ownerUserId,
msgid: sent.msgid,
chatCount: batch.length,
failList: sent.failList,
});
}
}
return { results, linkUrl: link?.url ?? "" };
}
function safeParseArray(value: string) {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
} catch {
return [];
}
}

View File

@@ -3,9 +3,11 @@ import { shanghaiDateFromTimestamp } from "./collection-service";
import {
sendWecomAppMessage,
sendWecomRobotMessage,
sendWecomTemplateCard,
type WecomConfig,
} from "./wecom-client";
import { getRuntimeEnv } from "./runtime-env";
import { releaseUnfinishedDistribution } from "./distribution-release-service";
type FetchLike = typeof fetch;
@@ -61,7 +63,11 @@ export async function runDueSoonWecomNotifications(
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 = '')
WHERE (
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL)
OR (t.task_type != 'screenshot_collect'
AND (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`,
@@ -162,6 +168,231 @@ function bindingValue(value: unknown) {
return String(value ?? "").trim();
}
export function resolveAutoReleaseDays(value: string | undefined) {
const parsed = Number(String(value ?? "").trim());
if (!Number.isFinite(parsed) || parsed < 1) return 0;
return Math.min(90, Math.floor(parsed));
}
type OverdueRow = {
id: string;
task_id: string;
task_name: string;
due_at: string;
project_id: string;
partner_id: string;
partner_name: string;
};
export type OverdueEscalationSummary = {
overdueTasks: number;
overdueDistributions: number;
robotSent: boolean;
cardSent: number;
cardFailed: number;
};
async function queryOverdueUnpublished(
db: DatabaseClient,
cutoff: string,
): Promise<OverdueRow[]> {
const result = await db
.prepare(
`SELECT d.id, t.id AS task_id, t.name AS task_name, t.due_at,
t.project_id, p.id AS partner_id, p.name AS partner_name
FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id
WHERE (
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL)
OR (t.task_type != 'screenshot_collect'
AND (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, t.name ASC`,
)
.bind(cutoff)
.all<OverdueRow>();
return result.results;
}
export async function runOverdueEscalation(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
): Promise<OverdueEscalationSummary> {
const today = shanghaiDateFromTimestamp(now);
const rows = await queryOverdueUnpublished(db, today);
const byTask = new Map<
string,
{ taskName: string; dueAt: string; projectId: string; count: number; partners: Set<string> }
>();
for (const row of rows) {
const entry = byTask.get(row.task_id) ?? {
taskName: row.task_name,
dueAt: row.due_at,
projectId: bindingValue(row.project_id),
count: 0,
partners: new Set<string>(),
};
entry.count += 1;
entry.partners.add(row.partner_name || row.partner_id);
byTask.set(row.task_id, entry);
}
const summary: OverdueEscalationSummary = {
overdueTasks: byTask.size,
overdueDistributions: rows.length,
robotSent: false,
cardSent: 0,
cardFailed: 0,
};
if (rows.length === 0) return summary;
if (config.robotWebhook) {
const lines = [...byTask.values()].map((entry) => {
const partners = [...entry.partners].slice(0, 5).join("、");
const overflow = entry.partners.size > 5 ? ` 等 ${entry.partners.size} 位` : "";
return `· ${truncate(entry.taskName, 24)}(截止 ${entry.dueAt}):${entry.count} 条超期未发布 — ${partners}${overflow}`;
});
try {
await sendWecomRobotMessage(
`【超期未发布升级】${today}\n${lines.join("\n")}\n请运营跟进催办或释放。`,
config,
fetchImpl,
);
summary.robotSent = true;
} catch (error) {
console.warn("[KOC LOOP] overdue escalation robot failed", {
error: safeError(error),
});
}
}
const env = getRuntimeEnv();
const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL);
for (const [taskId, entry] of byTask) {
if (!entry.projectId) continue;
try {
const project = await db
.prepare("SELECT main_group_member_ids FROM projects WHERE id = ?")
.bind(entry.projectId)
.first<{ main_group_member_ids: string }>();
const members = safeParseIdList(project?.main_group_member_ids);
if (members.length === 0) continue;
const sent = await sendWecomTemplateCard(
config,
{
touser: members,
taskId: `task:${taskId}:due`,
title: `超期催办:${truncate(entry.taskName, 20)}`,
description: `${entry.count} 条内容超期未发布(截止 ${entry.dueAt}),请跟进催办或释放`,
url: cardUrl,
buttons: [
{ key: "view_task", name: "查看任务", url: cardUrl },
{ key: "mark_handled", name: "已处理" },
],
},
fetchImpl,
);
if (!sent.skipped) {
summary.cardSent += sent.sent;
summary.cardFailed += sent.failed;
}
} catch (error) {
summary.cardFailed += 1;
console.warn("[KOC LOOP] overdue escalation card failed", {
taskId,
error: safeError(error),
});
}
}
return summary;
}
export type AutoReleaseSummary = {
enabled: boolean;
days: number;
cutoff: string;
released: number;
blocked: number;
failed: number;
robotSent: boolean;
};
export async function runOverdueAutoRelease(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
): Promise<AutoReleaseSummary> {
const days = resolveAutoReleaseDays(getRuntimeEnv().WECOM_AUTO_RELEASE_DAYS);
const summary: AutoReleaseSummary = {
enabled: days > 0,
days,
cutoff: "",
released: 0,
blocked: 0,
failed: 0,
robotSent: false,
};
if (days <= 0) return summary;
const today = shanghaiDateFromTimestamp(now);
summary.cutoff = computeDueCutoff(today, -days);
const rows = await queryOverdueUnpublished(db, summary.cutoff);
for (const row of rows) {
try {
await releaseUnfinishedDistribution(db, row.id);
summary.released += 1;
} catch (error) {
const status = (error as { status?: number }).status;
if (status === 409) {
summary.blocked += 1;
} else {
summary.failed += 1;
console.warn("[KOC LOOP] auto release failed", {
distributionId: row.id,
error: safeError(error),
});
}
}
}
if (summary.released > 0 && config.robotWebhook) {
try {
await sendWecomRobotMessage(
`【超期自动释放】截止 ${summary.cutoff} 前仍未发布的内容已释放 ${summary.released} 条回库存(跳过 ${summary.blocked} 条已提交、失败 ${summary.failed} 条),可重新领取。`,
config,
fetchImpl,
);
summary.robotSent = true;
} catch (error) {
console.warn("[KOC LOOP] auto release robot failed", {
error: safeError(error),
});
}
}
return summary;
}
function safeParseIdList(value: string | null | undefined) {
try {
const parsed = JSON.parse(String(value ?? "[]"));
if (!Array.isArray(parsed)) return [];
return parsed.map((item) => String(item)).filter(Boolean);
} catch {
return [];
}
}
function truncate(value: string, max: number) {
return value.length > max ? `${value.slice(0, max)}…` : value;
}

160
lib/wecom-oauth-service.ts Normal file
View File

@@ -0,0 +1,160 @@
import type { DatabaseClient } from "./database";
import { getRuntimeEnv } from "./runtime-env";
import { hashText } from "./mvp-db";
export const WECOM_IDENTITY_TTL_MS = 30 * 60 * 1_000;
const WECOM_IDENTITY_GRACE_MS = 60 * 1_000;
type WecomIdentityPayload = {
v: 1;
ext: string;
name: string;
iat: number;
exp: number;
jti: string;
};
export type WecomIdentity = {
externalUserId: string;
displayName: string;
};
export function getWecomOAuthSecret(): string {
const secret = String(getRuntimeEnv().ADMIN_INTERNAL_TOKEN ?? "").trim();
if (!secret) {
throw new Error("ADMIN_INTERNAL_TOKEN 未配置,无法签发企微身份票据");
}
return secret;
}
function toBase64Url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function fromBase64Url(value: string): Uint8Array {
const padded = value
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(value.length / 4) * 4, "=");
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(data),
);
return new Uint8Array(signature);
}
function constantTimeEquals(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let index = 0; index < a.length; index += 1) {
diff |= a[index] ^ b[index];
}
return diff === 0;
}
export async function signWecomIdentityTicket(
externalUserId: string,
displayName: string,
options: { secret?: string; nowMs?: number; ttlMs?: number } = {},
): Promise<{ ticket: string; expiresIn: number }> {
const secret = options.secret ?? getWecomOAuthSecret();
const issuedAt = options.nowMs ?? Date.now();
const ttl = options.ttlMs ?? WECOM_IDENTITY_TTL_MS;
const ext = String(externalUserId ?? "").trim();
if (!ext) throw new Error("缺少企微外部联系人 ID");
const payload: WecomIdentityPayload = {
v: 1,
ext,
name: String(displayName ?? "").trim().slice(0, 120),
iat: issuedAt,
exp: issuedAt + ttl,
jti: toBase64Url(crypto.getRandomValues(new Uint8Array(12))),
};
const payloadSegment = toBase64Url(
new TextEncoder().encode(JSON.stringify(payload)),
);
const signature = await hmacSha256(secret, payloadSegment);
return {
ticket: `${payloadSegment}.${toBase64Url(signature)}`,
expiresIn: Math.floor(ttl / 1_000),
};
}
export async function verifyWecomIdentityTicket(
ticket: string,
options: { secret?: string; nowMs?: number } = {},
): Promise<WecomIdentity | null> {
const raw = String(ticket ?? "").trim();
const dotIndex = raw.indexOf(".");
if (dotIndex <= 0 || dotIndex === raw.length - 1) return null;
const payloadSegment = raw.slice(0, dotIndex);
const signatureSegment = raw.slice(dotIndex + 1);
if (/[^A-Za-z0-9_-]/.test(payloadSegment) || /[^A-Za-z0-9_-]/.test(signatureSegment)) {
return null;
}
let payload: WecomIdentityPayload;
try {
payload = JSON.parse(
new TextDecoder().decode(fromBase64Url(payloadSegment)),
) as WecomIdentityPayload;
} catch {
return null;
}
if (
payload?.v !== 1 ||
typeof payload.ext !== "string" ||
!payload.ext.trim() ||
typeof payload.exp !== "number"
) {
return null;
}
const secret = options.secret ?? getWecomOAuthSecret();
const expected = await hmacSha256(secret, payloadSegment);
const provided = fromBase64Url(signatureSegment);
if (!constantTimeEquals(expected, provided)) return null;
const now = options.nowMs ?? Date.now();
if (now > payload.exp + WECOM_IDENTITY_GRACE_MS) return null;
return {
externalUserId: payload.ext.trim(),
displayName: payload.name || "企微客户",
};
}
export function wecomClaimantPartnerId(externalUserId: string): string {
return `partner-ext-${hashText(`claimant:wecom:${externalUserId}`)}`;
}
export async function resolveWecomClaimant(
db: DatabaseClient,
externalUserId: string,
): Promise<{ partnerId: string; matchedExisting: boolean }> {
const ext = String(externalUserId ?? "").trim();
if (!ext) throw new Error("缺少企微外部联系人 ID");
const existing = await db
.prepare("SELECT id FROM partners WHERE wecom_external_user_id = ?")
.bind(ext)
.first<{ id: string }>();
if (existing?.id) {
return { partnerId: existing.id, matchedExisting: true };
}
return { partnerId: wecomClaimantPartnerId(ext), matchedExisting: false };
}

View File

@@ -50,9 +50,6 @@ CREATE TABLE IF NOT EXISTS accounts (
profile_url TEXT NOT NULL DEFAULT (''),
ip_location VARCHAR(255) NOT NULL DEFAULT '待识别',
followers INT NOT NULL DEFAULT 0,
gender VARCHAR(16) NOT NULL DEFAULT '',
bio TEXT NOT NULL DEFAULT (''),
tags VARCHAR(500) NOT NULL DEFAULT '',
post_count INT NOT NULL DEFAULT 0,
avg_views INT NOT NULL DEFAULT 0,
first_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS `projects` (
`id` VARCHAR(64) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`brand` VARCHAR(255) NOT NULL DEFAULT '',
`main_group_member_ids` TEXT NOT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'active',
`note` TEXT NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- statement-breakpoint
CREATE TABLE IF NOT EXISTS `project_group_chats` (
`id` VARCHAR(64) NOT NULL,
`project_id` VARCHAR(64) NOT NULL,
`chat_id` VARCHAR(128) NOT NULL,
`partner_id` VARCHAR(64) NOT NULL DEFAULT '',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `project_group_chats_uniq` (`project_id`,`chat_id`),
KEY `project_group_chats_project_idx` (`project_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- statement-breakpoint
ALTER TABLE `partners` ADD COLUMN `partner_type` VARCHAR(32) NOT NULL DEFAULT 'external_koc';
-- statement-breakpoint
ALTER TABLE `partners` ADD COLUMN `wecom_userid` VARCHAR(128) NOT NULL DEFAULT '';
-- statement-breakpoint
ALTER TABLE `tasks` ADD COLUMN `project_id` VARCHAR(64) NOT NULL DEFAULT '';

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `wecom_push_logs` (
`id` VARCHAR(64) NOT NULL,
`task_id` VARCHAR(64) NOT NULL,
`project_id` VARCHAR(64) NOT NULL DEFAULT '',
`push_kind` VARCHAR(32) NOT NULL DEFAULT 'task_open',
`main_group_count` INT NOT NULL DEFAULT 0,
`koc_userids` TEXT NOT NULL,
`koc_ok_count` INT NOT NULL DEFAULT 0,
`koc_fail_list` TEXT NOT NULL,
`group_push_id` VARCHAR(128) NOT NULL DEFAULT '',
`detail` TEXT NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `wecom_push_logs_task_idx` (`task_id`,`push_kind`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

View File

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

View File

@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
runAccountFieldRefresh,
sanitizeRefreshFields,
} from "../lib/account-field-refresh-service.ts";
function fakeDb(accounts) {
const updates = [];
return {
db: {
prepare: (sql) => {
if (sql.startsWith("SELECT")) {
return {
all: async () => ({
results: accounts.map((account) => ({
id: account.id,
platform: account.platform,
platform_uid: account.platform_uid,
profile_url: account.profile_url,
})),
success: true,
meta: { changes: 0, last_row_id: 0 },
}),
};
}
assert.match(sql, /^UPDATE accounts SET [a-z_, ?= ]+ WHERE id = \?$/);
return {
bind: (...args) => ({
run: async () => {
updates.push({ sql, args });
return { success: true, meta: { changes: 1, last_row_id: 0 } };
},
}),
};
},
},
updates,
};
}
const profile = (overrides = {}) => ({
nickname: "昵称A",
redId: "id-a",
followers: 123,
ipLocation: "上海",
gender: "女",
bio: "简介A",
recentNoteTitles: [],
providerTags: [],
...overrides,
});
const noTag = async () => [];
test("writes only the selected fields as dynamic columns", async () => {
const accounts = [
{ id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" },
];
const { db, updates } = fakeDb(accounts);
const status = await runAccountFieldRefresh(
db,
async () => profile(),
noTag,
["nickname", "ipLocation"],
);
assert.equal(status.status, "done");
assert.equal(status.updated, 1);
assert.equal(status.fields.join(","), "nickname,ipLocation");
assert.equal(updates.length, 1);
assert.match(updates[0].sql, /SET nickname = \?, ip_location = \?/);
assert.doesNotMatch(updates[0].sql, /public_account_id|followers|gender|bio|tags/);
assert.deepEqual(updates[0].args, ["昵称A", "上海", "a1"]);
});
test("tags field uses the tagger output and joins with commas", async () => {
const accounts = [
{ id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" },
];
const { db, updates } = fakeDb(accounts);
const status = await runAccountFieldRefresh(
db,
async () => profile({ redId: "" }),
async () => ["美妆", "学生党"],
["tags"],
);
assert.equal(status.updated, 1);
assert.match(updates[0].sql, /SET tags = \?/);
assert.deepEqual(updates[0].args, ["美妆,学生党", "a1"]);
});
test("empty scrape values and empty tagger result are skipped without writing", async () => {
const accounts = [
{ id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" },
];
const { db, updates } = fakeDb(accounts);
const status = await runAccountFieldRefresh(
db,
async () => profile({ redId: " ", nickname: null, followers: null, ipLocation: "", gender: "", bio: "" }),
noTag,
["publicAccountId", "nickname", "followers", "ipLocation", "gender", "bio", "tags"],
);
assert.equal(status.updated, 0);
assert.equal(status.skipped, 1);
assert.equal(updates.length, 0);
});
test("scrape failures count as failed and demo accounts are excluded", async () => {
const accounts = [
{ id: "boom", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/boom" },
{ id: "demo", platform: "小红书", platform_uid: "xhs-8af3", profile_url: "https://www.xiaohongshu.com/user/profile/demo" },
{ id: "ok", platform: "抖音", platform_uid: "uid-2", profile_url: "https://www.douyin.com/user/ok" },
];
const { db, updates } = fakeDb(accounts);
const status = await runAccountFieldRefresh(
db,
async (url) => {
if (url.includes("boom")) throw new Error("mcp down");
return profile({ redId: "ok-2" });
},
noTag,
["publicAccountId"],
);
assert.equal(status.total, 2);
assert.equal(status.updated, 1);
assert.equal(status.failed, 1);
assert.deepEqual(updates[0].args.slice(-1), ["ok"]);
});
test("sanitizeRefreshFields filters unknown values and falls back to account id", () => {
assert.deepEqual(sanitizeRefreshFields(["nickname", "hacked", "tags"]), [
"nickname",
"tags",
]);
assert.deepEqual(sanitizeRefreshFields([]), ["publicAccountId"]);
assert.deepEqual(sanitizeRefreshFields("bad"), ["publicAccountId"]);
});

View File

@@ -0,0 +1,221 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildAccountTagText,
generateAccountTagsFromMcp,
parseAccountTags,
} from "../lib/mcp-collection-client.ts";
import { needsResourceEnrichment } from "../lib/resource-import.ts";
function sse(payload, headers = {}) {
return new Response(`event: message\ndata: ${JSON.stringify(payload)}\n\n`, {
status: 200,
headers: {
"content-type": "text/event-stream",
...headers,
},
});
}
function toolEnvelope(payload, isError = false) {
return {
jsonrpc: "2.0",
id: "tool-call",
result: {
isError,
content: [{ type: "text", text: JSON.stringify(payload) }],
},
};
}
function createFakeTaggingMcp(toolResult, isError = false) {
const calls = [];
const fetchImpl = async (url, init) => {
const body = JSON.parse(init.body);
calls.push({ url: String(url), body });
if (body.method === "initialize") {
return sse(
{
jsonrpc: "2.0",
id: body.id,
result: {
protocolVersion: "2025-03-26",
capabilities: {},
serverInfo: { name: "ai-tool-center", version: "test" },
},
},
{ "mcp-session-id": "session-test" },
);
}
if (body.method === "notifications/initialized") {
return new Response("", { status: 202 });
}
if (body.method === "tools/call") {
return sse(toolEnvelope(toolResult, isError));
}
throw new Error(`Unexpected MCP method: ${body.method}`);
};
return { fetchImpl, calls };
}
const config = { endpoint: "https://mcp.test/mcp", key: "test-key" };
const taggingEnv = { AI_TAGGING_TOOL: "generate_tags" };
const input = {
platform: "小红书",
nickname: "小美的日常",
bio: "分享护肤和生活",
gender: "女",
ipLocation: "上海",
followers: 1200,
providerTags: ["美妆"],
recentNoteTitles: ["平价护肤分享"],
};
test("parseAccountTags 解析纯 JSON 数组", () => {
assert.deepEqual(parseAccountTags('["美妆","学生党"]'), ["美妆", "学生党"]);
});
test("parseAccountTags 剥离代码块围栏", () => {
assert.deepEqual(parseAccountTags('```json\n["美食"]\n```'), ["美食"]);
assert.deepEqual(parseAccountTags('```\n["旅行"]\n```'), ["旅行"]);
});
test("parseAccountTags 从夹杂文字中提取数组", () => {
const text = '好的,以下是标签:["旅行","摄影"] 希望有帮助';
assert.deepEqual(parseAccountTags(text), ["旅行", "摄影"]);
});
test("parseAccountTags 清洗与过滤", () => {
assert.deepEqual(parseAccountTags('["#美食","探店#"]'), ["美食", "探店"]);
assert.deepEqual(parseAccountTags('["美妆","美妆","穿搭"]'), [
"美妆",
"穿搭",
]);
assert.deepEqual(
parseAccountTags('["这个标签实在太长了超过十二个字","护肤"]'),
["护肤"],
);
assert.deepEqual(
parseAccountTags('[1, null, "母婴", true]'),
["母婴"],
);
});
test("parseAccountTags 超过 5 个截断", () => {
const text = '["一","二","三","四","五","六"]';
assert.deepEqual(parseAccountTags(text), ["一", "二", "三", "四", "五"]);
});
test("parseAccountTags 无法解析时返回空数组", () => {
assert.deepEqual(parseAccountTags("无法生成标签"), []);
assert.deepEqual(parseAccountTags(""), []);
assert.deepEqual(parseAccountTags('{"tags":"美妆"}'), []);
});
test("buildAccountTagText 包含关键字段", () => {
const text = buildAccountTagText(input);
assert.ok(text.includes("小美的日常"));
assert.ok(text.includes("分享护肤和生活"));
assert.ok(text.includes("粉丝数:1200"));
assert.ok(text.includes("美妆"));
assert.ok(text.includes("平价护肤分享"));
});
test("buildAccountTagText 粉丝数为 0 或空时省略", () => {
assert.ok(!buildAccountTagText({ ...input, followers: 0 }).includes("粉丝数"));
assert.ok(
!buildAccountTagText({ ...input, followers: null }).includes("粉丝数"),
);
});
test("generateAccountTagsFromMcp 工具名为空时不发起请求", async () => {
const fetchImpl = async () => {
throw new Error("should not be called");
};
const tags = await generateAccountTagsFromMcp(
input,
config,
{},
fetchImpl,
);
assert.deepEqual(tags, []);
});
test("generateAccountTagsFromMcp 正常返回标签数组", async () => {
const { fetchImpl, calls } = createFakeTaggingMcp(["美妆", "探店", "好物分享"]);
const tags = await generateAccountTagsFromMcp(
input,
config,
taggingEnv,
fetchImpl,
);
assert.deepEqual(tags, ["美妆", "探店", "好物分享"]);
const toolCall = calls.find((call) => call.body.method === "tools/call");
assert.equal(toolCall.body.params.name, "generate_tags");
const args = toolCall.body.params.arguments;
assert.equal(args.objective, "为KOC账号生成运营标签");
assert.ok(args.text.includes("小美的日常"));
assert.ok(Array.isArray(args.rules) && args.rules.length > 0);
assert.equal(args.output_schema.properties.tags.type, "array");
assert.ok(String(toolCall.url).includes("key=test-key"));
});
test("generateAccountTagsFromMcp 支持对象包裹的返回", async () => {
const { fetchImpl } = createFakeTaggingMcp({ tags: ["母婴", "育儿"] });
const tags = await generateAccountTagsFromMcp(
input,
config,
taggingEnv,
fetchImpl,
);
assert.deepEqual(tags, ["母婴", "育儿"]);
});
test("generateAccountTagsFromMcp 工具报错时返回空数组", async () => {
const { fetchImpl } = createFakeTaggingMcp({ message: "boom" }, true);
const tags = await generateAccountTagsFromMcp(
input,
config,
taggingEnv,
fetchImpl,
);
assert.deepEqual(tags, []);
});
test("generateAccountTagsFromMcp 网络异常时返回空数组", async () => {
const fetchImpl = async () => {
throw new Error("network down");
};
const tags = await generateAccountTagsFromMcp(
input,
config,
taggingEnv,
fetchImpl,
);
assert.deepEqual(tags, []);
});
const completeRow = {
nickname: "小美",
publicAccountId: "xhs-123",
ipLocation: "上海",
followersResolved: true,
gender: "女",
bio: "分享生活",
tags: ["美妆"],
};
test("needsResourceEnrichment 资料齐全且有标签时为 false", () => {
assert.equal(needsResourceEnrichment(completeRow), false);
});
test("needsResourceEnrichment 资料齐全但无标签时为 true", () => {
assert.equal(needsResourceEnrichment({ ...completeRow, tags: [] }), true);
});
test("needsResourceEnrichment 缺资料且有标签时为 true", () => {
assert.equal(
needsResourceEnrichment({ ...completeRow, bio: "" }),
true,
);
});

View File

@@ -13,7 +13,7 @@ test("exposes authenticated KOC task, recovery, collection, and resource MCP too
readFile(new URL("../lib/task-service.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
readFile(new URL("../README.md", import.meta.url), "utf8"),
readFile(new URL("../.env.self-hosted.example", import.meta.url), "utf8"),
readFile(new URL("../.env.app.example", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
]);

View File

@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const routeSource = readFileSync(
new URL("../app/api/partner/route.ts", import.meta.url),
"utf8",
);
test("partner route accepts identityTicket for wecom-backed claim", () => {
assert.match(routeSource, /identityTicket\?: string/);
assert.match(routeSource, /verifyWecomIdentityTicket/);
assert.match(routeSource, /resolveWecomClaimant/);
});
test("claim keeps manual wechat/phone path untouched when no ticket", () => {
assert.match(
routeSource,
/请输入正确的微信号或手机号/,
"manual validation message preserved",
);
assert.match(
routeSource,
/VALUES \(\?, \?, \?, '外部KOC', 0, 0\)\s*\n\s*ON CONFLICT\(id\) DO UPDATE SET\s*\n\s*name = excluded\.name,\s*\n\s*wecom_name = excluded\.wecom_name`/,
"manual partner upsert statement preserved",
);
});
test("claim with ticket upserts wecom_external_user_id for reminders", () => {
assert.match(
routeSource,
/wecom_external_user_id = excluded\.wecom_external_user_id/,
);
});
test("recover builds dynamic partner candidates including wecom identity", () => {
assert.match(
routeSource,
/wecomPartnerId \? \[wecomPartnerId\] : \[partnerId, legacyPartnerId\]/,
);
assert.match(routeSource, /\.bind\(task\.task_type, task\.task_type, task\.id, \.\.\.partnerCandidates\)/);
});

264
tests/project-push.test.mjs Normal file
View File

@@ -0,0 +1,264 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildProjectAppMessage,
pushProjectTask,
} from "../lib/project-push-service.ts";
import { ProjectError } from "../lib/project-service.ts";
import {
clearWecomAccessTokenCacheForTests,
resolveWecomConfig,
} from "../lib/wecom-client.ts";
const baseBindings = {
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
};
const taskRow = {
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",
project_id: "proj-1",
claimed_quantity: 0,
status: "active",
};
const projectRow = {
id: "proj-1",
name: "项目一",
brand: "品牌A",
main_group_member_ids: JSON.stringify(["zhangsan", "lisi"]),
status: "active",
note: "",
created_at: "2026-08-01",
};
function fakePushDb({
task = taskRow,
project = projectRow,
links = [
{ project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" },
{ project_id: "proj-1", chat_id: "wr-2", partner_id: "" },
],
groupChats = [
{ 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 executed = [];
const makeStatement = (sql, params = []) => ({
sql,
params,
bind(...args) {
return makeStatement(sql, args);
},
async run() {
executed.push({ sql, params });
return { meta: { changes: 1 } };
},
async first() {
executed.push({ sql, params });
if (/FROM tasks WHERE id = \?/.test(sql)) return task;
if (/FROM projects WHERE id = \?/.test(sql)) return project;
return null;
},
async all() {
executed.push({ sql, params });
if (/FROM tasks WHERE project_id/.test(sql)) {
return { results: task ? [{ ...task }] : [] };
}
if (/FROM project_group_chats g/.test(sql)) {
return {
results: links
.filter((link) => link.project_id === params[0])
.map((link) => {
const chat = groupChats.find((item) => item.chat_id === link.chat_id);
return {
chat_id: link.chat_id,
partner_id: link.partner_id,
chat_name: chat ? chat.name : null,
owner_user_id: chat ? chat.owner_user_id : null,
member_count: chat ? chat.member_count : null,
chat_status: chat ? chat.status : null,
partner_name: null,
};
}),
};
}
if (/FROM wecom_group_chats/.test(sql)) {
return {
results: sql.includes(" IN (")
? groupChats.filter((row) => params.includes(row.chat_id))
: groupChats,
};
}
return { results: [] };
},
});
return {
executed,
prepare(sql) {
return makeStatement(sql);
},
async batch(statements) {
for (const statement of statements) await statement.run();
},
};
}
function fakeWecomFetch(calls, { failAppFor = [] } = {}) {
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/message/send")) {
const body = JSON.parse(String(init.body));
if (failAppFor.includes(body.touser)) {
return Response.json({ errcode: 60111, errmsg: "userid not found" });
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
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.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
test("buildProjectAppMessage targets internal members with claim url", () => {
const message = buildProjectAppMessage(taskRow, "https://portal.example.com/");
assert.match(message, /【项目任务】测试任务/);
assert.match(message, /请把任务转发到你负责的项目任务群/);
assert.match(message, /领取链接:https:\/\/portal\.example\.com\/\?task=tok-123/);
});
test("pushProjectTask sends per-member app messages and group templates", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb();
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
);
const appCalls = calls.filter((call) =>
call.url.pathname.endsWith("/cgi-bin/message/send"),
);
assert.deepEqual(
appCalls.map((call) => JSON.parse(String(call.init.body)).touser),
["zhangsan", "lisi"],
"one message/send per main group member",
);
for (const call of appCalls) {
assert.match(JSON.parse(String(call.init.body)).text.content, /【项目任务】测试任务/);
}
assert.equal(outcome.appResults.length, 2);
assert.ok(outcome.appResults.every((item) => item.ok));
const templateCalls = calls.filter((call) =>
call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
);
assert.equal(templateCalls.length, 2, "one template per owner");
assert.equal(outcome.groupResults.length, 2);
assert.equal(outcome.groupChatCount, 2);
assert.deepEqual(outcome.skippedReasons, []);
assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123");
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("single member failure does not block the rest of the push", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb();
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls, { failAppFor: ["zhangsan"] }),
);
assert.deepEqual(
outcome.appResults.map((item) => [item.userid, item.ok]),
[
["zhangsan", false],
["lisi", true],
],
);
assert.match(outcome.appResults[0].error, /userid not found/);
const templateCalls = calls.filter((call) =>
call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
);
assert.equal(templateCalls.length, 2, "group push still runs");
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("pushProjectTask skips empty targets instead of failing", async () => {
const db = fakePushDb({
project: { ...projectRow, main_group_member_ids: "[]" },
links: [{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" }],
groupChats: [],
});
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
);
assert.deepEqual(outcome.skippedReasons, ["项目总群未配置成员", "项目没有可用的任务群"]);
assert.equal(calls.length, 0, "no wecom api calls expected");
});
test("pushProjectTask rejects task without project or unknown task", async () => {
const unlinked = fakePushDb({ task: { ...taskRow, project_id: "" } });
await assert.rejects(
() =>
pushProjectTask(
unlinked,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
fakeWecomFetch([]),
),
(error) => error instanceof ProjectError && /未挂项目/.test(error.message),
);
await assert.rejects(
() => pushProjectTask(fakePushDb(), { taskId: "" }, resolveWecomConfig(baseBindings)),
(error) => error instanceof ProjectError,
);
await assert.rejects(
() =>
pushProjectTask(
fakePushDb(),
{ taskId: "task-1", sendMainGroup: false, sendGroupChats: false },
resolveWecomConfig(baseBindings),
fakeWecomFetch([]),
),
(error) => error instanceof ProjectError && /至少选择一个发送目标/.test(error.message),
);
});

View File

@@ -0,0 +1,378 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
deleteProject,
getProjectDetail,
listProjects,
parseMemberIds,
ProjectError,
saveGroupOwnerPartner,
saveProject,
setProjectGroupChats,
} from "../lib/project-service.ts";
function fakeProjectDb({
projects = [],
links = [],
tasks = [],
groupChats = [],
partners = [],
} = {}) {
const executed = [];
const applyWrite = (sql, params) => {
const normalized = sql.trim();
if (/^INSERT INTO projects/.test(normalized)) {
projects.push({
id: params[0],
name: params[1],
brand: params[2],
main_group_member_ids: params[3],
status: "active",
note: params[4],
created_at: params[5],
});
return 1;
}
if (/^UPDATE projects/.test(normalized)) {
const row = projects.find((item) => item.id === params[4]);
if (!row) return 0;
Object.assign(row, {
name: params[0],
brand: params[1],
main_group_member_ids: params[2],
note: params[3],
});
return 1;
}
if (/^DELETE FROM projects/.test(normalized)) {
const index = projects.findIndex((item) => item.id === params[0]);
if (index < 0) return 0;
projects.splice(index, 1);
return 1;
}
if (/^INSERT INTO project_group_chats/.test(normalized)) {
links.push({ project_id: params[1], chat_id: params[2], partner_id: params[3] });
return 1;
}
if (/^DELETE FROM project_group_chats/.test(normalized)) {
for (let index = links.length - 1; index >= 0; index -= 1) {
if (links[index].project_id === params[0]) links.splice(index, 1);
}
return 1;
}
if (/^UPDATE tasks SET project_id = ''/.test(normalized)) {
for (const task of tasks) {
if (task.project_id === params[0]) task.project_id = "";
}
return 1;
}
if (/^INSERT INTO partners/.test(normalized)) {
partners.push({
id: params[0],
name: params[1],
wecom_userid: params[2],
partner_type: "group_owner",
});
return 1;
}
if (/^UPDATE partners/.test(normalized)) {
const row = partners.find((item) => item.id === params[2]);
if (!row) return 0;
Object.assign(row, { name: params[0], wecom_userid: params[1] });
return 1;
}
return 1;
};
const makeStatement = (sql, params = []) => ({
sql,
params,
bind(...args) {
return makeStatement(sql, args);
},
async run() {
executed.push({ sql, params });
return { meta: { changes: applyWrite(sql, params) } };
},
async first() {
executed.push({ sql, params });
if (/FROM projects WHERE id = \?/.test(sql)) {
return projects.find((item) => item.id === params[0]) ?? null;
}
if (/FROM partners WHERE id = \?/.test(sql)) {
return partners.find((item) => item.id === params[0]) ?? null;
}
return null;
},
async all() {
executed.push({ sql, params });
if (/FROM projects p/.test(sql)) {
return {
results: projects.map((item) => ({
...item,
task_count: tasks.filter((task) => task.project_id === item.id).length,
group_chat_count: links.filter((link) => link.project_id === item.id).length,
})),
};
}
if (/FROM tasks WHERE project_id/.test(sql)) {
return {
results: tasks
.filter((task) => task.project_id === params[0])
.map((task) => ({ ...task })),
};
}
if (/FROM project_group_chats g/.test(sql)) {
return {
results: links
.filter((link) => link.project_id === params[0])
.map((link) => {
const chat = groupChats.find((item) => item.chat_id === link.chat_id);
return {
chat_id: link.chat_id,
partner_id: link.partner_id,
chat_name: chat ? chat.name : null,
owner_user_id: chat ? chat.owner_user_id : null,
member_count: chat ? chat.member_count : null,
chat_status: chat ? chat.status : null,
partner_name:
partners.find((item) => item.id === link.partner_id)?.name ?? null,
};
}),
};
}
if (/FROM partners/.test(sql)) {
return { results: partners.map((item) => ({ ...item })) };
}
return { results: [] };
},
});
return {
projects,
links,
tasks,
groupChats,
partners,
executed,
prepare(sql) {
return makeStatement(sql);
},
async batch(statements) {
const output = [];
for (const statement of statements) output.push(await statement.run());
return output;
},
};
}
test("parseMemberIds accepts lines, arrays and mixed separators, dedupes", () => {
assert.deepEqual(parseMemberIds("zhangsan\nlisi zhangsan"), ["zhangsan", "lisi"]);
assert.deepEqual(parseMemberIds(["a", " b ", "a"]), ["a", "b"]);
assert.deepEqual(parseMemberIds("a,b;c,d"), ["a", "b", "c", "d"]);
assert.deepEqual(parseMemberIds(" \n "), []);
});
test("saveProject inserts with proj- prefixed id and JSON members", async () => {
const db = fakeProjectDb();
const project = await saveProject(db, {
name: "暑期焕新",
brand: "青柠实验室",
mainGroupMemberIds: "zhangsan\nlisi\nzhangsan",
note: "备注",
});
assert.match(project.id, /^proj-/);
assert.equal(project.name, "暑期焕新");
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi"]);
assert.equal(db.projects.length, 1);
assert.equal(db.projects[0].status, "active");
});
test("saveProject updates existing row and rejects blank name", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "旧名",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
});
await saveProject(db, {
id: "proj-1",
name: "新名",
brand: "品牌",
mainGroupMemberIds: ["wangwu"],
});
assert.equal(db.projects[0].name, "新名");
assert.equal(db.projects[0].brand, "品牌");
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), ["wangwu"]);
assert.equal(db.projects.length, 1, "update must not insert");
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: " " }),
(error) => error instanceof ProjectError && error.status === 400,
);
});
test("saveProject rejects unknown id on update", async () => {
const db = fakeProjectDb();
await assert.rejects(
() => saveProject(db, { id: "proj-404", name: "x" }),
(error) => error instanceof ProjectError && error.status === 404,
);
});
test("listProjects aggregates task and group chat counts", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[\"u1\",\"u2\"]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
tasks: [
{ id: "t1", project_id: "proj-1" },
{ id: "t2", project_id: "proj-1" },
{ id: "t3", project_id: "" },
],
links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }],
});
const projects = await listProjects(db);
assert.equal(projects.length, 1);
assert.equal(projects[0].taskCount, 2);
assert.equal(projects[0].groupChatCount, 1);
assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]);
});
test("getProjectDetail marks missing chats and dangling partners", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
links: [
{ project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" },
{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" },
],
groupChats: [
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 },
],
partners: [],
});
const detail = await getProjectDetail(db, "proj-1");
assert.equal(detail.groupChats.length, 2);
const alive = detail.groupChats.find((chat) => chat.chatId === "wr-1");
assert.equal(alive.missing, false);
assert.equal(alive.partnerName, "", "dangling partner shows blank name");
const gone = detail.groupChats.find((chat) => chat.chatId === "wr-gone");
assert.equal(gone.missing, true);
assert.equal(gone.name, "");
});
test("getProjectDetail throws 404 for unknown project", async () => {
const db = fakeProjectDb();
await assert.rejects(
() => getProjectDetail(db, "proj-404"),
(error) => error instanceof ProjectError && error.status === 404,
);
});
test("setProjectGroupChats replaces links and dedupes chat ids", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
links: [{ project_id: "proj-1", chat_id: "wr-old", partner_id: "" }],
groupChats: [
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 },
],
partners: [{ id: "p-1", name: "张三", wecom_userid: "zhangsan" }],
});
const detail = await setProjectGroupChats(db, "proj-1", [
{ chatId: "wr-1", partnerId: "p-1" },
{ chatId: "wr-1", partnerId: "p-1" },
{ chatId: " ", partnerId: "" },
]);
assert.equal(detail.groupChats.length, 1);
assert.equal(detail.groupChats[0].chatId, "wr-1");
assert.equal(detail.groupChats[0].partnerName, "张三");
assert.equal(db.links.length, 1);
assert.equal(db.links[0].chat_id, "wr-1");
});
test("deleteProject removes links and unlinks tasks", async () => {
const db = fakeProjectDb({
projects: [
{
id: "proj-1",
name: "A",
brand: "",
main_group_member_ids: "[]",
status: "active",
note: "",
created_at: "2026-08-01",
},
],
links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }],
tasks: [
{ id: "t1", project_id: "proj-1" },
{ id: "t2", project_id: "proj-2" },
],
});
await deleteProject(db, "proj-1");
assert.equal(db.projects.length, 0);
assert.equal(db.links.length, 0);
assert.equal(db.tasks[0].project_id, "");
assert.equal(db.tasks[1].project_id, "proj-2");
await assert.rejects(
() => deleteProject(db, "proj-404"),
(error) => error instanceof ProjectError && error.status === 404,
);
});
test("saveGroupOwnerPartner inserts and updates group_owner partners", async () => {
const db = fakeProjectDb();
const created = await saveGroupOwnerPartner(db, {
name: "张三",
wecomUserid: "zhangsan",
});
assert.match(created.id, /^partner-go-/);
assert.equal(db.partners[0].partner_type, "group_owner");
const updated = await saveGroupOwnerPartner(db, {
id: created.id,
name: "张三丰",
wecomUserid: "zhangsanfeng",
});
assert.equal(updated.name, "张三丰");
assert.equal(db.partners.length, 1);
await assert.rejects(
() => saveGroupOwnerPartner(db, { name: "李四", wecomUserid: "" }),
(error) => error instanceof ProjectError,
);
});

View File

@@ -23,28 +23,20 @@ test("builds the KOC LOOP product shell", async () => {
await access(new URL("../.next/static", import.meta.url));
});
test("keeps distribution filters as always-visible fuzzy search fields", async () => {
test("merges distribution detail into the task center", async () => {
const [adminApp, globalCss] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(globalCss, /\.distribution-task-search\s*\{[^}]*flex-direction:\s*row/s);
assert.match(globalCss, /\.distribution-task-search\s*\{[^}]*margin:\s*0/s);
assert.match(globalCss, /\.distribution-task-search\s*>\s*span\s*\{[^}]*flex:\s*0\s+0\s+18px/s);
assert.match(globalCss, /\.distribution-task-filter-combobox\.brand\s*\{[^}]*flex-basis:\s*176px/s);
assert.match(globalCss, /\.distribution-task-filter-menu\s*\{[^}]*position:\s*absolute[^}]*z-index:\s*60/s);
assert.match(globalCss, /\.distribution-task-filter-input\s*\{[^}]*display:\s*flex[^}]*margin:\s*0/s);
assert.match(adminApp, /aria-expanded=\{openTaskFilter === filter\.key\}/);
assert.match(adminApp, /role="combobox"/);
assert.match(adminApp, /role="listbox"/);
assert.match(adminApp, /placeholder=\{`搜索\$\{filter\.label\}`\}/);
assert.match(adminApp, /setTaskFilterValue\(filter\.key, event\.target\.value\)/);
assert.doesNotMatch(adminApp, /distribution-task-filter-trigger/);
assert.doesNotMatch(adminApp, /distribution-task-filter-menu-search/);
assert.doesNotMatch(adminApp, /taskFilterQuery/);
assert.doesNotMatch(adminApp, /distribution-task-option-panel/);
assert.doesNotMatch(adminApp, /<select value=\{contentTypeFilter\}/);
assert.match(adminApp, /分发明细/);
assert.match(adminApp, /function TaskDistributionDetail/);
assert.match(adminApp, /选择一个数据回收任务/);
assert.doesNotMatch(adminApp, /"distributions"/);
assert.doesNotMatch(adminApp, /选择一个分发任务/);
assert.doesNotMatch(adminApp, /distribution-task-/);
assert.doesNotMatch(globalCss, /distribution-task-/);
assert.doesNotMatch(adminApp, /mode === "distribution"/);
});
test("stacks user management and securely removes departed accounts", async () => {
@@ -95,7 +87,6 @@ test("organizes distribution and recovery by task and imports the verified Feish
]);
const snapshot = JSON.parse(snapshotText);
assert.match(adminApp, /选择一个分发任务/);
assert.match(adminApp, /选择一个数据回收任务/);
assert.match(adminApp, /读取表格/);
assert.match(adminApp, /当前仅展示/);
@@ -219,12 +210,9 @@ test("issues external task links and supports one-to-one note submissions", asyn
assert.match(adminApp, /latest_publish_url/);
assert.match(adminApp, /通过作品查看主页/);
assert.match(adminApp, /platform-logo/);
assert.match(adminApp, /按任务名称模糊搜索/);
assert.match(adminApp, /全部品牌\/项目/);
assert.match(adminApp, /全部内容类型/);
assert.match(adminApp, /全部平台/);
assert.match(adminApp, /task-scope-subline/);
assert.match(adminApp, /识别失败,请手动填写/);
assert.match(adminApp, /分发明细/);
assert.match(adminApp, /AdminImageLightbox/);
assert.match(adminApp, /CreatorScreenshotPreview/);
assert.match(adminApp, /admin-image-lightbox/);
@@ -511,11 +499,12 @@ test("filters and exports the current KOC resource result set", 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/globals.css", 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-write.ts", 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/0007_account_current_contact.sql", import.meta.url), "utf8"),
@@ -528,7 +517,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_BYTES = 20 \* 1024 \* 1024/);
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(adminApp, /异常数据将自动跳过,不会导入/);
assert.match(
@@ -543,7 +532,7 @@ test("imports existing KOC resources through a validated spreadsheet preview", a
assert.match(importRoute, /跳过 \$\{summary\.error\} 条异常数据/);
assert.match(importRoute, /previewAnalyzedRows\(analyzed\)/);
assert.match(resourceParser, /当前自动解析仅支持小红书或抖音账号主页/);
assert.match(importRoute, /resolveProfileDetailsFromMcp/);
assert.match(resourceWrite, /resolveProfileDetailsFromMcp/);
assert.match(accountMigration, /cooperation_source/);
assert.match(profileMigration, /ADD COLUMN gender/);
assert.match(profileMigration, /ADD COLUMN bio/);

View File

@@ -0,0 +1,250 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildKocDirectMessage,
oneClickPushTask,
OneClickPushError,
} from "../lib/task-one-click-push-service.ts";
import {
clearWecomAccessTokenCacheForTests,
resolveWecomConfig,
} from "../lib/wecom-client.ts";
const baseBindings = {
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
};
const taskRow = {
id: "task-1",
name: "测试任务",
brand: "品牌A",
quantity: 3,
due_at: "2026-09-05",
task_type: "content_publish",
platform: "小红书",
content_format: "image_text",
share_token: "tok-123",
project_id: "",
};
const boundPartners = [
{ id: "p-1", name: "合作方甲", wecom_external_user_id: "wmOK1" },
{ id: "p-2", name: "合作方乙", wecom_external_user_id: "wmOK2" },
{ id: "p-3", name: "合作方丙", wecom_external_user_id: "wmFAIL" },
];
function fakePushDb({
task = taskRow,
partners = boundPartners,
todayLog = null,
} = {}) {
const executed = [];
const makeStatement = (sql, params = []) => ({
sql,
params,
bind(...args) {
return makeStatement(sql, args);
},
async run() {
executed.push({ sql, params });
return { meta: { changes: 1 } };
},
async first() {
executed.push({ sql, params });
if (/FROM tasks WHERE id = \?/.test(sql)) return task;
if (/FROM wecom_push_logs/.test(sql) && /DATE\(created_at\) = CURDATE\(\)/.test(sql)) {
return todayLog;
}
return null;
},
async all() {
executed.push({ sql, params });
if (/FROM partners/.test(sql) && /wecom_external_user_id/.test(sql)) {
return { results: partners };
}
return { results: [] };
},
});
return {
executed,
prepare(sql) {
return makeStatement(sql);
},
};
}
function fakeWecomFetch(calls, { invalidUsers = [] } = {}) {
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/message/send")) {
const body = JSON.parse(String(init.body));
if (invalidUsers.includes(body.touser)) {
return Response.json({
errcode: 0,
errmsg: "ok",
invaliduser: body.touser,
});
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
return new Response("not found", { status: 404 });
};
}
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
test("buildKocDirectMessage targets KOC with claim url and OAuth hint", () => {
const message = buildKocDirectMessage(taskRow, "https://portal.example.com/");
assert.match(message, /【新任务】测试任务/);
assert.match(message, /先到先得/);
assert.match(message, /免输身份/);
assert.match(message, /https:\/\/portal\.example\.com\/\?task=tok-123/);
});
test("oneClickPushTask sends only to selected partners and logs the push", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb();
const calls = [];
const outcome = await oneClickPushTask(
db,
{
taskId: "task-1",
sendMainGroup: false,
sendKoc: true,
createGroupTemplate: false,
selectedKocPartnerIds: ["p-1", "p-3"],
},
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls, { invalidUsers: ["wmFAIL"] }),
);
const appCalls = calls.filter((call) =>
call.url.pathname.endsWith("/cgi-bin/message/send"),
);
assert.deepEqual(
appCalls.map((call) => JSON.parse(String(call.init.body)).touser),
["wmOK1", "wmFAIL"],
);
for (const call of appCalls) {
assert.match(JSON.parse(String(call.init.body)).text.content, /【新任务】测试任务/);
}
assert.equal(outcome.kocSent, 1);
assert.equal(outcome.kocFailed, 1);
assert.equal(outcome.kocResults[1].partnerName, "合作方丙");
assert.match(outcome.kocResults[1].error, /invaliduser/);
assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123");
const insert = db.executed.find((item) => /INSERT INTO wecom_push_logs/.test(item.sql));
assert.ok(insert, "push log should be written");
assert.equal(insert.params[1], "task-1");
assert.equal(insert.params[3], "task_open");
assert.equal(insert.params[6], 1);
const kocUserids = JSON.parse(insert.params[5]);
assert.equal(kocUserids.length, 2);
assert.equal(kocUserids[0].externalUserId, "wmOK1");
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("oneClickPushTask blocks duplicate same-day push unless forced", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb({
todayLog: { id: "wpl-old", task_id: "task-1", push_kind: "task_open" },
});
const calls = [];
await assert.rejects(
() =>
oneClickPushTask(
db,
{ taskId: "task-1", sendMainGroup: false, sendKoc: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
),
(error) =>
error instanceof OneClickPushError && /仍要推送/.test(error.message) && error.status === 409,
);
assert.equal(calls.length, 0, "no wecom api calls on duplicate block");
const outcome = await oneClickPushTask(
db,
{
taskId: "task-1",
sendMainGroup: false,
sendKoc: true,
selectedKocPartnerIds: ["p-2"],
force: true,
},
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
);
assert.equal(outcome.kocSent, 1);
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("oneClickPushTask without project skips project targets and still reaches KOC", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb({ task: { ...taskRow, project_id: "" } });
const calls = [];
const outcome = await oneClickPushTask(
db,
{ taskId: "task-1", sendMainGroup: true, sendKoc: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
);
assert.ok(
outcome.skippedReasons.some((reason) => /未挂项目/.test(reason)),
"should explain project skip",
);
assert.equal(outcome.projectOutcome, null);
assert.equal(outcome.kocSent, 3);
assert.equal(
calls.filter((call) => call.url.pathname.includes("/cgi-bin/externalcontact/")).length,
0,
"no group template calls expected",
);
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("oneClickPushTask requires share token and at least one target", async () => {
await assert.rejects(
() =>
oneClickPushTask(
fakePushDb({ task: { ...taskRow, share_token: "" } }),
{ taskId: "task-1", sendMainGroup: false, sendKoc: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch([]),
),
(error) => error instanceof OneClickPushError && /领取链接/.test(error.message),
);
await assert.rejects(
() =>
oneClickPushTask(
fakePushDb(),
{ taskId: "task-1", sendMainGroup: false, sendKoc: false, createGroupTemplate: false },
resolveWecomConfig(baseBindings),
fakeWecomFetch([]),
),
(error) => error instanceof OneClickPushError && /至少选择一个推送目标/.test(error.message),
);
});

164
tests/task-report.test.mjs Normal file
View File

@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildTaskReportCsv } from "../lib/task-report-csv.ts";
import { getTaskReportData } from "../lib/mvp-db.ts";
const reportRow = {
id: "task-A",
name: "夏日种草计划",
task_type: "content_publish",
brand: "轻食品牌",
platform: "小红书",
status: "active",
due_at: "2026-08-20",
created_at: "2026-08-01 10:00:00",
quantity: 20,
project_name: "八月投放",
claimed_count: 10,
published_count: 8,
completed_count: 5,
exposure_total: 12000,
views_total: 9000,
d2_likes: 100,
d2_comments: 20,
d2_collects: 30,
d5_likes: 150,
d5_comments: 25,
d5_collects: 40,
d7_likes: 180,
d7_comments: 30,
d7_collects: 50,
latest_likes: 200,
latest_comments: 40,
latest_collects: 60,
latest_shares: 10,
};
test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
const csv = buildTaskReportCsv([reportRow]);
assert.ok(csv.startsWith(""), "starts with UTF-8 BOM");
const lines = csv.slice(1).split("\r\n");
assert.equal(lines.length, 2);
assert.equal(
lines[0],
[
"任务ID",
"任务名称",
"任务类型",
"品牌",
"平台",
"所属项目",
"任务状态",
"计划数量",
"领取数",
"发布数",
"完成数",
"完成率",
"截止日期",
"创建时间",
"曝光量合计",
"阅读量合计",
"D2点赞",
"D2评论",
"D2收藏",
"D5点赞",
"D5评论",
"D5收藏",
"D7点赞",
"D7评论",
"D7收藏",
"最新点赞",
"最新评论",
"最新收藏",
"最新转发",
].join(","),
);
assert.equal(
lines[1],
[
"task-A",
"夏日种草计划",
"内容发布",
"轻食品牌",
"小红书",
"八月投放",
"进行中",
20,
10,
8,
5,
"50%",
"2026-08-20",
"2026-08-01 10:00:00",
12000,
9000,
100,
20,
30,
150,
25,
40,
180,
30,
50,
200,
40,
60,
10,
].join(","),
);
});
test("buildTaskReportCsv escapes commas and quotes, blanks null metrics", () => {
const csv = buildTaskReportCsv([
{
...reportRow,
name: '含,逗号"引号"任务',
project_name: null,
claimed_count: 0,
exposure_total: null,
views_total: null,
d2_likes: null,
d2_comments: null,
d2_collects: null,
d5_likes: null,
d5_comments: null,
d5_collects: null,
d7_likes: null,
d7_comments: null,
d7_collects: null,
latest_likes: null,
latest_comments: null,
latest_collects: null,
latest_shares: null,
},
]);
const rowLine = csv.slice(1).split("\r\n")[1];
assert.ok(rowLine.includes('"含,逗号""引号""任务"'));
const cells = rowLine.split(",");
assert.equal(cells.at(-1), "", "null latest_shares becomes empty");
assert.equal(cells.at(-18), "", "no claims means empty completion rate");
assert.equal(cells.at(-16), "2026-08-01 10:00:00");
});
test("getTaskReportData aggregates per task via distributions join", async () => {
let capturedSql = "";
const db = {
prepare(sql) {
capturedSql = sql;
return {
async all() {
return { results: [reportRow] };
},
};
},
};
const rows = await getTaskReportData(db);
assert.deepEqual(rows, [reportRow]);
assert.match(capturedSql, /FROM tasks t/);
assert.match(capturedSql, /LEFT JOIN distributions d ON d\.task_id = t\.id/);
assert.match(capturedSql, /LEFT JOIN projects p ON p\.id = t\.project_id/);
assert.match(capturedSql, /GROUP BY t\.id/);
assert.match(capturedSql, /publish_url/);
assert.match(capturedSql, /latest_shares/);
});

View File

@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import {
decryptWecomCallback,
encryptWecomCallback,
resolveWecomCallbackAesKey,
verifyWecomCallbackSignature,
wecomSignaturesMatch,
WecomCallbackCryptoError,
} from "../lib/wecom-callback-crypto.ts";
import {
extractWecomEncryptField,
handleWecomCallbackEvent,
parseWecomCallbackXml,
} from "../lib/wecom-callback-events.ts";
const AES_KEY = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ";
const CORP_ID = "wwCallbackCorp01";
const TOKEN = "callback-token-test";
test("aes key resolution accepts 43-char key and rejects invalid input", () => {
const key = resolveWecomCallbackAesKey(AES_KEY);
assert.equal(key.length, 32);
assert.throws(
() => resolveWecomCallbackAesKey("short"),
WecomCallbackCryptoError,
);
assert.throws(
() => resolveWecomCallbackAesKey("abcdefghij;klmnopqrstuvwxyzABCDEFGHIJKLMNOP"),
WecomCallbackCryptoError,
);
});
test("encrypt → decrypt roundtrip preserves chinese message and receiveId", () => {
const plain =
'<xml><ToUserName><![CDATA[wwCallbackCorp01]]></ToUserName><Event><![CDATA[模板卡片]]></Event></xml>';
const encrypted = encryptWecomCallback(plain, CORP_ID, AES_KEY);
const decrypted = decryptWecomCallback(encrypted, AES_KEY);
assert.equal(decrypted.message, plain);
assert.equal(decrypted.receiveId, CORP_ID);
});
test("decrypt rejects tampered ciphertext", () => {
const encrypted = encryptWecomCallback("hello", CORP_ID, AES_KEY);
const corrupted = `${encrypted.slice(0, -4)}AAAA`;
assert.throws(
() => decryptWecomCallback(corrupted, AES_KEY),
WecomCallbackCryptoError,
);
});
test("decrypt rejects non-block-aligned ciphertext", () => {
assert.throws(
() => decryptWecomCallback("AAAAAAAA", AES_KEY),
WecomCallbackCryptoError,
);
});
test("signature = sha1 of sorted join, order independent", () => {
const encrypted = encryptWecomCallback("hello", CORP_ID, AES_KEY);
const expected = createHash("sha1")
.update([TOKEN, "1756700000", "nonce-abc", encrypted].sort().join(""))
.digest("hex");
const actual = verifyWecomCallbackSignature(
TOKEN,
"1756700000",
"nonce-abc",
encrypted,
);
assert.equal(actual, expected);
assert.ok(wecomSignaturesMatch(actual, expected));
assert.ok(!wecomSignaturesMatch(actual, `${expected}x`));
assert.ok(!wecomSignaturesMatch(actual, ""));
});
test("parse callback xml handles CDATA and plain fields", () => {
const body =
"<xml><ToUserName><![CDATA[wwCorp]]></ToUserName><Encrypt><![CDATA[RW5jcnlwdGVkQmFzZTY0PT0=]]></Encrypt><CreateTime>1756700000</CreateTime></xml>";
const fields = parseWecomCallbackXml(body);
assert.equal(fields.ToUserName, "wwCorp");
assert.equal(fields.Encrypt, "RW5jcnlwdGVkQmFzZTY0PT0=");
assert.equal(fields.CreateTime, "1756700000");
assert.equal(extractWecomEncryptField(body), "RW5jcnlwdGVkQmFzZTY0PT0=");
});
test("event dispatcher classifies template_card mark_handled and ignores others", async () => {
const inserts = [];
const fakeDb = {
prepare(sql) {
return {
bind(...params) {
return {
async run() {
inserts.push({ sql, params });
return { success: true };
},
};
},
};
},
};
const ack = await handleWecomCallbackEvent(fakeDb, {
MsgType: "event",
Event: "template_card_event",
TaskId: "task:t123:due",
BtnKey: "mark_handled",
FromUserName: "opsUser01",
});
assert.equal(ack.recognized, true);
assert.equal(ack.action, "card_ack");
assert.equal(ack.taskId, "task:t123:due");
assert.equal(ack.operator, "opsUser01");
assert.equal(inserts.length, 1);
assert.match(inserts[0].sql, /INSERT INTO wecom_push_logs/);
assert.equal(inserts[0].params[0].startsWith("wpl-"), true);
assert.equal(inserts[0].params[1], "t123");
const view = await handleWecomCallbackEvent(fakeDb, {
MsgType: "event",
Event: "template_card_event",
TaskId: "task:t123:due",
BtnKey: "view_task",
});
assert.equal(view.recognized, true);
assert.equal(view.action, "ignored");
const other = await handleWecomCallbackEvent(fakeDb, {
MsgType: "event",
Event: "change_contact",
});
assert.equal(other.recognized, false);
});

View File

@@ -1,7 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildWecomOAuthUrl,
clearWecomAccessTokenCacheForTests,
createGroupMsgTemplate,
exchangeWecomOAuthCode,
fetchWecomExternalContactName,
listCustomerGroupChats,
listExternalContacts,
resolveWecomConfig,
sendWecomAppMessage,
sendWecomRobotMessage,
@@ -215,3 +221,553 @@ test("access_token is cached across calls", async () => {
);
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),
/客户群列表为空/,
);
});
function fakeWecomOAuth(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/auth/getuserinfo")) {
return Response.json({
errcode: options.oauthErrcode ?? 0,
errmsg: options.oauthErrmsg ?? "ok",
...(options.userId ? { userid: options.userId } : {}),
...(options.externalUserId ? { external_userid: options.externalUserId } : {}),
});
}
if (url.pathname.endsWith("/cgi-bin/externalcontact/get")) {
const eid = url.searchParams.get("external_userid");
if (options.contactNames && options.contactNames[eid]) {
return Response.json({
errcode: 0,
errmsg: "ok",
external_contact: { external_userid: eid, name: options.contactNames[eid] },
});
}
return Response.json({ errcode: 60111, errmsg: "not found" });
}
return new Response("not found", { status: 404 });
};
return { calls, fetchImpl };
}
test("buildWecomOAuthUrl assembles authorize URL with encoded params", () => {
const url = new URL(
buildWecomOAuthUrl({
corpId: "corp-test",
agentId: "1000001",
redirectUri: "https://portal.example.com/koc/?task=abc",
state: "t=abc",
}),
);
assert.equal(url.origin, "https://open.weixin.qq.com");
assert.equal(url.pathname, "/connect/oauth2/authorize");
assert.equal(url.hash, "#wechat_redirect");
assert.equal(url.searchParams.get("appid"), "corp-test");
assert.equal(url.searchParams.get("agentid"), "1000001");
assert.equal(url.searchParams.get("response_type"), "code");
assert.equal(url.searchParams.get("scope"), "snsapi_base");
assert.equal(url.searchParams.get("state"), "t=abc");
assert.equal(
url.searchParams.get("redirect_uri"),
"https://portal.example.com/koc/?task=abc",
);
});
test("exchangeWecomOAuthCode returns external identity and posts code", async () => {
const { calls, fetchImpl } = fakeWecomOAuth({ externalUserId: "wmExt123" });
const identity = await exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-abc",
fetchImpl,
);
assert.deepEqual(identity, { externalUserId: "wmExt123" });
const call = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/auth/getuserinfo"),
);
assert.ok(call, "getuserinfo was called");
assert.equal(call.url.searchParams.get("access_token"), "token-test");
assert.equal(JSON.parse(String(call.init.body)).code, "code-abc");
});
test("exchangeWecomOAuthCode returns internal member userid", async () => {
const { fetchImpl } = fakeWecomOAuth({ userId: "zhangsan" });
const identity = await exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-abc",
fetchImpl,
);
assert.deepEqual(identity, { userId: "zhangsan" });
});
test("exchangeWecomOAuthCode throws 400 on invalid code (errcode 40029)", async () => {
const { fetchImpl } = fakeWecomOAuth({
oauthErrcode: 40029,
oauthErrmsg: "invalid code",
});
await assert.rejects(
() =>
exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-expired",
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 400);
return true;
},
);
});
test("exchangeWecomOAuthCode throws when provider returns no identity", async () => {
const { fetchImpl } = fakeWecomOAuth();
await assert.rejects(
() =>
exchangeWecomOAuthCode(
resolveWecomConfig(baseBindings),
"code-abc",
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 400);
return true;
},
);
});
test("exchangeWecomOAuthCode reuses cached access_token", async () => {
const { calls, fetchImpl } = fakeWecomOAuth({ externalUserId: "wmExt123" });
const config = resolveWecomConfig(baseBindings);
await exchangeWecomOAuthCode(config, "code-1", fetchImpl);
await exchangeWecomOAuthCode(config, "code-2", fetchImpl);
assert.equal(
calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/gettoken")).length,
1,
);
});
test("fetchWecomExternalContactName resolves name and falls back to null", async () => {
const okImpl = fakeWecomOAuth({ contactNames: { wmExt123: "张三" } });
assert.equal(
await fetchWecomExternalContactName(
resolveWecomConfig(baseBindings),
"wmExt123",
okImpl.fetchImpl,
),
"张三",
);
const failImpl = fakeWecomOAuth();
assert.equal(
await fetchWecomExternalContactName(
resolveWecomConfig(baseBindings),
"wmMissing",
failImpl.fetchImpl,
),
null,
);
assert.equal(
await fetchWecomExternalContactName(
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
"wmExt123",
failImpl.fetchImpl,
),
null,
);
});

View File

@@ -0,0 +1,361 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
resolveAutoReleaseDays,
runOverdueAutoRelease,
runOverdueEscalation,
} from "../lib/wecom-notifier-service.ts";
import {
clearWecomAccessTokenCacheForTests,
resolveWecomConfig,
} from "../lib/wecom-client.ts";
const config = resolveWecomConfig({
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
WECOM_ROBOT_WEBHOOK: "https://qyapi.example.com/webhook/send?key=abc",
});
const TODAY = "2026-09-01";
const NOW = new Date(`${TODAY}T02:00:00Z`).getTime();
const overdueRows = [
{
id: "d-1",
task_id: "task-A",
task_name: "超期任务A",
due_at: "2026-08-20",
task_type: "content_publish",
publish_url: null,
result_submitted_at: null,
project_id: "proj-1",
partner_id: "p-1",
partner_name: "合作方甲",
},
{
id: "d-2",
task_id: "task-A",
task_name: "超期任务A",
due_at: "2026-08-20",
task_type: "content_publish",
publish_url: null,
result_submitted_at: null,
project_id: "proj-1",
partner_id: "p-2",
partner_name: "合作方乙",
},
{
id: "d-3",
task_id: "task-B",
task_name: "无项目任务B",
due_at: "2026-08-25",
task_type: "content_publish",
publish_url: null,
result_submitted_at: null,
project_id: "",
partner_id: "p-3",
partner_name: "合作方丙",
},
];
const releasableDistribution = {
id: "d-1",
task_id: "task-A",
task_type: "content_publish",
content_id: "c-1",
content_title: "超期内容",
partner_id: "p-1",
partner_name: "合作方甲",
claim_id: null,
delegation_bundle_id: null,
publish_url: null,
result_submitted_at: null,
};
function filterOverdue(rows, cutoff) {
return rows.filter((row) => {
if (!(row.due_at && row.due_at < cutoff)) return false;
return row.task_type === "screenshot_collect"
? !row.result_submitted_at
: !row.publish_url;
});
}
function fakeEscalationDb({ rows = overdueRows } = {}) {
return {
prepare(sql) {
return {
bind(...params) {
return {
async first() {
if (/FROM projects WHERE id = \?/.test(sql)) {
return { main_group_member_ids: JSON.stringify(["opsZhang", "opsLi"]) };
}
return null;
},
async all() {
if (
/FROM distributions d/.test(sql) &&
/result_submitted_at IS NULL/.test(sql)
) {
return { results: filterOverdue(rows, params[0]) };
}
return { results: [] };
},
};
},
};
},
};
}
function fakeReleaseDb({ rows = overdueRows, blockedIds = [] } = {}) {
const executed = [];
const inner = {
prepare(sql) {
return {
bind(...params) {
return {
async first() {
if (/FROM projects WHERE id = \?/.test(sql)) {
return { main_group_member_ids: JSON.stringify(["opsZhang"]) };
}
if (/FOR UPDATE/.test(sql)) {
const row = rows.find((item) => item.id === params[0]);
if (!row) return null;
return {
...releasableDistribution,
id: row.id,
task_id: row.task_id,
partner_id: row.partner_id,
partner_name: row.partner_name,
result_submitted_at: blockedIds.includes(row.id)
? "2026-08-30 10:00:00"
: null,
};
}
return null;
},
async all() {
if (
/FROM distributions d/.test(sql) &&
/result_submitted_at IS NULL/.test(sql)
) {
return { results: filterOverdue(rows, params[0]) };
}
return { results: [] };
},
async run() {
executed.push({ sql, params });
return { meta: { changes: 1 } };
},
};
},
};
},
};
return {
executed,
...inner,
async transaction(fn) {
return fn(inner);
},
};
}
function wecomFetch(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/message/send") ||
url.host === "qyapi.example.com"
) {
return Response.json({ errcode: 0, errmsg: "ok" });
}
return new Response("not found", { status: 404 });
};
}
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
delete process.env.WECOM_AUTO_RELEASE_DAYS;
delete process.env.APP_ORIGIN;
});
test("resolveAutoReleaseDays parses bounds and defaults to disabled", () => {
assert.equal(resolveAutoReleaseDays(undefined), 0);
assert.equal(resolveAutoReleaseDays(""), 0);
assert.equal(resolveAutoReleaseDays("0"), 0);
assert.equal(resolveAutoReleaseDays("-5"), 0);
assert.equal(resolveAutoReleaseDays("abc"), 0);
assert.equal(resolveAutoReleaseDays("7"), 7);
assert.equal(resolveAutoReleaseDays("120"), 90);
});
test("runOverdueEscalation posts robot digest and template cards per project", async () => {
const calls = [];
const summary = await runOverdueEscalation(
fakeEscalationDb(),
config,
wecomFetch(calls),
NOW,
);
assert.equal(summary.overdueTasks, 2);
assert.equal(summary.overdueDistributions, 3);
assert.equal(summary.robotSent, true);
const robotBodies = calls
.filter((call) => call.url.host === "qyapi.example.com")
.map((call) => JSON.parse(String(call.init.body)));
assert.equal(robotBodies.length, 1);
assert.match(robotBodies[0].text.content, /【超期未发布升级】/);
assert.match(robotBodies[0].text.content, /超期任务A/);
assert.match(robotBodies[0].text.content, /无项目任务B/);
const cardCalls = calls.filter((call) =>
call.url.pathname.endsWith("/cgi-bin/message/send"),
);
assert.equal(cardCalls.length, 1, "only the project task gets a card");
const body = JSON.parse(String(cardCalls[0].init.body));
assert.equal(body.msgtype, "template_card");
assert.equal(body.template_card.task_id, "task:task-A:due");
assert.equal(body.touser, "opsZhang|opsLi");
const buttons = body.template_card.button_list;
assert.equal(buttons.length, 2);
assert.ok(buttons.some((button) => button.type === 0 && button.key === "mark_handled"));
assert.equal(summary.cardSent, 2);
assert.equal(summary.cardFailed, 0);
});
test("runOverdueEscalation no-ops without overdue rows", async () => {
const calls = [];
const summary = await runOverdueEscalation(
fakeEscalationDb({ rows: [] }),
config,
wecomFetch(calls),
NOW,
);
assert.equal(summary.overdueDistributions, 0);
assert.equal(summary.robotSent, false);
assert.equal(calls.length, 0);
});
test("runOverdueEscalation distinguishes screenshot tasks from content tasks", async () => {
const rows = [
{
id: "d-s1",
task_id: "task-S",
task_name: "截图任务S",
due_at: "2026-08-20",
task_type: "screenshot_collect",
publish_url: null,
result_submitted_at: null,
project_id: "",
partner_id: "p-1",
partner_name: "合作方甲",
},
{
id: "d-s2",
task_id: "task-S",
task_name: "截图任务S",
due_at: "2026-08-20",
task_type: "screenshot_collect",
publish_url: null,
result_submitted_at: "2026-08-21 09:00:00",
project_id: "",
partner_id: "p-2",
partner_name: "合作方乙",
},
{
id: "d-c1",
task_id: "task-A",
task_name: "内容任务A",
due_at: "2026-08-20",
task_type: "content_publish",
publish_url: "https://www.xiaohongshu.com/note/1",
result_submitted_at: null,
project_id: "",
partner_id: "p-3",
partner_name: "合作方丙",
},
{
id: "d-c2",
task_id: "task-A",
task_name: "内容任务A",
due_at: "2026-08-20",
task_type: "content_publish",
publish_url: null,
result_submitted_at: null,
project_id: "",
partner_id: "p-4",
partner_name: "合作方丁",
},
];
const calls = [];
const summary = await runOverdueEscalation(
fakeEscalationDb({ rows }),
config,
wecomFetch(calls),
NOW,
);
// 已提交截图的 d-s2 与已回填链接的 d-c1 不算超期
assert.equal(summary.overdueTasks, 2);
assert.equal(summary.overdueDistributions, 2);
const robotBodies = calls
.filter((call) => call.url.host === "qyapi.example.com")
.map((call) => JSON.parse(String(call.init.body)));
assert.equal(robotBodies.length, 1);
assert.match(robotBodies[0].text.content, /截图任务S(截止 2026-08-20):1 条/);
assert.match(robotBodies[0].text.content, /内容任务A(截止 2026-08-20):1 条/);
});
test("runOverdueAutoRelease is disabled without env switch", async () => {
const calls = [];
const db = fakeReleaseDb();
const summary = await runOverdueAutoRelease(db, config, wecomFetch(calls), NOW);
assert.equal(summary.enabled, false);
assert.equal(summary.released, 0);
assert.equal(calls.length, 0);
assert.equal(db.executed.length, 0);
});
test("runOverdueAutoRelease releases overdue rows and skips blocked ones", async () => {
process.env.WECOM_AUTO_RELEASE_DAYS = "10";
const calls = [];
const db = fakeReleaseDb({ blockedIds: ["d-3"] });
const summary = await runOverdueAutoRelease(db, config, wecomFetch(calls), NOW);
// cutoff = 2026-08-22:d-1/d-2(08-20)命中,d-3(08-25)不在窗口内
assert.equal(summary.enabled, true);
assert.equal(summary.cutoff, "2026-08-22");
assert.equal(summary.released, 2);
assert.equal(summary.blocked, 0);
assert.ok(
db.executed.some((item) => /UPDATE contents SET status = 'available'/.test(item.sql)),
"released content returns to pool",
);
const robotBodies = calls
.filter((call) => call.url.host === "qyapi.example.com")
.map((call) => JSON.parse(String(call.init.body)));
assert.equal(robotBodies.length, 1);
assert.match(robotBodies[0].text.content, /已释放 2 条/);
});
test("runOverdueAutoRelease counts blocked rows as skipped", async () => {
process.env.WECOM_AUTO_RELEASE_DAYS = "3";
const db = fakeReleaseDb({ blockedIds: ["d-1", "d-2", "d-3"] });
const summary = await runOverdueAutoRelease(db, config, wecomFetch([]), NOW);
assert.equal(summary.cutoff, "2026-08-29");
assert.equal(summary.released, 0);
assert.equal(summary.blocked, 3);
assert.equal(summary.failed, 0);
});

View File

@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildCollectionFinishedMessage,
buildPublishSubmittedMessage,
notifyCollectionFinished,
notifyPartnerSubmitted,
} from "../lib/wecom-feedback-service.ts";
import { resolveWecomConfig } from "../lib/wecom-client.ts";
const config = resolveWecomConfig({
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
WECOM_ROBOT_WEBHOOK: "https://qyapi.example.com/webhook/send?key=abc",
});
const submittedRow = {
claimant_name: "小王",
partner_name: "合作方甲",
task_name: "夏日轻盈计划",
content_title: "好物分享笔记",
publish_url: "https://www.xiaohongshu.com/explore/abc",
};
function robotFetch(calls) {
return async (input, init = {}) => {
const url = new URL(String(input));
calls.push({ url, init });
return Response.json({ errcode: 0, errmsg: "ok" });
};
}
function fakeSubmittedDb(row = submittedRow) {
return {
prepare(sql) {
return {
bind() {
return {
async first() {
if (/FROM distributions d/.test(sql)) return row;
return null;
},
};
},
};
},
};
}
test("publish submitted message covers partner, task and link", () => {
const message = buildPublishSubmittedMessage(submittedRow, "publish");
assert.match(message, /【KOC 已提交发布】/);
assert.match(message, /合作方:合作方甲/);
assert.match(message, /夏日轻盈计划/);
assert.match(message, /xiaohongshu\.com\/explore\/abc/);
const screenshot = buildPublishSubmittedMessage(submittedRow, "screenshot");
assert.match(screenshot, /【KOC 已提交截图】/);
assert.doesNotMatch(screenshot, /链接:/);
});
test("collection digest includes failure hint only when failed", () => {
const ok = buildCollectionFinishedMessage({
currentDate: "2026-09-01",
dueTasks: 3,
attempted: 10,
succeeded: 10,
failed: 0,
});
assert.match(ok, /成功 10、失败 0/);
assert.doesNotMatch(ok, /重试/);
const bad = buildCollectionFinishedMessage({
currentDate: "2026-09-01",
dueTasks: 3,
attempted: 10,
succeeded: 7,
failed: 3,
});
assert.match(bad, /重试/);
});
test("notifyPartnerSubmitted posts robot message and skips without webhook", async () => {
const calls = [];
const sent = await notifyPartnerSubmitted(
fakeSubmittedDb(),
{ distributionId: "d-1", kind: "publish" },
config,
robotFetch(calls),
);
assert.equal(sent, true);
assert.equal(calls.length, 1);
const body = JSON.parse(String(calls[0].init.body));
assert.equal(body.msgtype, "text");
assert.match(body.text.content, /【KOC 已提交发布】/);
const noWebhook = await notifyPartnerSubmitted(
fakeSubmittedDb(),
{ distributionId: "d-1", kind: "publish" },
resolveWecomConfig({}),
robotFetch([]),
);
assert.equal(noWebhook, false);
});
test("notifyCollectionFinished skips empty batches and posts on activity", async () => {
const calls = [];
const idle = await notifyCollectionFinished(
{ currentDate: "2026-09-01", dueTasks: 0, attempted: 0, succeeded: 0, failed: 0 },
config,
robotFetch(calls),
);
assert.equal(idle, false);
assert.equal(calls.length, 0);
const active = await notifyCollectionFinished(
{ currentDate: "2026-09-01", dueTasks: 2, attempted: 5, succeeded: 4, failed: 1 },
config,
robotFetch(calls),
);
assert.equal(active, true);
assert.equal(calls.length, 1);
assert.match(JSON.parse(String(calls[0].init.body)).text.content, /成功 4、失败 1/);
});

View File

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

View File

@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getWecomOAuthSecret,
resolveWecomClaimant,
signWecomIdentityTicket,
verifyWecomIdentityTicket,
wecomClaimantPartnerId,
WECOM_IDENTITY_TTL_MS,
} from "../lib/wecom-oauth-service.ts";
const SECRET = "internal-token-test";
function fakePartnerDb(partners = []) {
return {
prepare(sql) {
return {
bind(...params) {
return {
async first() {
if (/WHERE wecom_external_user_id = \?/.test(sql)) {
const row = partners.find(
(item) => item.wecom_external_user_id === params[0],
);
return row ? { id: row.id } : null;
}
return null;
},
};
},
};
},
};
}
test("identity ticket sign → verify roundtrip", async () => {
const { ticket } = await signWecomIdentityTicket("wmExt123", "张三", {
secret: SECRET,
});
const identity = await verifyWecomIdentityTicket(ticket, { secret: SECRET });
assert.deepEqual(identity, { externalUserId: "wmExt123", displayName: "张三" });
});
test("identity ticket falls back to default display name when empty", async () => {
const { ticket } = await signWecomIdentityTicket("wmExt123", " ", {
secret: SECRET,
});
const identity = await verifyWecomIdentityTicket(ticket, { secret: SECRET });
assert.equal(identity.displayName, "企微客户");
});
test("identity ticket default TTL is 30 minutes and expiresIn matches", async () => {
const now = Date.now();
const { ticket, expiresIn } = await signWecomIdentityTicket("wmExt123", "张三", {
secret: SECRET,
nowMs: now,
});
assert.equal(expiresIn, 30 * 60);
const identity = await verifyWecomIdentityTicket(ticket, {
secret: SECRET,
nowMs: now + WECOM_IDENTITY_TTL_MS - 1,
});
assert.ok(identity, "still valid just before expiry");
const expired = await verifyWecomIdentityTicket(ticket, {
secret: SECRET,
nowMs: now + WECOM_IDENTITY_TTL_MS + 61_000,
});
assert.equal(expired, null, "expired beyond grace window");
});
test("identity ticket passes within 60s grace window", async () => {
const now = Date.now();
const { ticket } = await signWecomIdentityTicket("wmExt123", "张三", {
secret: SECRET,
nowMs: now,
});
const identity = await verifyWecomIdentityTicket(ticket, {
secret: SECRET,
nowMs: now + WECOM_IDENTITY_TTL_MS + 30_000,
});
assert.deepEqual(identity?.externalUserId, "wmExt123");
});
test("identity ticket verification fails on wrong secret", async () => {
const { ticket } = await signWecomIdentityTicket("wmExt123", "张三", {
secret: SECRET,
});
assert.equal(
await verifyWecomIdentityTicket(ticket, { secret: "other-secret" }),
null,
);
});
test("identity ticket verification fails on tampered payload", async () => {
const { ticket } = await signWecomIdentityTicket("wmExt123", "张三", {
secret: SECRET,
});
const [payload, signature] = ticket.split(".");
const decoded = JSON.parse(
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(
"utf8",
),
);
decoded.ext = "wmVictim";
const forgedPayload = Buffer.from(JSON.stringify(decoded))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
assert.equal(
await verifyWecomIdentityTicket(`${forgedPayload}.${signature}`, {
secret: SECRET,
}),
null,
);
});
test("identity ticket verification fails on malformed input", async () => {
for (const bad of ["", "no-dot", ".abc", "abc.", "a b.c d", "xxx.yyy"]) {
assert.equal(
await verifyWecomIdentityTicket(bad, { secret: SECRET }),
null,
`rejects ${JSON.stringify(bad)}`,
);
}
});
test("getWecomOAuthSecret throws when ADMIN_INTERNAL_TOKEN missing", () => {
const previous = process.env.ADMIN_INTERNAL_TOKEN;
delete process.env.ADMIN_INTERNAL_TOKEN;
try {
assert.throws(() => getWecomOAuthSecret(), /ADMIN_INTERNAL_TOKEN/);
} finally {
if (previous !== undefined) process.env.ADMIN_INTERNAL_TOKEN = previous;
}
});
test("wecomClaimantPartnerId derives stable partner-ext id", () => {
const first = wecomClaimantPartnerId("wmExt123");
assert.match(first, /^partner-ext-[a-z0-9]+$/);
assert.equal(first, wecomClaimantPartnerId("wmExt123"));
assert.notEqual(first, wecomClaimantPartnerId("wmExt456"));
});
test("resolveWecomClaimant reuses partner bound by operator", async () => {
const db = fakePartnerDb([
{ id: "partner-9", wecom_external_user_id: "wmExt123" },
]);
const resolved = await resolveWecomClaimant(db, "wmExt123");
assert.deepEqual(resolved, { partnerId: "partner-9", matchedExisting: true });
});
test("resolveWecomClaimant derives new id when unbound", async () => {
const db = fakePartnerDb();
const resolved = await resolveWecomClaimant(db, "wmExt123");
assert.equal(resolved.matchedExisting, false);
assert.equal(resolved.partnerId, wecomClaimantPartnerId("wmExt123"));
});