6 Commits

Author SHA1 Message Date
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
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
51 changed files with 4401 additions and 511 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*

58
.env.app.example Normal file
View File

@@ -0,0 +1,58 @@
# 复制为 .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
# ── 调度与入口 ────────────────────────────────────────────
# 每天北京时间 09:00 自动执行采集计划,生产保持 true。
ENABLE_SCHEDULER=true
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/
# debug
npm-debug.log*
@@ -31,9 +33,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

133
CLAUDE.md Normal file
View File

@@ -0,0 +1,133 @@
# 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(可选)
```
## 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

@@ -41,9 +41,17 @@ import {
resolveWecomConfig,
sendWecomAppMessage,
sendWecomRobotMessage,
listExternalContacts,
WecomClientError,
type WecomBindings,
} from "../../../lib/wecom-client";
import { runDueSoonWecomNotifications } from "../../../lib/wecom-notifier-service";
import {
listGroupChatRows,
listGroupPushes,
pushTaskToGroupChats,
syncGroupChats,
} from "../../../lib/wecom-group-push-service";
import { isManagerRequest } from "../../../lib/user-auth";
import { extractPublishUrl } from "../../../lib/publish-url";
@@ -546,21 +554,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 },
);
}
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 +620,72 @@ 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 === "wecom_list_external_contacts") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const contacts = await listExternalContacts(wecomConfig);
return Response.json({ contacts });
} else if (body.action === "wecom_sync_group_chats") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const groups = await syncGroupChats(db, wecomConfig);
return Response.json({ groups, syncedCount: groups.length });
} else if (body.action === "wecom_push_task_to_groups") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const summary = await pushTaskToGroupChats(
db,
{
taskId: String(body.taskId ?? ""),
chatIds: body.chatIds,
text: body.text,
},
wecomConfig,
);
const failCount = summary.results.reduce(
(total, item) => total + item.failList.length,
0,
);
const groupCount = summary.results.reduce(
(total, item) => total + item.chatCount,
0,
);
return Response.json({
results: summary.results,
groupCount,
failCount,
hint: "群发任务已创建,群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群",
});
} else if (body.action === "wecom_task_group_pushes") {
const taskId = String(body.taskId ?? "").trim();
const pushes = await listGroupPushes(db, taskId || undefined);
return Response.json({ pushes });
} else if (body.action === "wecom_group_chats") {
const groups = await listGroupChatRows(db);
return Response.json({ groups });
} else {
return Response.json({ error: "不支持的操作" }, { status: 400 });
}

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

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

View File

@@ -21,6 +21,7 @@ export const partners = mysqlTable("partners", {
owner: varchar("owner", { length: 255 }).notNull().default("运营组"),
claimedTotal: int("claimed_total").notNull().default(0),
completedTotal: int("completed_total").notNull().default(0),
wecomExternalUserId: varchar("wecom_external_user_id", { length: 128 }),
createdAt: timestamp("created_at"),
});
@@ -252,6 +253,35 @@ 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 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

32
dev.vars_exp Normal file
View File

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

View File

@@ -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,12 @@ 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` |
企业微信变量全部选填:不填则临期催办与汇总都跳过,不影响其他功能。所有变量都可以在「合作方」页面顶部「企业微信连通性」面板查看就绪状态,并用「测试群机器人」「测试发送」按钮验证。
密钥必须由密码管理器生成,禁止提交到 Git、聊天、部署日志或 URL。三个业务密钥 `ADMIN_INTERNAL_TOKEN`、`KOC_MCP_API_KEY`、`AI_TOOL_CENTER_MCP_KEY` 不得复用。
@@ -62,14 +75,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 +203,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 +226,57 @@ docker compose --env-file .env.self-hosted \
| 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 |
生产日志不得打印数据库密码、飞书 Secret、MCP key 或完整带 key 的采集服务 URL。
## 11. 企业微信自建应用与群机器人配置
KOC LOOP 的企业微信通知是**单向外发**:每天 09:00 与定时采集一同触发,给合作方绑定的外部联系人推送临期催办,再向群机器人发送当日汇总。不需要 OAuth 回调,也不需要拉通讯录。
### 11.1 群机器人(用于当日催办汇总)
1. 在企业微信里建立一个用于催办的群;
2. 群设置 → 群机器人 → 添加机器人 → 复制 webhook 地址,形如 `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...`;
3. 把整个 webhook 写入 `WECOM_ROBOT_WEBHOOK`。
只要这一项就绪,每天 09:00 的催办汇总就会推到该群。
### 11.2 自建应用(用于给单个合作方发应用消息)
1. 登录企业微信管理后台 → 应用管理 → 自建 → 创建应用,名称建议 `KOC LOOP 催办`;
2. 应用详情页记下 `AgentId`,填入 `WECOM_AGENT_ID`;
3. 我的企业 → 企业信息 → 企业 ID,填入 `WECOM_CORP_ID`;
4. 应用详情页 → Secret → 发送 Secret 到管理端,填入 `WECOM_SECRET`;
5. 应用「可见范围」必须包含合作方对应的企业微信成员,否则推送时会返回 `invaliduser`。
### 11.3 绑定外部联系人
企业微信应用消息推送需要 `external_userid`(外部联系人 ID),KOC LOOP 不自动拉取,需要手动绑定:
1. 在企业微信「客户联系 → 外部联系人」里找到合作方对应的客户;
2. 复制其 external_userid(形如 `woxxxxxx`);
3. 在 KOC LOOP 后台「合作方」页面,找到对应合作方行,点「绑定」粘贴 ID → 「保存」;
4. 点「测试发送」验证;如果返回 `应用消息发送失败`,多半是应用可见范围未包含该外部联系人,或 external_userid 复制错了。
外部联系人 ID 不入库后不会自动同步企业微信端的变更;如果合作方的外部联系人在企业微信里被删除或转移,需要回来更新这一列。
### 11.4 验证
部署后到后台「合作方」页面,顶部「企业微信连通性」面板应显示三项就绪状态:群机器人、自建应用消息、临期阈值。逐项点「测试」:
- 「测试群机器人」:群内收到 `[KOC LOOP 测试] 群机器人连通性正常,时间 ...` 即配置成功;
- 合作方行的「测试发送」:对应外部联系人收到同样格式的测试消息即绑定正确。
如果面板显示「未配置」但已填环境变量,先确认容器加载了新的 `.env.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 会进入失败列表但不影响其他群。

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

@@ -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

@@ -1484,3 +1484,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(

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,13 +13,11 @@ 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;

View File

@@ -25,6 +25,31 @@ type WecomEnvelope = {
access_token?: string;
expires_in?: number;
invaliduser?: string;
follow_user?: Array<{ userid: string }>;
external_userid?: string[];
external_contact?: {
external_userid?: string;
name?: string;
avatar?: string;
corp_fullname?: string;
};
next_cursor?: string;
group_chat_list?: Array<{ chat_id?: string; status?: number }>;
group_chat?: {
name?: string;
owner?: string;
member_count?: number;
};
fail_list?: string[];
msgid?: string;
};
export type WecomExternalContact = {
externalUserId: string;
name: string;
avatar: string;
corpName: string;
ownerUserId: string;
};
type CachedAccessToken = {
@@ -217,3 +242,219 @@ export async function sendWecomAppMessage(
export function clearWecomAccessTokenCacheForTests() {
cachedAccessToken = null;
}
export type WecomGroupChat = {
chatId: string;
name: string;
ownerUserId: string;
memberCount: number;
status: number;
};
export async function listCustomerGroupChats(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<WecomGroupChat[]> {
if (!hasAppCredentials(config)) return [];
const token = await fetchAccessToken(config, fetchImpl);
const chatIds: Array<{ chatId: string; status: number }> = [];
let cursor = "";
do {
const listUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/groupchat/list?access_token=${encodeURIComponent(token)}`;
const listResp = await fetchImpl(listUrl, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ limit: 100, cursor }),
signal: AbortSignal.timeout(12_000),
});
const listPayload = await readEnvelope(listResp, "获取企业微信客户群列表失败");
if (!listResp.ok) {
throw new WecomClientError(
`获取企业微信客户群列表失败(HTTP ${listResp.status})`,
502,
);
}
ensureOk(listPayload, "获取企业微信客户群列表失败");
for (const chat of listPayload.group_chat_list ?? []) {
const chatId = bindingValue(chat.chat_id);
if (chatId) chatIds.push({ chatId, status: Number(chat.status ?? 0) });
}
cursor = bindingValue(listPayload.next_cursor);
} while (cursor);
const groups: WecomGroupChat[] = [];
for (const { chatId, status } of chatIds) {
if (status !== 0) continue;
const getUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/groupchat/get?access_token=${encodeURIComponent(token)}`;
const getResp = await fetchImpl(getUrl, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ chat_id: chatId }),
signal: AbortSignal.timeout(12_000),
});
const getPayload = await readEnvelope(getResp, "获取企业微信客户群详情失败");
if (!getResp.ok) continue;
if (Number(getPayload.errcode ?? 0) !== 0) continue;
const detail = getPayload.group_chat;
groups.push({
chatId,
name: bindingValue(detail?.name),
ownerUserId: bindingValue(detail?.owner),
memberCount: Number(detail?.member_count ?? 0),
status,
});
}
return groups;
}
export type WecomGroupMsgLink = {
title: string;
desc?: string;
url: string;
picurl?: string;
};
export async function createGroupMsgTemplate(
config: WecomConfig,
params: {
sender: string;
chatIdList: string[];
text?: string;
link?: WecomGroupMsgLink;
},
fetchImpl: FetchLike = fetch,
): Promise<{ msgid: string; failList: string[] }> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const chatIdList = params.chatIdList.map(bindingValue).filter(Boolean);
if (chatIdList.length === 0) {
throw new WecomClientError("客户群列表为空", 400);
}
const sender = bindingValue(params.sender);
if (!sender) {
throw new WecomClientError("发送成员(群主 userid)不能为空", 400);
}
const text = String(params.text ?? "").trim();
const link = params.link;
if (!text && !link) {
throw new WecomClientError("文本与图文附件不能同时为空", 400);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/add_msg_template?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
chat_type: "group",
chat_id_list: chatIdList,
sender,
...(text ? { text: { content: text } } : {}),
...(link
? {
attachments: [
{
msgtype: "link",
link: {
title: link.title,
desc: link.desc ?? "",
url: link.url,
picurl: link.picurl ?? "",
},
},
],
}
: {}),
}),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "创建企业群发失败");
if (!response.ok) {
throw new WecomClientError(
`创建企业群发失败(HTTP ${response.status})`,
502,
);
}
ensureOk(payload, "创建企业群发失败");
return {
msgid: bindingValue(payload.msgid),
failList: (payload.fail_list ?? []).filter(Boolean),
};
}
export async function listExternalContacts(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<WecomExternalContact[]> {
if (!hasAppCredentials(config)) return [];
const token = await fetchAccessToken(config, fetchImpl);
const followUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get_follow_user_list?access_token=${encodeURIComponent(token)}`;
const followResp = await fetchImpl(followUrl, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const followPayload = await readEnvelope(
followResp,
"获取企业微信跟进人列表失败",
);
if (!followResp.ok) {
throw new WecomClientError(
`获取企业微信跟进人列表失败(HTTP ${followResp.status})`,
502,
);
}
ensureOk(followPayload, "获取企业微信跟进人列表失败");
const ownerUserIds = (followPayload.follow_user ?? [])
.map((user) => bindingValue(user.userid))
.filter(Boolean);
const ownerToExternalIds: Array<{ externalUserId: string; ownerUserId: string }> = [];
for (const ownerUserId of ownerUserIds) {
const listUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/list?access_token=${encodeURIComponent(token)}&userid=${encodeURIComponent(ownerUserId)}`;
const listResp = await fetchImpl(listUrl, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const listPayload = await readEnvelope(
listResp,
"获取企业微信外部联系人列表失败",
);
if (!listResp.ok) continue;
if (Number(listPayload.errcode ?? 0) !== 0) continue;
const externalIds = (listPayload.external_userid ?? []).filter(Boolean);
for (const externalUserId of externalIds) {
ownerToExternalIds.push({ externalUserId, ownerUserId });
}
}
const contacts: WecomExternalContact[] = [];
for (const { externalUserId, ownerUserId } of ownerToExternalIds) {
const getUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get?access_token=${encodeURIComponent(token)}&external_userid=${encodeURIComponent(externalUserId)}`;
const getResp = await fetchImpl(getUrl, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const getPayload = await readEnvelope(
getResp,
"获取企业微信外部联系人详情失败",
);
if (!getResp.ok) continue;
if (Number(getPayload.errcode ?? 0) !== 0) continue;
const info = getPayload.external_contact;
if (!info) continue;
contacts.push({
externalUserId,
name: bindingValue(info.name),
avatar: bindingValue(info.avatar),
corpName: bindingValue(info.corp_fullname) || bindingValue(info.name),
ownerUserId,
});
}
return contacts;
}

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

@@ -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

@@ -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,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

@@ -510,11 +510,12 @@ test("filters and exports the current KOC resource result set", async () => {
});
test("imports existing KOC resources through a validated spreadsheet preview", async () => {
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"),
@@ -527,7 +528,7 @@ test("imports existing KOC resources through a validated spreadsheet preview", a
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_ROWS = 10_000/);
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_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(
@@ -542,7 +543,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

@@ -2,6 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
clearWecomAccessTokenCacheForTests,
createGroupMsgTemplate,
listCustomerGroupChats,
listExternalContacts,
resolveWecomConfig,
sendWecomAppMessage,
sendWecomRobotMessage,
@@ -215,3 +218,390 @@ 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),
/客户群列表为空/,
);
});

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;
}
});