38 Commits

Author SHA1 Message Date
ABAPPLO
87f4d4066d feat: KOC详情弹窗 + 任务中心单入口改版 + 资源池默认合作方/采集兜底
KOC 详情弹窗(合作方/数据回收/工作台三处入口):
- 新增 KocDetailModal:基本信息 / 绑定KOC账号画像 / 参与任务记录 / 履约漏斗
- 工作台最近分发、回收队列账号名、截图回收合作方名、合作方页名称、分发详情头部合作方均可点开
- 分发表行点击仍打开分发详情(stopPropagation 隔离)

任务中心单入口改版(对齐"单任务双阶段"模型):
- 任务卡发布/回收面板整块可点,全卡仅保留一个「查看明细」,回收面板重复按钮移除
- 详情页自述身份:动态 label(任务详情·分发与回收一体/仅分发)+ 阶段徽章(含回收·截止/未开通回收)
- 内容分发表新增阶段筛选 chips(全部/待发布/已发布待数据/待收截图,未开通回收不显示截图项),筛选落空给专用空态文案
- 数据回收页入口文案改为「选择一个任务进入回收队列」,全站不再区分发布/回收两种任务类型

资源池 KOC 默认加入合作方 + 采集兜底:
- partners 新增 released_account_id(mysql/0023):显式解绑/被顶替的账号不再被默认合作方同步回填
- 任务未设置采集计划时,默认按发布时间起连续采集 7 天,等待文案显示「等待D1 · 默认7天」
2026-10-12 11:11:31 +08:00
ABAPPLO
c11afd5f99 feat: 采集失败KOC补交截图闭环 + 任务中心分发详情弹窗
公开数据补交闭环(F1):
- distributions 新增 public_metrics_screenshot_key(mysql/0022 迁移)
- KOC 门户:链接已回填但采集失败时展示补交数据截图入口(kind=public-metrics)
- 运营端:回收队列"核对补录"支持公开互动数据(赞/藏/评),按补交截图补录后采集状态置成功
- 企微催办:采集失败纳入待办(公开数据待补交)
- task-result-image 支持 kind=publish 输出发布截图

门户文案(F2):
- 自动采集数据明确"由系统每天自动采集,无需你填写",仅识别失败时才手动填写曝光/阅读

任务中心分发详情:
- 任务中心/数据回收的分发表行可点击,弹窗查看详情
- 详情按任务类型适配:发布阶段/数据回收(自动采集)/创作者数据(第7天)/截图回收四区块三段式布局
2026-10-09 17:02:46 +08:00
ABAPPLO
c686003cfb fix: 完成态判据统一为曝光+阅读齐+交互断点三处修复
- 创作者凭证提交(manual_metrics/submit_creator_metrics)的完成判据从
  d7_likes(全库无赋值方,永不触发)改为 exposure+views 齐,与采集侧口径
  对齐;管理端手动回填后同步 refreshTaskStatus 联动任务状态
- 回收队列头部改为「已发布 N / 总数」口径,未回填链接行不再计入
- 行内更新/补填笔记链接从原生 prompt 改为弹窗表单,失败保留已输入内容
- 顶栏「获取KOC领取链接」从导航跳转改为弹窗直接列出各任务领取链接,
  一键复制;弹窗内点击不冒泡误关
2026-10-08 15:17:14 +08:00
ABAPPLO
c54649258b fix: 修复两端走查发现的4个UI断点
- 门户回填表单:缺发布截图时提交按钮前置禁用并提示「先上传发布截图,
  链接回填需以截图为凭证」,不再等提交后才 toast 报错(服务端守卫保留)
- 门户笔记详情:回填链接前收起回收区块的说明与示例图,只留一行锁定提示,
  降低回填阶段信息密度
- 运营端分发表:数据状态按任务采集计划显示等待日(等待D{首个采集日}),
  未设置计划时显示「待设置采集计划」,不再写死「等待D2」
- 截图包回收清单:序号列对无 source_row 的内容回退行号;种子内容补
  source_row,演示数据导出不再出现空序号
2026-10-08 14:44:47 +08:00
ABAPPLO
2db06063ee fix: 采集失败不降级有数据分行+任务领取计数对齐实际分发行数
- 采集失败时按 latest_likes 分流:无数据才标 failed,已有历史数据保持
  success 并注明沿用上次数据,collection_updated_at 不动;run 行照实记失败
- 种子 seedIfEmpty 末尾统一对账 claimed_quantity,杜绝演示数据口径漂移
- 新增 mysql/0021 对账迁移,修复 0020 物理合并遗留的生产计数漂移
- 运营端展示全面改读实时 dist_total(任务卡双面板/项目树/概览/进度),
  project-service 查询补 dist_total 子查询
- 走查实证:卡片 7/18 vs 详情已领取 2 两处口径打架;保存采集计划补采
  失败会把历史成功行改写为 failed
2026-10-08 14:30:36 +08:00
ABAPPLO
ec45e7318c test: 门户守卫测试对齐任务模型物理合并
e2a93e2 将 screenshot_collect 并入单任务双阶段(先发布后回收截图)时漏改
koc-portal/tests,两条源码守卫断言失配导致 CI 失败:

- note-index 完成态改为仅看 publish_url 的单分支断言
- 截图任务用例改为合并后的回收截图模型:recoveryEnabled 守卫、
  先回填发布链接再传截图的门槛、批次回收进度、回收示例图,
  并加 doesNotMatch 防止回退 screenshot_collect/isScreenshotTask 旧模型
2026-09-28 13:48:37 +08:00
ABAPPLO
e2a93e22cf feat: 任务模型物理合并,发布+回收并为单任务双阶段
- tasks 新增 recovery_enabled/recovery_due_at/recovery_instructions/recovery_example_images,回收挂自己名下(先发布后交截图),不再置 complete
- 存量 screenshot_collect 迁移并入父任务后删除(mysql/0020 含 DELETE,生产执行前务必备份)
- 新 action task_enable_recovery:ghost 面板一键开通回收,截止默认=发布截止
- 企微催办双阶段(待发布/截图待提交);超期自动释放仅发布阶段
- admin 删 groupTaskMasters 聚合改单卡双面板;portal 详情页回收截图区块+批次回收进度
- 测试 309 全绿 + Playwright E2E 20/20
2026-09-24 15:52:45 +08:00
ABAPPLO
ab78b187c0 feat: 项目层级树+合作方绑定KOC资源+客户群全量可选+精简编辑表单,移除复盘报表
- 项目支持父子层级:树形折叠展示、详情面板内联管理总群成员、推送聚合子项目任务、环检测与子项目删除守卫
- 外部KOC合作方绑定 KOC 资源账号(唯一占用、换绑/解绑、资源页一键设为合作方),列表直显联系方式
- 绑定客户群改为全量外部群缓存(含各群主),同步按钮强刷;member_count 缺失以 member_list 兜底
- 任务/项目编辑模式隐藏不可变字段(平台/内容类型/品牌/总群成员),详情面板维护成员时保留 parent_id
- 删除复盘报表整栏(导航/页面/task_report_list/导出路由/CSV lib 及测试)
2026-09-23 11:13:17 +08:00
ABAPPLO
8f1ebf7697 feat: 负责人下钻视图+项目负责人编辑管理+企微授权自助绑定
- 任务/项目负责人 union scope:任务中心分组视图、工作台项目进度卡、收录验证列
- 项目负责人可管理自己项目:编辑/删除项目、编辑任务、分配负责人、推送、项目内建任务;堵死转移归属/越权新建/任务迁移
- 只读依赖放开给已登录:inspect_feishu、wecom_list_internal_contacts
- 企微 OAuth 自助绑定:/api/wecom-bind(state 签名校验+登录态一致性+归属冲突检查),未绑定 banner 增加入口
- 工作台项目进度卡与未绑定 banner 排版修复
- 修 user-auth 判空分支被 Turbopack dev 折叠删除导致匿名请求 500
2026-09-17 15:21:24 +08:00
ABAPPLO
dd5b603149 feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人
- 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人
- 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行
- 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events)
- 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表
- 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群)
- 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件
- 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
2026-09-04 18:03:46 +08:00
ABAPPLO
5430624a43 feat: 企微通讯录选人替换手填 userid(项目负责人/总群成员/群主合作方)
- wecom-client 新增 listInternalContacts:department/list + 逐部门 user/simplelist(cursor 翻页、去重、部门名映射)
- 新 action wecom_list_internal_contacts(仅返回应用可见范围内成员)
- admin 新增 WecomUserPickerModal:单选点行填入/多选勾选合并,姓名+userid 搜索,5 分钟共享缓存
- 三处接线:项目负责人(单选)、项目总群成员(多选合并去重)、新建群主合作方(单选)
- 拉取失败(60020 等)提示手填兜底,手填路径不变
2026-09-03 13:52:10 +08:00
ABAPPLO
bd80b2d7d8 feat: 项目负责人+内部任务群+portal 内部身份领取+群主链接归属
- 迁移 0012:projects.owner_userid / tasks.task_chat_id / claims.owner_partner_id
- lib/task-group-service.ts:懒建 appchat 任务群(首个内部群主领取时创建,成员 diff 补员),播报/卡片文案
- wecom-client 新增 appchat create/update/get/send 封装
- OAuth ticket 增可选 uid(v:1 兼容),内部成员领取按 wecom_userid 解析群主身份
- 外部 OAuth 领取人 upsert 打 partner_type='group_owner'
- portal 链接带 ?owner= 穿透 OAuth 往返,claims 记录归属群主
- 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态
- 催办与超期升级同步播报进任务群
2026-09-03 11:30:26 +08:00
ABAPPLO
5d2a17f5b5 refactor: 项目中心合并进任务中心(导航 8→7,分组/平铺双视图)
manager 默认按项目分组(项目区块含总群成员/任务群关联/推送,末尾未分组任务),
可切换平铺;普通用户仅平铺列表无管理入口。任务卡抽为 TaskRowCard 双视图共用,
项目编辑/删除弹窗上提为受控组件,详情内任务表与区块任务卡去重(单任务推送
改走推送弹层任务下拉)。
2026-09-02 17:32:42 +08:00
ABAPPLO
7ebf8d4902 chore: 增加企业微信可信域名验证文件 2026-09-02 14:31:37 +08:00
ABAPPLO
5cb056507d fix: 催办按任务类型分流 + 领取事务加锁防超发
- 临期/超期/自动释放查询按 task_type 区分完成口径:内容任务看 publish_url,截图任务看 result_submitted_at,截图任务不再被永久误判为未发布
- portal 领取改为事务内 SELECT ... FOR UPDATE 锁定可用内容,消除并发领取超发窗口
2026-09-02 13:56:07 +08:00
ABAPPLO
e1005081fe refactor: 任务中心合并任务分发(导航 9→8,任务卡进入分发明细) 2026-09-02 11:25:16 +08:00
ABAPPLO
fb1551a3a4 docs: 企微化流程环境变量清单与部署指南(回调/回流/催办/复盘) 2026-09-01 17:47:51 +08:00
ABAPPLO
4c1ff2d5b8 feat: 复盘报表视图与 CSV 导出(manager 专属) 2026-09-01 17:46:11 +08:00
ABAPPLO
3e4269729c feat: 催办分层(超期升级+交互卡片)与超期自动释放开关 2026-09-01 17:37:35 +08:00
ABAPPLO
bca524417e feat: 数据回流通知(提交/采集结果推运营群,默认关闭) 2026-09-01 17:34:14 +08:00
ABAPPLO
d01281fba9 feat: 任务一键推送企微(总群+KOC个人直达+群发,含推送记录) 2026-09-01 17:30:47 +08:00
ABAPPLO
b243562e60 feat: 企微回调服务基建(加解密+事件分发,默认关闭) 2026-09-01 15:08:00 +08:00
ABAPPLO
ca3810a3d0 feat: 门户接入企微网页授权——企微内静默免输身份领取,浏览器外保留手动输入 2026-08-31 17:17:00 +08:00
ABAPPLO
6e590e3b08 feat: 任务领取/找回支持企微身份票据,领取自动绑定催办通道 2026-08-31 17:13:03 +08:00
ABAPPLO
b83a6a53c9 feat: 新增企微 OAuth 换码路由与 HMAC 身份票据服务 2026-08-31 17:11:20 +08:00
ABAPPLO
7f6c0e0983 feat: wecom-client 新增网页授权能力(授权 URL / code 换身份 / 外部联系人显示名) 2026-08-31 17:10:03 +08:00
ABAPPLO
a6f5a5fd4a feat: 新增项目中心——按项目组织任务/任务群/总群并一键推送
- 新表 projects / project_group_chats,partners 加 partner_type/wecom_userid,tasks 加 project_id(迁移 0010)
- 项目 CRUD、总群成员配置、任务群人工映射(群主→合作方)、任务挂项目
- 推送:总群成员逐人应用消息 + 任务群按群主企业群发,结果分项回显
2026-08-31 16:35:17 +08:00
ABAPPLO
ed13262166 feat: 批量更新账号资料支持勾选字段(含AI打标)
- 更新全部账号ID 升级为 批量更新账号资料:弹窗勾选 账号ID/昵称/粉丝数/IP属地/性别/简介/标签
- 标签勾选时调用 LLM 重新打标(覆盖已有标签),AI_TAGGING_TOOL 未配置自动跳过
- 只写入选中字段,爬取为空的字段跳过不清空;演示账号(xhs- 前缀)不参与
- .env.app.example 为 SEED_DEMO_DATA 补充注释
2026-08-28 14:00:07 +08:00
ABAPPLO
8e4e3de974 feat: KOC资源列表分页展示(每页15个) 2026-08-27 19:17:53 +08:00
ABAPPLO
dfc7dc217d feat: KOC资源页新增一键爬取更新全部账号ID 2026-08-27 18:54:55 +08:00
ABAPPLO
5614a13ebb feat: KOC资源卡片支持手动修改账号ID 2026-08-27 17:50:44 +08:00
ABAPPLO
9c37141399 merge: 合入 main(PR #4 搜索 API + MCP 截图识别),解决 .gitignore 冲突 2026-08-27 16:32:03 +08:00
ABAPPLO
b79117df9a fix: 移除 0001 初始化脚本中与 0006 迁移重复的列定义
gender/bio/tags 由 0006 迁移 ALTER TABLE 添加,0001 基础建表若同时包含
会导致全新安装执行迁移时报重复列错误。
2026-08-27 16:21:15 +08:00
ABAPPLO
db5c7522f7 refactor: 环境变量统一为 .env.app 单文件 + 清理死配置
- 新增 .env.app.example 统一模板(两套 compose 共用,数据库按模式分节注释),删除 .env.self-hosted.example / .env.production.example
- 部署指南与 README 的 --env-file 引用统一为 .env.app;.gitignore / .dockerignore 白名单同步调整
- runtime-env.ts 移除零消费的 MCP_API_KEY / COLLECTION_MCP_URL / COLLECTION_MCP_KEY 遗留声明
- .dockerignore 补充 .dev.vars 排除;mcp-task-server 测试改读新模板
2026-08-27 11:42:02 +08:00
ABAPPLO
cb010b6c5b chore: 根目录图片加入 .gitignore 并移除已入库的调试截图
- .gitignore 新增根目录 png/jpg/jpeg/webp 忽略规则(正式资源在 public/ 与 koc-portal/public/,不受影响)
- git rm --cached 移除 15 张 design-qa 调试截图(本地文件保留)
2026-08-26 17:40:10 +08:00
ABAPPLO
9b9f6a3520 feat: 新增生产环境 compose 编排(直连公共 MySQL)
- 新增 docker-compose.production.yml:仅 app + nginx,数据库连公司公共 MySQL,MYSQL_HOST/MYSQL_PASSWORD 必填校验
- 新增 .env.production.example 环境模板,.gitignore 白名单放行
- 修复 docker-compose.self-hosted.yml 漏转发 AI_TAGGING_TOOL 与 WECOM_* 共 7 个环境变量
- 部署指南补充两套编排文件选用说明与生产启动命令;忽略真实账号数据 CSV 防误提交
2026-08-26 17:28:25 +08:00
ABAPPLO
4a64feee02 feat: KOC 资源导入后自动爬取主页资料并调用 LLM 打标签
- 新增 structured_text_process 打标客户端(文本构造、标签解析清洗、MCP 调用)
- 写库后后台打标(tagUntaggedAccounts),单条新增 / 批量导入 / 每日补全三个入口统一
- 标签只补空,UPDATE 带 tags 为空守卫,不覆盖已有标签
- 新增 AI_TAGGING_TOOL 配置项(留空关闭),更新配置示例与文档
- 新增 16 个打标测试用例,全量 131 个测试通过
2026-08-26 15:21:28 +08:00
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
132 changed files with 23022 additions and 2238 deletions

View File

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

View File

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

70
.env.app.example Normal file
View File

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

View File

@@ -1,39 +0,0 @@
# 复制为 .env.self-hosted 后填写。不要把真实密钥提交到 Git。
MYSQL_ROOT_PASSWORD=replace-with-strong-root-password
MYSQL_DATABASE=koc_loop
MYSQL_USER=koc
MYSQL_PASSWORD=replace-with-strong-app-password
# 对外地址。部署域名确定后替换。
APP_ORIGIN=https://koc.example.com
KOC_PORTAL_URL=https://koc.example.com/koc/
# 后台首次启动会创建唯一超级管理员;账号创建后建议移除明文密码并重启。
SUPER_ADMIN_USERNAME=admin
SUPER_ADMIN_PASSWORD=replace-with-strong-admin-password
ADMIN_INTERNAL_TOKEN=replace-with-random-internal-token
# KOC LOOP 自身 MCP 的访问密钥。
KOC_MCP_API_KEY=replace-with-random-mcp-key
# 飞书内容表读取。
FEISHU_APP_ID=
FEISHU_APP_SECRET=
# 小红书公开数据采集 MCP。
AI_TOOL_CENTER_MCP_URL=
AI_TOOL_CENTER_MCP_KEY=
# 企业微信通知(临期催办 + 管理员汇总)。群机器人只需 webhook;KOC 侧催办还需 corp/agent/secret,
# 并在后台 partners 编辑里把 wecom_external_user_id 填好。
WECOM_ROBOT_WEBHOOK=
WECOM_CORP_ID=
WECOM_AGENT_ID=
WECOM_SECRET=
WECOM_NOTIFY_DUE_DAYS=3
WECOM_NOTIFY_ENABLED=true
# 每天北京时间 09:00 自动执行采集计划。
ENABLE_SCHEDULER=true
SEED_DEMO_DATA=false
HTTP_PORT=80

16
.gitignore vendored
View File

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

138
CLAUDE.md Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ export async function POST(request: Request) {
const password = String(body.password ?? "");
const user = await getRawDb()
.prepare(
`SELECT id, username, role, password_hash, password_salt, password_iterations
`SELECT id, username, role, wecom_userid, password_hash, password_salt, password_iterations
FROM users WHERE username = ? LIMIT 1`,
)
.bind(username)
@@ -24,6 +24,7 @@ export async function POST(request: Request) {
id: string;
username: string;
role: string;
wecom_userid: string | null;
password_hash: string;
password_salt: string;
password_iterations: number;
@@ -34,7 +35,14 @@ export async function POST(request: Request) {
const token = await createSession(user.id);
const secure = requestUsesHttps(request);
return Response.json(
{ user: { id: user.id, username: user.username, role: user.role } },
{
user: {
id: user.id,
username: user.username,
role: user.role,
wecom_userid: String(user.wecom_userid ?? ""),
},
},
{ headers: { "Set-Cookie": createSessionCookie(token, secure) } },
);
} catch (error) {

View File

@@ -14,7 +14,9 @@ import {
getRawDb,
seedIfEmpty,
} from "../../../lib/mvp-db";
import { ensurePartnersForPoolAccounts } from "../../../lib/resource-write";
import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth";
import { getOwnerScope, ownerScopeUserid } from "../../../lib/owner-scope";
export async function GET(request: Request) {
const principal = await getRequestPrincipal(request);
@@ -22,6 +24,13 @@ export async function GET(request: Request) {
try {
await ensureSchema();
await seedIfEmpty();
// 资源池 KOC 默认加入合作方:在返回面板数据前补齐,失败不阻塞看板
await ensurePartnersForPoolAccounts().catch((error) => {
console.error(
"KOC pool partner sync failed",
error instanceof Error ? error.message : "unknown error",
);
});
const db = getRawDb();
const mcpConfig = resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
@@ -56,12 +65,9 @@ export async function GET(request: Request) {
accountBackfill,
]);
runInBackground(catchup, "bootstrap catchup");
const dashboard = await getDashboardData();
return Response.json(
principal.kind === "user" && principal.user.role === "user"
? { ...dashboard, accounts: [] }
: dashboard,
);
const scope = await getOwnerScope(request);
const dashboard = await getDashboardData(ownerScopeUserid(scope));
return Response.json(dashboard);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "加载失败" },

View File

@@ -10,20 +10,33 @@ export async function GET(request: Request) {
const isAdmin = await isAdminRequest(request);
try {
await ensureSchema();
const distributionId = new URL(request.url).searchParams
.get("distribution")
?.trim();
const url = new URL(request.url);
const distributionId = url.searchParams.get("distribution")?.trim();
// kind=public-metrics 时返回 KOC 补交的公开数据截图(仅后台核对用)
const imageKind = url.searchParams.get("kind") === "public-metrics"
? "public-metrics"
: "creator";
if (!distributionId) {
return Response.json({ error: "缺少分发记录" }, { status: 400 });
}
const row = await getRawDb()
.prepare(
`SELECT screenshot_key FROM distributions
`SELECT screenshot_key, public_metrics_screenshot_key FROM distributions
WHERE id = ?`,
)
.bind(distributionId)
.first<{ screenshot_key: string | null }>();
const mcpToken = new URL(request.url).searchParams.get("mcp_token") || "";
.first<{
screenshot_key: string | null;
public_metrics_screenshot_key: string | null;
}>();
const mcpToken = url.searchParams.get("mcp_token") || "";
const key =
imageKind === "public-metrics"
? row?.public_metrics_screenshot_key ?? null
: row?.screenshot_key ?? null;
if (imageKind === "public-metrics" && !isAdmin) {
return adminForbidden();
}
if (
!isAdmin &&
(!row?.screenshot_key ||
@@ -36,18 +49,20 @@ export async function GET(request: Request) {
return adminForbidden();
}
if (
!row?.screenshot_key ||
!row.screenshot_key.startsWith("creator-center/")
!key ||
!key.startsWith(
imageKind === "public-metrics" ? "public-metrics/" : "creator-center/",
)
) {
return Response.json({ error: "创作者截图不存在" }, { status: 404 });
return Response.json({ error: "截图不存在" }, { status: 404 });
}
const object = await getUploadBucket().get(row.screenshot_key);
const object = await getUploadBucket().get(key);
if (!object) {
return Response.json({ error: "创作者截图不存在" }, { status: 404 });
return Response.json({ error: "截图不存在" }, { status: 404 });
}
const headers = new Headers({
"Cache-Control": "private, no-store",
"Content-Disposition": 'inline; filename="creator-center-screenshot"',
"Content-Disposition": `inline; filename="${imageKind}-screenshot"`,
"X-Content-Type-Options": "nosniff",
});
object.writeHttpMetadata(headers);

View File

@@ -38,6 +38,7 @@ import {
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
import {
createCollectionRunTasks,
parseCollectionDays,
runDueScheduledCollections,
} from "../../../lib/collection-service";
import { normalizeWorkbookImage } from "../../../lib/workbook-image";
@@ -664,49 +665,48 @@ async function handlePost(request: Request) {
updatedRows += 1;
}
}
if (
(publishedCount > 0 || publishUrlChangedCount > 0) &&
access.task.collection_start_date &&
access.task.collection_days !== "[]"
) {
let collectionDays: number[] = [];
try {
const parsed = JSON.parse(access.task.collection_days);
if (Array.isArray(parsed)) collectionDays = parsed.map(Number);
} catch {
collectionDays = [];
}
if (collectionDays.length > 0) {
await db
.prepare(
`UPDATE distributions SET collection_status = 'scheduled',
collection_status_description = ?
WHERE task_id = ? AND publish_url IS NOT NULL AND publish_url != ''`,
)
.bind(
`已安排${collectionDays.length}个采集日,每日09:00执行`,
access.task.id,
)
.run();
if (publishedCount > 0 || publishUrlChangedCount > 0) {
const collectionDays = parseCollectionDays(
access.task.collection_days,
);
const isScheduled = Boolean(
access.task.collection_start_date &&
collectionDays.length > 0,
);
await db
.prepare(
`UPDATE distributions SET collection_status = 'scheduled',
collection_status_description = ?
WHERE task_id = ? AND publish_url IS NOT NULL AND publish_url != ''`,
)
.bind(
isScheduled
? `已安排${collectionDays.length}个采集日,每日09:00执行`
: "默认发布后7天自动采集,每日09:00执行",
access.task.id,
)
.run();
if (isScheduled && access.task.collection_start_date) {
await createCollectionRunTasks(
db,
access.task.id,
access.task.collection_start_date,
collectionDays,
);
runInBackground(
runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
access.task.id,
).catch(() => undefined),
"collection catchup after batch publish update",
);
}
// 未设置计划时调度器同样兜底:默认自发布日起 7 天自动采集。
runInBackground(
runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
access.task.id,
).catch(() => undefined),
"collection catchup after batch publish update",
);
}
for (const enrichment of enrichments) {
runInBackground(

View File

@@ -75,9 +75,12 @@ async function handleGet(request: Request) {
c.video_assets,
d.result_screenshot_key,
d.publish_screenshot_key,
d.screenshot_key
d.screenshot_key,
d.public_metrics_screenshot_key,
t.recovery_example_images
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN tasks t ON t.id = d.task_id
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
WHERE d.id = ?
AND b.share_token = ?
@@ -90,6 +93,8 @@ async function handleGet(request: Request) {
result_screenshot_key: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
public_metrics_screenshot_key: string | null;
recovery_example_images: string;
}>()
: await getRawDb()
.prepare(
@@ -97,7 +102,9 @@ async function handleGet(request: Request) {
c.video_assets,
d.result_screenshot_key,
d.publish_screenshot_key,
d.screenshot_key
d.screenshot_key,
d.public_metrics_screenshot_key,
t.recovery_example_images
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN claims cl ON cl.id = d.claim_id
@@ -114,6 +121,8 @@ async function handleGet(request: Request) {
result_screenshot_key: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
public_metrics_screenshot_key: string | null;
recovery_example_images: string;
}>();
const asset =
imageKind === "result"
@@ -125,6 +134,10 @@ async function handleGet(request: Request) {
],
}
: undefined
: imageKind === "task-example"
? row
? findAsset(row.recovery_example_images, imageIndex, ["task-assets/"])
: undefined
: imageKind === "publish"
? row?.publish_screenshot_key?.startsWith("publish-evidence/")
? { index: 1, key: row.publish_screenshot_key }
@@ -133,6 +146,10 @@ async function handleGet(request: Request) {
? row?.screenshot_key?.startsWith("creator-center/")
? { index: 1, key: row.screenshot_key }
: undefined
: imageKind === "public-metrics"
? row?.public_metrics_screenshot_key?.startsWith("public-metrics/")
? { index: 1, key: row.public_metrics_screenshot_key }
: undefined
: imageKind === "video"
? row
? findAsset(row.video_assets, imageIndex, ["content-videos/"])
@@ -187,7 +204,9 @@ async function handleGet(request: Request) {
const headers = new Headers();
object.writeHttpMetadata(headers);
const isMutableEvidence =
imageKind === "publish" || imageKind === "creator";
imageKind === "publish" ||
imageKind === "creator" ||
imageKind === "public-metrics";
headers.set(
"Cache-Control",
isMutableEvidence ? "private, no-store" : "private, max-age=3600",

View File

@@ -87,13 +87,18 @@ async function handlePost(request: Request) {
}
const isCreatorCenter = upload.uploadKind === "creator-center";
const isTaskResult = upload.uploadKind === "task-result";
if (!["publish", "creator-center", "task-result"].includes(upload.uploadKind)) {
const isPublicMetrics = upload.uploadKind === "public-metrics";
if (
!["publish", "creator-center", "task-result", "public-metrics"].includes(
upload.uploadKind,
)
) {
return Response.json({ error: "不支持的截图类型" }, { status: 400 });
}
const assignment = upload.delegationToken
? await getRawDb()
.prepare(
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.task_type
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.recovery_enabled
FROM distributions d
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
JOIN tasks t ON t.id = d.task_id
@@ -102,10 +107,10 @@ async function handlePost(request: Request) {
AND b.status = 'active'`,
)
.bind(upload.distributionId, upload.delegationToken)
.first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; task_type: string }>()
.first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; recovery_enabled: number }>()
: await getRawDb()
.prepare(
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.task_type
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.recovery_enabled
FROM distributions d
JOIN claims c ON c.id = d.claim_id
JOIN tasks t ON t.id = d.task_id
@@ -115,21 +120,18 @@ async function handlePost(request: Request) {
AND c.task_id = t.id`,
)
.bind(upload.distributionId, upload.claimToken, upload.taskToken)
.first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; task_type: string }>();
.first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; recovery_enabled: number }>();
if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
}
if (isCreatorCenter && !assignment.publish_url) {
if ((isCreatorCenter || isPublicMetrics) && !assignment.publish_url) {
return Response.json(
{ error: "请先回填这篇笔记的发布信息" },
{ status: 409 },
);
}
if (isTaskResult && assignment.task_type !== "screenshot_collect") {
return Response.json({ error: "当前任务不支持截图结果回填" }, { status: 400 });
}
if (!isTaskResult && assignment.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务请上传任务结果截图" }, { status: 400 });
if (isTaskResult && Number(assignment.recovery_enabled) !== 1) {
return Response.json({ error: "该任务未开通截图回收" }, { status: 400 });
}
const existingResultKeys = isTaskResult
? parseResultScreenshotKeys(assignment.result_screenshot_key)
@@ -146,6 +148,8 @@ async function handlePost(request: Request) {
? `task-results/${upload.distributionId}/${uid("shot")}.${extension}`
: isCreatorCenter
? `creator-center/${upload.distributionId}/${uid("shot")}.${extension}`
: isPublicMetrics
? `public-metrics/${upload.distributionId}/${uid("shot")}.${extension}`
: `publish-evidence/${upload.distributionId}/${uid("shot")}.${extension}`;
await getUploadBucket().put(key, upload.fileBytes, {
httpMetadata: { contentType: upload.fileType },
@@ -163,6 +167,18 @@ async function handlePost(request: Request) {
upload.distributionId,
)
.run();
} else if (isPublicMetrics) {
// 公开数据自动采集失败时的兜底凭证:运营据此核对补录互动数据
await getRawDb()
.prepare(
`UPDATE distributions SET
public_metrics_screenshot_key = ?,
collection_status_description = 'KOC已补交数据截图,待运营核对',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(key, upload.distributionId)
.run();
} else if (isCreatorCenter) {
await getRawDb()
.prepare(
@@ -229,7 +245,9 @@ async function handlePost(request: Request) {
? "task-result"
: isCreatorCenter
? "creator-center"
: "publish",
: isPublicMetrics
? "public-metrics"
: "publish",
ocrStatus: isCreatorCenter ? "failed" : undefined,
});
} catch (error) {

View File

@@ -6,6 +6,7 @@ const env = getRuntimeEnv();
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
import {
createCollectionRunTasks,
parseCollectionDays,
runDueScheduledCollections,
} from "../../../lib/collection-service";
import {
@@ -23,6 +24,26 @@ import {
} from "../../../lib/partner-utils";
import { extractPublishUrl } from "../../../lib/publish-url";
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
import { refreshTaskStatus } from "../../../lib/task-service";
import {
resolveWecomClaimant,
verifyWecomIdentityTicket,
type WecomIdentity,
} from "../../../lib/wecom-oauth-service";
import {
isWecomFeedbackEnabled,
notifyPartnerSubmitted,
} from "../../../lib/wecom-feedback-service";
import {
resolveWecomConfig,
type WecomBindings,
} from "../../../lib/wecom-client";
import {
ensureTaskChatForMembers,
sendTaskChatMessage,
taskChatIntroText,
taskClaimBroadcastText,
} from "../../../lib/task-group-service";
import {
partnerOptions,
withPartnerCors,
@@ -39,6 +60,8 @@ type PartnerBody = {
delegationBundleId?: string;
claimantIdentifier?: string;
claimantName?: string;
identityTicket?: string;
owner?: string;
quantity?: number;
publishUrl?: string;
exposure?: number | string;
@@ -80,6 +103,15 @@ function idList(value: unknown) {
];
}
function recoveryPayload(task: PartnerTask) {
return {
recoveryEnabled: Number(task?.recovery_enabled) === 1,
recoveryDueAt: String(task?.recovery_due_at ?? ""),
recoveryInstructions: String(task?.recovery_instructions ?? ""),
recoveryExampleImages: publicImageAssets(task?.recovery_example_images),
};
}
function publicImageAssets(value: unknown): ImageAsset[] {
try {
const assets = JSON.parse(String(value ?? "[]")) as Array<
@@ -115,13 +147,18 @@ type PartnerTask = {
task_type: string;
platform: string;
content_format: string;
recovery_enabled: number;
recovery_due_at: string;
recovery_instructions: string | null;
recovery_example_images: string;
};
async function findTask(taskToken: string) {
return getRawDb()
.prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, platform, content_format
task_type, platform, content_format,
recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images
FROM tasks WHERE share_token = ?`,
)
.bind(taskToken)
@@ -142,6 +179,10 @@ async function findDelegationAccess(delegationToken: string) {
t.task_type,
t.platform,
t.content_format,
t.recovery_enabled,
t.recovery_due_at,
t.recovery_instructions,
t.recovery_example_images,
b.id AS bundle_id,
b.label AS bundle_label,
b.quantity AS bundle_quantity,
@@ -286,6 +327,12 @@ async function handleGet(request: Request) {
d.ocr_status,
d.exposure,
d.views,
d.collection_status,
d.latest_likes,
d.latest_comments,
d.latest_collects,
d.collection_updated_at,
d.public_metrics_screenshot_key,
c.title,
c.body,
c.source_row,
@@ -325,6 +372,11 @@ async function handleGet(request: Request) {
)
.bind(claimRow.id)
.all();
const joinQrRow = await getRawDb()
.prepare("SELECT join_qr_code FROM tasks WHERE id = ?")
.bind(task.id)
.first<{ join_qr_code: string | null }>();
const joinQrCode = String(joinQrRow?.join_qr_code ?? "").trim();
claim = {
id: claimRow.id,
claimantName: claimRow.claimant_name,
@@ -338,6 +390,7 @@ async function handleGet(request: Request) {
video_assets: undefined,
})),
delegations: delegations.results,
joinQrCode,
};
} else if (delegationAccess) {
const assignments = await getRawDb()
@@ -354,6 +407,12 @@ async function handleGet(request: Request) {
d.ocr_status,
d.exposure,
d.views,
d.collection_status,
d.latest_likes,
d.latest_comments,
d.latest_collects,
d.collection_updated_at,
d.public_metrics_screenshot_key,
c.title,
c.body,
c.source_row,
@@ -370,7 +429,7 @@ async function handleGet(request: Request) {
.all();
delegation = {
id: delegationAccess.bundle_id,
label: task.task_type === "screenshot_collect" ? "转派截图任务包" : "转派发布包",
label: "转派发布包",
quantity: delegationAccess.bundle_quantity,
createdAt: delegationAccess.bundle_created_at,
assignments: assignments.results.map((assignment) => ({
@@ -393,6 +452,8 @@ async function handleGet(request: Request) {
type: task.task_type,
platform: task.platform,
contentFormat: task.content_format,
recoveryEnabled: recoveryPayload(task).recoveryEnabled,
recoveryDueAt: recoveryPayload(task).recoveryDueAt,
}
: {
name: task.name,
@@ -405,6 +466,7 @@ async function handleGet(request: Request) {
platform: task.platform,
contentFormat: task.content_format,
availableQuantity: available?.count ?? 0,
...recoveryPayload(task),
},
claim,
delegation,
@@ -446,13 +508,40 @@ async function handlePost(request: Request) {
}
const db = getRawDb();
let wecomIdentity: WecomIdentity | null = null;
if (body.identityTicket) {
try {
wecomIdentity = await verifyWecomIdentityTicket(
textValue(body.identityTicket, 512),
);
} catch {
wecomIdentity = null;
}
}
if (body.action === "recover") {
const identifierInput = textValue(
body.claimantIdentifier ?? body.claimantName,
64,
);
const claimantIdentifier = parseClaimantIdentifier(identifierInput);
if (!claimantIdentifier && identifierInput.length < 2) {
let wecomPartnerId: string | null = null;
if (wecomIdentity?.userId) {
const internalPartner = await db
.prepare(
`SELECT id FROM partners
WHERE wecom_userid = ? AND partner_type = 'group_owner'
LIMIT 1`,
)
.bind(wecomIdentity.userId.trim())
.first<{ id: string }>();
wecomPartnerId = internalPartner?.id ?? null;
} else if (wecomIdentity?.externalUserId) {
wecomPartnerId = (
await resolveWecomClaimant(db, wecomIdentity.externalUserId)
).partnerId;
}
if (!claimantIdentifier && identifierInput.length < 2 && !wecomPartnerId) {
return Response.json(
{ error: "请输入领取时使用的正确微信号或手机号" },
{ status: 400 },
@@ -466,6 +555,11 @@ async function handlePost(request: Request) {
`claimant:${claimantIdentifier.canonical}`,
)}`
: legacyPartnerId;
const partnerCandidates = [
...new Set(
wecomPartnerId ? [wecomPartnerId] : [partnerId, legacyPartnerId],
),
];
const recovered = await db
.prepare(
`SELECT
@@ -474,19 +568,20 @@ async function handlePost(request: Request) {
c.created_at,
COUNT(d.id) AS note_count,
SUM(CASE
WHEN ? = 'screenshot_collect' AND d.result_submitted_at IS NOT NULL THEN 1
WHEN ? != 'screenshot_collect' AND d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1
WHEN d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1
ELSE 0 END) AS completed_count,
MIN(co.title) AS first_title
FROM claims c
LEFT JOIN distributions d ON d.claim_id = c.id
LEFT JOIN contents co ON co.id = d.content_id
WHERE c.task_id = ? AND c.partner_id IN (?, ?)
WHERE c.task_id = ? AND c.partner_id IN (${partnerCandidates
.map(() => "?")
.join(", ")})
GROUP BY c.id, c.claim_token, c.quantity, c.created_at
ORDER BY c.created_at DESC, c.id DESC
LIMIT 20`,
)
.bind(task.task_type, task.task_type, task.id, partnerId, legacyPartnerId)
.bind(task.id, ...partnerCandidates)
.all<{
claim_token: string;
quantity: number;
@@ -507,7 +602,7 @@ async function handlePost(request: Request) {
quantity: claim.note_count || claim.quantity,
completedCount: claim.completed_count || 0,
createdAt: claim.created_at,
firstTitle: claim.first_title || (task.task_type === "screenshot_collect" ? "领取的截图任务" : "领取的笔记"),
firstTitle: claim.first_title || "领取的笔记",
})),
});
}
@@ -522,92 +617,196 @@ async function handlePost(request: Request) {
);
const claimantIdentifier = parseClaimantIdentifier(identifierInput);
const quantity = quantityValue(body.quantity);
if (!claimantIdentifier) {
if (!claimantIdentifier && !wecomIdentity) {
return Response.json(
{ error: "请输入正确的微信号或手机号" },
{ status: 400 },
);
}
const available = await db
.prepare(
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY COALESCE(source_row, 999999), created_at, id
LIMIT ${quantity}`,
)
.bind(task.id)
.all<{ id: string }>();
if (available.results.length === 0) {
return Response.json({ error: "当前任务已领完" }, { status: 409 });
const internalUserid = wecomIdentity?.userId?.trim() ?? "";
const externalIdentity =
!internalUserid && wecomIdentity ? wecomIdentity : null;
let partnerId: string;
let claimantName: string;
if (internalUserid) {
const ownerPartner = await db
.prepare(
`SELECT id, name FROM partners
WHERE wecom_userid = ? AND partner_type = 'group_owner'
LIMIT 1`,
)
.bind(internalUserid)
.first<{ id: string; name: string }>();
if (!ownerPartner) {
return Response.json(
{ error: "未找到你的群主身份,请先联系运营在后台登记" },
{ status: 403 },
);
}
partnerId = ownerPartner.id;
claimantName = ownerPartner.name || internalUserid;
} else if (externalIdentity) {
partnerId = (
await resolveWecomClaimant(db, externalIdentity.externalUserId)
).partnerId;
claimantName = externalIdentity.displayName;
} else {
partnerId = `partner-ext-${hashText(
`claimant:${claimantIdentifier?.canonical}`,
)}`;
claimantName = claimantIdentifier?.display ?? "";
}
const ownerInput = textValue(body.owner, 64);
let ownerPartnerId = "";
if (ownerInput) {
const ownerRow = await db
.prepare(
`SELECT id FROM partners WHERE id = ? AND partner_type = 'group_owner'`,
)
.bind(ownerInput)
.first<{ id: string }>();
if (ownerRow) ownerPartnerId = ownerRow.id;
}
const partnerId = `partner-ext-${hashText(
`claimant:${claimantIdentifier.canonical}`,
)}`;
const claimantName = claimantIdentifier.display;
const claimId = uid("claim");
const claimToken = crypto.randomUUID().replaceAll("-", "");
const statements: DatabaseStatement[] = [
db
const claimResult = await db.transaction(async (tx) => {
const available = await tx
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total)
VALUES (?, ?, ?, '外部KOC', 0, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wecom_name = excluded.wecom_name`,
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY COALESCE(source_row, 999999), created_at, id
LIMIT ${quantity}
FOR UPDATE`,
)
.bind(partnerId, claimantName, claimantName),
db
.prepare(
`INSERT INTO claims
(id, task_id, partner_id, claimant_name, claim_token, quantity)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
claimId,
task.id,
partnerId,
claimantName,
claimToken,
available.results.length,
),
];
for (const content of available.results) {
.bind(task.id)
.all<{ id: string }>();
if (available.results.length === 0) return null;
const statements: DatabaseStatement[] = [
externalIdentity
? db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total,
wecom_external_user_id, partner_type)
VALUES (?, ?, ?, '外部KOC', 0, 0, ?, 'group_owner')
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wecom_name = excluded.wecom_name,
wecom_external_user_id = excluded.wecom_external_user_id,
partner_type = 'group_owner'`,
)
.bind(
partnerId,
claimantName,
claimantName,
externalIdentity.externalUserId,
)
: db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total)
VALUES (?, ?, ?, '外部KOC', 0, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wecom_name = excluded.wecom_name`,
)
.bind(partnerId, claimantName, claimantName),
db
.prepare(
`INSERT INTO claims
(id, task_id, partner_id, owner_partner_id, claimant_name, claim_token, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
claimId,
task.id,
partnerId,
ownerPartnerId,
claimantName,
claimToken,
available.results.length,
),
];
for (const content of available.results) {
statements.push(
db
.prepare(
`INSERT INTO distributions
(id, task_id, content_id, partner_id, claim_id, status)
VALUES (?, ?, ?, ?, ?, 'claimed')`,
)
.bind(uid("dist"), task.id, content.id, partnerId, claimId),
db
.prepare(
`UPDATE contents SET status = 'allocated'
WHERE id = ? AND status = 'available'`,
)
.bind(content.id),
);
}
statements.push(
db
.prepare(
`INSERT INTO distributions
(id, task_id, content_id, partner_id, claim_id, status)
VALUES (?, ?, ?, ?, ?, 'claimed')`,
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
WHERE id = ?`,
)
.bind(uid("dist"), task.id, content.id, partnerId, claimId),
.bind(available.results.length, task.id),
db
.prepare(
`UPDATE contents SET status = 'allocated'
WHERE id = ? AND status = 'available'`,
`UPDATE partners SET claimed_total = claimed_total + ?
WHERE id = ?`,
)
.bind(content.id),
.bind(available.results.length, partnerId),
);
}
statements.push(
db
.prepare(
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
WHERE id = ?`,
)
.bind(available.results.length, task.id),
db
.prepare(
`UPDATE partners SET claimed_total = claimed_total + ?
WHERE id = ?`,
)
.bind(available.results.length, partnerId),
);
await db.batch(statements);
return Response.json({
claimToken,
claimedCount: available.results.length,
for (const statement of statements) {
await tx.execute(statement.sql, statement.values());
}
return { claimToken, claimedCount: available.results.length };
});
if (!claimResult) {
return Response.json({ error: "当前任务已领完" }, { status: 409 });
}
runInBackground(
(async () => {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const ensure = await ensureTaskChatForMembers(
db,
{
taskId: task.id,
...(internalUserid ? { addUserids: [internalUserid] } : {}),
},
wecomConfig,
);
if (!ensure.chatId) return;
if (ensure.created) {
await sendTaskChatMessage(
db,
task.id,
taskChatIntroText({
name: task.name,
brand: task.brand,
quantity: task.quantity,
dueAt: task.due_at,
}),
wecomConfig,
);
}
await sendTaskChatMessage(
db,
task.id,
taskClaimBroadcastText(
task.name,
claimantName,
claimResult.claimedCount,
),
wecomConfig,
);
})(),
`task-group-claim-${task.id}`,
);
return Response.json(claimResult);
}
if (body.action === "create_delegation") {
@@ -622,7 +821,7 @@ async function handlePost(request: Request) {
}
if (distributionIds.length === 0) {
return Response.json(
{ error: task.task_type === "screenshot_collect" ? "请至少选择一份待提交任务" : "请至少选择一篇待发布笔记" },
{ error: "请至少选择一篇待发布笔记" },
{ status: 400 },
);
}
@@ -657,13 +856,9 @@ async function handlePost(request: Request) {
{ status: 403 },
);
}
if (selected.results.some((item) =>
task.task_type === "screenshot_collect"
? item.result_submitted_at
: item.publish_url,
)) {
if (selected.results.some((item) => item.publish_url)) {
return Response.json(
{ error: task.task_type === "screenshot_collect" ? "已提交的截图任务不能再次转派" : "已发布的笔记不能再次转派" },
{ error: "已发布的笔记不能再次转派" },
{ status: 409 },
);
}
@@ -776,7 +971,7 @@ async function handlePost(request: Request) {
.first<{ count: number }>();
if ((completed?.count ?? 0) > 0) {
return Response.json(
{ error: task.task_type === "screenshot_collect" ? "该分享包已有截图提交,不能撤销" : "该分享包已有笔记发布,需保留链接继续完成第7天数据回收" },
{ error: "该分享包已有笔记发布,需保留链接继续完成第7天数据回收" },
{ status: 409 },
);
}
@@ -802,9 +997,6 @@ async function handlePost(request: Request) {
}
if (body.action === "submit") {
if (task.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务无需填写发布链接" }, { status: 400 });
}
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const publishInput = textValue(body.publishUrl, 5000);
@@ -962,47 +1154,47 @@ async function handlePost(request: Request) {
collection_start_date: string | null;
collection_days: string;
}>();
if (
collectionSchedule?.collection_start_date &&
collectionSchedule.collection_days !== "[]"
) {
let collectionDays: number[] = [];
try {
const parsed = JSON.parse(collectionSchedule.collection_days);
if (Array.isArray(parsed)) collectionDays = parsed.map(Number);
} catch {
collectionDays = [];
}
if (collectionDays.length > 0) {
await db
.prepare(
`UPDATE distributions SET collection_status = 'scheduled',
collection_status_description = ? WHERE id = ?`,
)
.bind(
`已安排${collectionDays.length}个采集日,每日09:00执行`,
assignment.id,
)
.run();
if (collectionSchedule) {
const collectionDays = parseCollectionDays(
collectionSchedule.collection_days,
);
const isScheduled = Boolean(
collectionSchedule.collection_start_date &&
collectionDays.length > 0,
);
await db
.prepare(
`UPDATE distributions SET collection_status = 'scheduled',
collection_status_description = ? WHERE id = ?`,
)
.bind(
isScheduled
? `已安排${collectionDays.length}个采集日,每日09:00执行`
: "默认发布后7天自动采集,每日09:00执行",
assignment.id,
)
.run();
if (isScheduled && collectionSchedule.collection_start_date) {
await createCollectionRunTasks(
db,
task.id,
collectionSchedule.collection_start_date,
collectionDays,
);
runInBackground(
runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
task.id,
).catch(() => undefined),
"collection catchup after partner publish update",
);
}
// 未设置计划时调度器同样兜底:默认自发布日起 7 天自动采集。
runInBackground(
runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
task.id,
).catch(() => undefined),
"collection catchup after partner publish update",
);
}
const enrichment = enrichDistributionAccount(
db,
@@ -1014,13 +1206,19 @@ async function handlePost(request: Request) {
),
).catch(() => undefined);
runInBackground(enrichment, "distribution account enrichment");
if (isWecomFeedbackEnabled()) {
const feedback = notifyPartnerSubmitted(
db,
{ distributionId: assignment.id, kind: "publish" },
resolveWecomConfig(env as unknown as WecomBindings),
).catch(() => undefined);
runInBackground(feedback, "wecom publish feedback");
}
await refreshTaskStatus(task.id, db).catch(() => undefined);
return Response.json({ ok: true });
}
if (body.action === "submit_creator_metrics") {
if (task.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务不需要创作者数据" }, { status: 400 });
}
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const exposure = creatorMetricValue(body.exposure);
@@ -1058,18 +1256,19 @@ async function handlePost(request: Request) {
exposure = ?,
views = ?,
ocr_status = 'manual',
status = CASE WHEN d7_likes IS NOT NULL THEN 'complete' ELSE status END,
status = CASE WHEN exposure IS NOT NULL AND views IS NOT NULL THEN 'complete' ELSE status END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(exposure, views, assignment.id)
.run();
await refreshTaskStatus(task.id, db).catch(() => undefined);
return Response.json({ submitted: true });
}
if (body.action === "submit_screenshot_result") {
if (task.task_type !== "screenshot_collect") {
return Response.json({ error: "当前任务不支持这种回填方式" }, { status: 400 });
if (Number(task.recovery_enabled) !== 1) {
return Response.json({ error: "该任务未开通截图回收" }, { status: 400 });
}
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
@@ -1080,17 +1279,22 @@ async function handlePost(request: Request) {
delegationToken,
);
if (!assignment) {
return Response.json({ error: "任务与领取凭证不匹配" }, { status: 403 });
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
}
if (!assignment.publish_url) {
return Response.json(
{ error: "请先回填这篇笔记的发布链接,再提交回收截图" },
{ status: 409 },
);
}
if (!assignment.result_screenshot_key) {
return Response.json({ error: "请先上传任务截图" }, { status: 400 });
return Response.json({ error: "请先上传回收截图" }, { status: 400 });
}
const statements: DatabaseStatement[] = [
db
.prepare(
`UPDATE distributions SET
result_submitted_at = CURRENT_TIMESTAMP,
status = 'complete',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
@@ -1107,6 +1311,15 @@ async function handlePost(request: Request) {
);
}
await db.batch(statements);
await refreshTaskStatus(task.id, db).catch(() => undefined);
if (isWecomFeedbackEnabled()) {
const feedback = notifyPartnerSubmitted(
db,
{ distributionId: assignment.id, kind: "screenshot" },
resolveWecomConfig(env as unknown as WecomBindings),
).catch(() => undefined);
runInBackground(feedback, "wecom screenshot feedback");
}
return Response.json({ submitted: true });
}

View File

@@ -1,6 +1,7 @@
import { getRuntimeEnv } from "../../../lib/runtime-env";
import type { StoredObjectBody } from "../../../lib/object-store";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { getOwnerScope, isTaskOwner, ownerForbidden } from "../../../lib/owner-scope";
import { parseStoredDate } from "../../../lib/date-utils";
import {
downloadFeishuMedia,
@@ -39,6 +40,7 @@ type ExportRow = {
publish_url: string | null;
publish_time: string | null;
publish_screenshot_key: string | null;
result_submitted_at: string | null;
screenshot_key: string | null;
exposure: number | null;
views: number | null;
@@ -241,10 +243,27 @@ export async function GET(request: Request) {
return Response.json({ error: "缺少导出任务" }, { status: 400 });
}
const db = getRawDb();
if (!tokenPayload) {
const scope = await getOwnerScope(request);
if (
scope.kind === "owner" &&
!(await isTaskOwner(db, taskId, scope.wecomUserId))
) {
return ownerForbidden();
}
}
const task = await db
.prepare("SELECT id, name, brand, platform FROM tasks WHERE id = ?")
.prepare(
"SELECT id, name, brand, platform, recovery_enabled FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<{ id: string; name: string; brand: string; platform: string }>();
.first<{
id: string;
name: string;
brand: string;
platform: string;
recovery_enabled: number;
}>();
if (!task) {
return Response.json({ error: "没有找到这个任务" }, { status: 404 });
}
@@ -260,6 +279,7 @@ export async function GET(request: Request) {
d.publish_url,
d.publish_time,
d.publish_screenshot_key,
d.result_submitted_at,
d.screenshot_key,
d.exposure,
d.views,
@@ -294,6 +314,7 @@ export async function GET(request: Request) {
.bind(taskId)
.all<ExportRow>();
const rows = result.results;
const recoveryEnabled = Number(task.recovery_enabled) === 1;
const assetsByContent = new Map(
rows.map((row) => [row.content_id, parseAssets(row.image_assets)]),
);
@@ -346,6 +367,7 @@ export async function GET(request: Request) {
"采集状态",
"合作方",
"领取人微信号/手机号",
...(recoveryEnabled ? ["收录截图"] : []),
];
const originalImageStart = 3;
const accountColumn = originalImageStart + maxContentImages;
@@ -388,6 +410,15 @@ export async function GET(request: Request) {
row.distribution_id ? collectionLabel(row) : "未领取",
row.partner_name || "",
row.claimant_name || "",
...(recoveryEnabled
? [
row.distribution_id
? row.result_submitted_at
? "已提交"
: "未提交"
: "—",
]
: []),
];
const images: RecoveryWorkbookRow["images"] = [];
for (const asset of contentAssets) {
@@ -423,6 +454,7 @@ export async function GET(request: Request) {
24,
18,
22,
...(recoveryEnabled ? [12] : []),
];
const workbook = buildRecoveryWorkbook({
sheetName: `${task.name}-数据回收`,

View File

@@ -1,290 +1,26 @@
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,
ensurePartnersForPoolAccounts,
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 +46,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 +62,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 +76,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 +87,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 +134,14 @@ export async function POST(request: Request) {
);
}
await writeAnalyzedRows(importableRows);
runInBackground(
tagUntaggedAccounts(importableRows),
"resource account tagging",
);
runInBackground(
ensurePartnersForPoolAccounts(),
"pool partner sync after resource import",
);
if (deferredEnrichment > 0) {
const importableSourceRows = rows.filter((row) =>
importableRowNumbers.has(row.rowNumber),

View File

@@ -0,0 +1,75 @@
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { runInBackground } from "../../../lib/background";
import { buildManualRow, type ManualResourceInput } from "../../../lib/resource-import";
import {
analyzeRows,
ensurePartnersForPoolAccounts,
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",
);
runInBackground(
ensurePartnersForPoolAccounts(),
"pool partner sync after manual resource insert",
);
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

@@ -2,6 +2,7 @@ import { zipSync, strToU8 } from "fflate";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { ensureSchema, getRawDb, getUploadBucket } from "../../../lib/mvp-db";
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
import { getOwnerScope, isTaskOwner, ownerForbidden } from "../../../lib/owner-scope";
function safeName(value: string) {
return value.replace(/[\\/:*?"<>|\r\n]/g, "_").trim().slice(0, 60) || "KOC";
@@ -20,12 +21,19 @@ export async function GET(request: Request) {
if (!taskId) {
return Response.json({ error: "缺少任务参数" }, { status: 400 });
}
const scope = await getOwnerScope(request);
if (
scope.kind === "owner" &&
!(await isTaskOwner(getRawDb(), taskId, scope.wecomUserId))
) {
return ownerForbidden();
}
const task = await getRawDb()
.prepare("SELECT name, task_type FROM tasks WHERE id = ?")
.prepare("SELECT name FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ name: string; task_type: string }>();
if (!task || task.task_type !== "screenshot_collect") {
return Response.json({ error: "没有找到截图回收任务" }, { status: 404 });
.first<{ name: string }>();
if (!task) {
return Response.json({ error: "没有找到任务" }, { status: 404 });
}
const rows = await getRawDb()
.prepare(
@@ -52,7 +60,7 @@ export async function GET(request: Request) {
}>();
const files: Record<string, Uint8Array> = {};
const manifest = [
["序号", "搜索关键词", "领取人", "领取时间", "提交时间", "文件名"],
["序号", "笔记标题", "领取人", "领取时间", "提交时间", "文件名"],
];
let exported = 0;
for (const [rowIndex, row] of rows.results.entries()) {
@@ -70,7 +78,7 @@ export async function GET(request: Request) {
exported += 1;
}
manifest.push([
String(row.source_row ?? ""),
String(row.source_row ?? rowIndex + 1),
row.title,
row.claimant_name || row.partner_name,
row.claimed_at,

View File

@@ -1,31 +1,46 @@
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { ensureSchema, getRawDb, getUploadBucket } from "../../../lib/mvp-db";
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
import { getOwnerScope, isDistributionOwner, ownerForbidden } from "../../../lib/owner-scope";
export async function GET(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const distributionId = new URL(request.url).searchParams
.get("distribution")
?.trim();
const url = new URL(request.url);
const distributionId = url.searchParams.get("distribution")?.trim();
const scope = await getOwnerScope(request);
if (
scope.kind === "owner" &&
!(await isDistributionOwner(getRawDb(), distributionId ?? "", scope.wecomUserId))
) {
return ownerForbidden();
}
// kind=publish 时返回 KOC 上传的发布截图(分发详情页核对用)
const imageKind = url.searchParams.get("kind") === "publish" ? "publish" : "result";
const imageIndex = Math.max(
1,
Number(new URL(request.url).searchParams.get("index") || 1),
Number(url.searchParams.get("index") || 1),
);
if (!distributionId) {
return Response.json({ error: "缺少任务记录" }, { status: 400 });
}
const row = await getRawDb()
.prepare(
`SELECT result_screenshot_key FROM distributions
`SELECT result_screenshot_key, publish_screenshot_key FROM distributions
WHERE id = ?`,
)
.bind(distributionId)
.first<{ result_screenshot_key: string | null }>();
const screenshotKey = row
? parseResultScreenshotKeys(row.result_screenshot_key)[imageIndex - 1]
: undefined;
.first<{
result_screenshot_key: string | null;
publish_screenshot_key: string | null;
}>();
const screenshotKey =
imageKind === "publish"
? row?.publish_screenshot_key ?? null
: row
? parseResultScreenshotKeys(row.result_screenshot_key)[imageIndex - 1]
: undefined;
if (!screenshotKey) {
return Response.json({ error: "任务截图不存在" }, { status: 404 });
}

View File

@@ -13,6 +13,7 @@ type UserRow = {
id: string;
username: string;
role: UserRole;
wecom_userid: string | null;
created_at: string;
updated_at: string;
};
@@ -28,7 +29,7 @@ async function manager(request: Request) {
async function listUsers() {
const result = await getRawDb()
.prepare(
`SELECT id, username, role, created_at, updated_at
`SELECT id, username, role, wecom_userid, created_at, updated_at
FROM users
ORDER BY CASE role WHEN 'super_admin' THEN 1 WHEN 'admin' THEN 2 ELSE 3 END,
created_at ASC`,
@@ -54,6 +55,7 @@ export async function POST(request: Request) {
password?: string;
role?: UserRole;
userId?: string;
wecomUserid?: string;
};
const db = getRawDb();
@@ -65,6 +67,9 @@ export async function POST(request: Request) {
const passwordRecord = await createPasswordRecord(password);
const username = normalizeUsername(body.username);
const role = body.role === "admin" ? "admin" : "user";
const wecomUserid = String(body.wecomUserid ?? "")
.trim()
.slice(0, 128);
if (!validateUsername(username)) {
return Response.json(
{ error: "账号需为2—32位中文、字母、数字或 _ . @ + -" },
@@ -77,8 +82,8 @@ export async function POST(request: Request) {
await db
.prepare(
`INSERT INTO users
(id, username, password_hash, password_salt, password_iterations, role)
VALUES (?, ?, ?, ?, ?, ?)`,
(id, username, password_hash, password_salt, password_iterations, role, wecom_userid)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
crypto.randomUUID(),
@@ -87,8 +92,36 @@ export async function POST(request: Request) {
passwordRecord.passwordSalt,
passwordRecord.passwordIterations,
role,
wecomUserid,
)
.run();
} else if (body.action === "set_wecom") {
const wecomUserid = String(body.wecomUserid ?? "")
.trim()
.slice(0, 128);
const target = await db
.prepare("SELECT id, role FROM users WHERE id = ?")
.bind(String(body.userId ?? ""))
.first<{ id: string; role: UserRole }>();
if (!target) {
return Response.json({ error: "没有找到这个账号" }, { status: 404 });
}
if (target.role === "super_admin") {
return Response.json(
{ error: "不能修改超级管理员账号" },
{ status: 403 },
);
}
if (currentUser.role !== "super_admin" && target.role !== "user") {
return managerForbidden();
}
await db
.prepare(
`UPDATE users SET wecom_userid = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(wecomUserid, target.id)
.run();
} else if (body.action === "reset_password") {
const password = String(body.password ?? "");
if (!validatePassword(password)) {

177
app/api/wecom-bind/route.ts Normal file
View File

@@ -0,0 +1,177 @@
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import {
buildWecomOAuthUrl,
exchangeWecomOAuthCode,
resolveWecomConfig,
WecomClientError,
type WecomBindings,
} from "../../../lib/wecom-client";
import { getRequestPrincipal } from "../../../lib/user-auth";
import {
signWecomBindState,
verifyWecomBindState,
} from "../../../lib/wecom-oauth-service";
const env = getRuntimeEnv();
function bindResultPage(
title: string,
message: string,
options: { ok?: boolean; status?: number; redirectHome?: boolean } = {},
) {
const ok = options.ok ?? false;
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
background:#f4f6f5; font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif; color:#1f2a26; }
.card { width:min(420px, calc(100% - 40px)); padding:32px 28px; border:1px solid #e3e8e5; border-radius:16px;
background:#fff; text-align:center; }
.icon { width:44px; height:44px; margin:0 auto 14px; border-radius:50%; display:flex; align-items:center;
justify-content:center; font-size:22px; font-weight:700; color:#fff; }
.ok .icon { background:#2f9e75; } .fail .icon { background:#c9503f; }
h1 { margin:0 0 10px; font-size:17px; }
p { margin:0 0 18px; font-size:13px; line-height:1.8; color:#5f6a65; word-break:break-all; }
a { display:inline-block; padding:9px 22px; border-radius:10px; background:#2f9e75; color:#fff;
font-size:13px; text-decoration:none; }
</style>
</head>
<body>
<div class="card ${ok ? "ok" : "fail"}">
<div class="icon">${ok ? "✓" : "!"}</div>
<h1>${title}</h1>
<p>${message}</p>
<a href="/">回到工作台</a>
</div>
${options.redirectHome ? "<script>setTimeout(function(){location.href = '/';},1500)</script>" : ""}
</body>
</html>`;
return new Response(html, {
status: options.status ?? 200,
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "private, no-store",
},
});
}
function appOrigin(requestUrl: URL) {
const configured = String(env.APP_ORIGIN ?? "").trim().replace(/\/$/, "");
return configured || requestUrl.origin;
}
async function handleStart(request: Request, requestUrl: URL) {
const principal = await getRequestPrincipal(request);
if (!principal || principal.kind !== "user") {
return Response.redirect(new URL("/", requestUrl).toString(), 302);
}
const config = resolveWecomConfig(env as unknown as WecomBindings);
if (!config.corpId || !config.agentId || !config.secret) {
return bindResultPage(
"暂不可用",
"服务器未配置企业微信自建应用(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET),请联系管理员。",
{ status: 503 },
);
}
const state = await signWecomBindState(principal.user.id);
const redirectUri = `${appOrigin(requestUrl)}/api/wecom-bind`;
return new Response(null, {
status: 302,
headers: {
location: buildWecomOAuthUrl({
corpId: config.corpId,
agentId: config.agentId,
redirectUri,
state,
}),
"cache-control": "private, no-store",
},
});
}
async function handleCallback(request: Request, requestUrl: URL) {
const code = String(requestUrl.searchParams.get("code") ?? "").trim();
const state = String(requestUrl.searchParams.get("state") ?? "").trim();
if (!code || !state) {
return bindResultPage("绑定失败", "缺少企业微信授权参数,请回到工作台重新发起绑定。", {
status: 400,
});
}
const verified = await verifyWecomBindState(state);
if (!verified) {
return bindResultPage(
"绑定链接已过期",
"本次授权会话已失效(10 分钟内有效),请回到工作台重新点击「企业微信授权绑定」。",
{ status: 400 },
);
}
const principal = await getRequestPrincipal(request);
if (
!principal ||
principal.kind !== "user" ||
principal.user.id !== verified.userId
) {
return bindResultPage(
"登录态校验失败",
"请先在本浏览器登录发起绑定的那个后台账号,再重新完成企业微信授权。",
{ status: 403 },
);
}
const config = resolveWecomConfig(env as unknown as WecomBindings);
let identity: { userId?: string; externalUserId?: string };
try {
identity = await exchangeWecomOAuthCode(config, code);
} catch (error) {
const message =
error instanceof WecomClientError
? error.message
: "企业微信授权身份换取失败,请稍后重试。";
return bindResultPage("绑定失败", message, { status: 502 });
}
if (!identity.userId) {
return bindResultPage(
"无法绑定",
"当前企微身份不是企业内部成员(外部联系人不能绑定后台账号),请用企业内部成员身份授权。",
{ status: 400 },
);
}
await ensureSchema();
const db = getRawDb();
const conflict = await db
.prepare(
"SELECT id, username FROM users WHERE wecom_userid = ? AND id <> ? LIMIT 1",
)
.bind(identity.userId, principal.user.id)
.first<{ id: string; username: string }>();
if (conflict) {
return bindResultPage(
"该企微账号已被绑定",
`企微账号 ${identity.userId} 已绑定到后台账号「${conflict.username}」,如需换绑请联系管理员在「用户管理」中处理。`,
{ status: 409 },
);
}
await db
.prepare(
"UPDATE users SET wecom_userid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
)
.bind(identity.userId, principal.user.id)
.run();
return bindResultPage(
"绑定成功",
`已将企微账号 ${identity.userId} 绑定到当前后台账号,正在返回工作台…`,
{ ok: true, redirectHome: true },
);
}
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
if (requestUrl.searchParams.has("code")) {
return handleCallback(request, requestUrl);
}
return handleStart(request, requestUrl);
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { readApiResponse } from "./wecom-user-picker";
type CustomerChat = {
chatId: string;
name: string;
ownerUserId: string;
memberCount: number;
status: number;
};
type BindResult = {
customerChatId: string;
customerChatName: string;
joinConfigId: string;
joinQrCode: string;
qrError?: string;
};
export function TaskCustomerChatModal({
task,
onClose,
onBound,
}: {
task: { id: string; name: string; customerChatId: string; customerChatName: string };
onClose: () => void;
onBound: () => void;
}) {
const [chats, setChats] = useState<CustomerChat[]>([]);
const [boundChatId, setBoundChatId] = useState(task.customerChatId);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [selectedChatId, setSelectedChatId] = useState(task.customerChatId);
const [bindResult, setBindResult] = useState<BindResult | null>(null);
const loadChats = useCallback(async (options?: { sync?: boolean }) => {
setLoading(true);
setError("");
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "task_customer_chats",
taskId: task.id,
sync: options?.sync === true,
}),
});
const parsed = await readApiResponse<{
chats?: CustomerChat[];
boundChatId?: string;
error?: string;
}>(response, "获取客户群列表失败");
if (!response.ok) {
throw new Error(parsed.error || "获取客户群列表失败");
}
setChats(Array.isArray(parsed.chats) ? parsed.chats : []);
const bound = String(parsed.boundChatId ?? "");
setBoundChatId(bound);
setSelectedChatId(bound);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "获取客户群列表失败");
} finally {
setLoading(false);
}
}, [task.id]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadChats();
}, 0);
return () => window.clearTimeout(timer);
}, [loadChats]);
const submitBind = async (chatId: string) => {
const chat = chats.find((item) => item.chatId === chatId);
setSaving(true);
setError("");
setNotice("");
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "task_bind_customer_chat",
taskId: task.id,
chatId,
chatName: chat?.name ?? "",
}),
});
const parsed = await readApiResponse<{ task?: BindResult; error?: string }>(
response,
"绑定客户群失败",
);
if (!response.ok) {
throw new Error(parsed.error || "绑定客户群失败");
}
const result = parsed.task ?? null;
setBindResult(result);
setBoundChatId(chatId);
setSelectedChatId(chatId);
setNotice(
chatId
? result?.qrError
? `已绑定,但${result.qrError},可重新保存刷新`
: `已绑定「${result?.customerChatName || chat?.name || chatId}」,认领人扫码即可进群`
: "已解绑,认领页不再展示进群二维码",
);
onBound();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "绑定客户群失败");
} finally {
setSaving(false);
}
};
const unbind = async () => {
if (!window.confirm("解绑后认领页将不再展示进群二维码,确定解绑?")) return;
await submitBind("");
};
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div
className="modal-card wecom-picker-modal"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<h2>绑定客户群</h2>
<p>
列出全部成员名下的企业微信客户群(含各群主的外部群),绑定后生成进群活码,认领人在领取页扫码进群。
</p>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="import-guide-strip">
<span className="wecom-picker-cache-hint">
任务:{task.name}
{boundChatId ? ` · 当前绑定:${task.customerChatName || boundChatId}` : " · 未绑定"}
</span>
<button
type="button"
className="ghost-button"
onClick={() => void loadChats({ sync: true })}
disabled={loading || saving}
>
{loading ? "同步中…" : "同步最新群列表"}
</button>
</div>
{error && <p className="form-error">{error}</p>}
{notice && <p className="wecom-picker-cache-hint">{notice}</p>}
{bindResult?.joinQrCode && (
<div className="join-qr-preview">
<img src={bindResult.joinQrCode} alt="进群活码" />
<span>进群活码(已同步展示在认领页)</span>
</div>
)}
<div className="wecom-picker-list">
{loading ? (
<div className="wecom-picker-empty">正在拉取客户群列表…</div>
) : chats.length === 0 ? (
<div className="wecom-picker-empty">
未拉取到客户群——请群主先在企微客户端创建客户群(内外部成员都可拉入,且群主需在应用可见范围内),
再点击「同步最新群列表」。
</div>
) : (
chats.map((chat) => {
const isBound = chat.chatId === boundChatId;
return (
<label key={chat.chatId} className="wecom-picker-row">
<span className="wecom-picker-avatar placeholder" aria-hidden="true">
{(chat.name || chat.chatId).slice(0, 1)}
</span>
<span className="wecom-picker-meta">
<span className="wecom-picker-name">{chat.name || chat.chatId}</span>
<span className="wecom-picker-sub">
群主 {chat.ownerUserId || "—"} · {chat.memberCount} 人
</span>
<code className="wecom-picker-id">{chat.chatId}</code>
</span>
<input
type="radio"
name="customer-chat"
checked={selectedChatId === chat.chatId}
onChange={() => setSelectedChatId(chat.chatId)}
/>
{isBound ? <span className="wecom-picker-current">已绑定</span> : null}
</label>
);
})
)}
</div>
<div className="modal-actions">
{boundChatId ? (
<button
type="button"
className="ghost-button"
onClick={() => void unbind()}
disabled={saving}
>
解绑当前群
</button>
) : null}
<button type="button" className="ghost-button" onClick={onClose} disabled={saving}>
关闭
</button>
<button
type="button"
className="primary-button"
onClick={() => void submitBind(selectedChatId)}
disabled={saving || loading || !selectedChatId || selectedChatId === boundChatId}
>
{saving ? "保存中…" : selectedChatId === boundChatId ? "已绑定" : "绑定所选群"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -2,11 +2,13 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import type { AuthUser, UserRole } from "../lib/user-auth";
import { WecomUserPickerModal } from "./wecom-user-picker";
type ManagedUser = {
id: string;
username: string;
role: UserRole;
wecom_userid: string | null;
created_at: string;
updated_at: string;
};
@@ -31,6 +33,9 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
const [resetPassword, setResetPassword] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ManagedUser | null>(null);
const [createWecom, setCreateWecom] = useState("");
const [createPickerOpen, setCreatePickerOpen] = useState(false);
const [bindTarget, setBindTarget] = useState<ManagedUser | null>(null);
const [message, setMessage] = useState("");
const [working, setWorking] = useState(false);
@@ -58,13 +63,20 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "create", username, password, role }),
body: JSON.stringify({
action: "create",
username,
password,
role,
wecomUserid: createWecom,
}),
}),
);
setUsers(next);
setUsername("");
setPassword("");
setRole("user");
setCreateWecom("");
setMessage("账号已创建,可以把账号和密码交给用户登录");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "创建失败");
@@ -126,6 +138,27 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
}
};
const bindWecom = async (userId: string, wecomUserid: string) => {
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set_wecom", userId, wecomUserid }),
}),
);
setUsers(next);
setBindTarget(null);
setMessage(wecomUserid ? "企微绑定已更新,立即生效" : "已解除企微绑定");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "绑定失败");
} finally {
setWorking(false);
}
};
const canReset = (item: ManagedUser) => {
if (currentUser.role === "super_admin") {
return item.role !== "super_admin" || item.id === currentUser.id;
@@ -133,6 +166,11 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
return item.role === "user";
};
const canBind = (item: ManagedUser) => {
if (item.role === "super_admin") return false;
return currentUser.role === "super_admin" || item.role === "user";
};
const canDelete = (item: ManagedUser) => {
if (item.id === currentUser.id || item.role === "super_admin") return false;
return currentUser.role === "super_admin" || item.role === "user";
@@ -182,6 +220,22 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
</select>
</label>
)}
<label className="user-role-field">
<span>企微 userid(任务负责人,可选)</span>
<input
value={createWecom}
onChange={(event) => setCreateWecom(event.target.value)}
placeholder="绑定后该账号只看自己负责的任务"
autoComplete="off"
/>
<button
type="button"
className="ghost-button"
onClick={() => setCreatePickerOpen(true)}
>
从企微选择
</button>
</label>
<button className="primary-button" disabled={working}>
{working ? "处理中…" : "创建账号"}
</button>
@@ -198,12 +252,45 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
</div>
<div className="user-table">
<div className="user-table-head">
<span>登录账号</span><span>角色</span><span>更新时间</span><span>操作</span>
<span>登录账号</span><span>角色</span><span>企微绑定</span><span>更新时间</span><span>操作</span>
</div>
{users.map((item) => (
<div className="user-table-row" key={item.id}>
<strong>{item.username}</strong>
<span className={`role-pill ${item.role}`}>{ROLE_LABEL[item.role]}</span>
<span className="user-wecom-cell">
{item.wecom_userid ? (
<code>{item.wecom_userid}</code>
) : (
<span>—</span>
)}
{canBind(item) && (
item.wecom_userid ? (
<>
<button
className="user-action-button"
onClick={() => setBindTarget(item)}
>
换绑
</button>
<button
className="user-action-button danger"
disabled={working}
onClick={() => void bindWecom(item.id, "")}
>
解绑
</button>
</>
) : (
<button
className="user-action-button"
onClick={() => setBindTarget(item)}
>
绑定
</button>
)
)}
</span>
<span>{new Date(item.updated_at).toLocaleDateString("zh-CN", { timeZone: "Asia/Shanghai" })}</span>
<div className="user-row-actions">
{canReset(item) && (
@@ -307,6 +394,28 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
</div>
</div>
)}
{createPickerOpen && (
<WecomUserPickerModal
mode="single"
selected={createWecom ? [createWecom] : []}
onClose={() => setCreatePickerOpen(false)}
onConfirm={(userids) => {
setCreateWecom(userids[0] ?? "");
setCreatePickerOpen(false);
}}
/>
)}
{bindTarget && (
<WecomUserPickerModal
mode="single"
selected={bindTarget.wecom_userid ? [bindTarget.wecom_userid] : []}
onClose={() => setBindTarget(null)}
onConfirm={(userids) => {
void bindWecom(bindTarget.id, userids[0] ?? "");
}}
/>
)}
</div>
);
}

239
app/wecom-user-picker.tsx Normal file
View File

@@ -0,0 +1,239 @@
"use client";
import { useCallback, useEffect, useState } from "react";
export async function readApiResponse<T>(
response: Response,
fallback: string,
) {
const text = await response.text();
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("json")) {
if (response.status === 403) {
throw new Error("请求被安全网关拦截,请刷新页面后重试");
}
throw new Error(`${fallback}(服务返回异常,HTTP ${response.status})`);
}
try {
return JSON.parse(text) as T & { error?: string };
} catch {
throw new Error(`${fallback}(服务返回的数据无法解析)`);
}
}
export type InternalWecomContact = {
userid: string;
name: string;
departmentNames: string[];
};
let internalWecomContactsCache: {
contacts: InternalWecomContact[];
fetchedAt: number;
} | null = null;
const INTERNAL_WECOM_CONTACTS_TTL_MS = 5 * 60 * 1000;
export function WecomUserPickerModal({
mode,
selected,
onClose,
onConfirm,
}: {
mode: "single" | "multi";
selected: string[];
onClose: () => void;
onConfirm: (userids: string[]) => void;
}) {
const [contacts, setContacts] = useState<InternalWecomContact[]>(
internalWecomContactsCache?.contacts ?? [],
);
const [fetchedAt, setFetchedAt] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [checked, setChecked] = useState<string[]>(selected.filter(Boolean));
const loadContacts = useCallback(async (force = false) => {
const snapshot = internalWecomContactsCache;
if (
!force &&
snapshot &&
Date.now() - snapshot.fetchedAt <= INTERNAL_WECOM_CONTACTS_TTL_MS
) {
setContacts(snapshot.contacts);
setFetchedAt(snapshot.fetchedAt);
setLoading(false);
return;
}
setLoading(true);
setError("");
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "wecom_list_internal_contacts" }),
});
const parsed = await readApiResponse<{
contacts?: InternalWecomContact[];
error?: string;
}>(response, "获取企业微信通讯录失败");
if (!response.ok) {
throw new Error(parsed.error || "获取企业微信通讯录失败");
}
const list = Array.isArray(parsed.contacts) ? parsed.contacts : [];
internalWecomContactsCache = { contacts: list, fetchedAt: Date.now() };
setContacts(list);
setFetchedAt(internalWecomContactsCache.fetchedAt);
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "获取企业微信通讯录失败",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadContacts();
}, 0);
return () => window.clearTimeout(timer);
}, [loadContacts]);
const keyword = search.trim().toLowerCase();
const filteredContacts =
keyword.length === 0
? contacts
: contacts.filter((contact) =>
`${contact.name} ${contact.userid}`.toLowerCase().includes(keyword),
);
const toggleChecked = (userid: string) => {
setChecked((previous) =>
previous.includes(userid)
? previous.filter((item) => item !== userid)
: [...previous, userid],
);
};
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div
className="modal-card wecom-picker-modal"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<h2>选择企业微信成员</h2>
<p>
从企业微信通讯录(自建应用可见范围)中挑选,
{mode === "multi" ? "勾选后点击确定" : "点击成员即填入"}。
</p>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="import-guide-strip">
<input
type="search"
className="wecom-picker-search"
placeholder="按姓名 / userid 搜索"
value={search}
onChange={(event) => setSearch(event.target.value)}
disabled={loading}
/>
<button
type="button"
className="ghost-button"
onClick={() => void loadContacts(true)}
disabled={loading}
>
{loading ? "拉取中…" : "刷新列表"}
</button>
{fetchedAt > 0 && !loading && (
<span className="wecom-picker-cache-hint">
缓存于 {new Date(fetchedAt).toLocaleTimeString()}(5 分钟内复用)
</span>
)}
</div>
{error && (
<p className="form-error">
{error} 可关闭此弹窗,直接在输入框手动填写 userid。
</p>
)}
<div className="wecom-picker-list">
{loading && contacts.length === 0 ? (
<div className="wecom-picker-empty">正在拉取企业微信通讯录…</div>
) : contacts.length === 0 ? (
<div className="wecom-picker-empty">
{error
? "拉取失败,请检查应用配置(通讯录权限 / 可信 IP)后重试"
: "未拉取到通讯录成员——仅显示自建应用可见范围内的成员,也可手动填写 userid"}
</div>
) : filteredContacts.length === 0 ? (
<div className="wecom-picker-empty">没有匹配的成员</div>
) : (
filteredContacts.map((contact) => {
const isSelected =
mode === "multi"
? checked.includes(contact.userid)
: selected.includes(contact.userid);
const rowBody = (
<>
<span className="wecom-picker-avatar placeholder" aria-hidden="true">
{(contact.name || contact.userid).slice(0, 1)}
</span>
<span className="wecom-picker-meta">
<span className="wecom-picker-name">
{contact.name || contact.userid}
</span>
<span className="wecom-picker-sub">
{contact.departmentNames.join(" / ") || "—"}
</span>
<code className="wecom-picker-id">{contact.userid}</code>
</span>
</>
);
return mode === "multi" ? (
<label key={contact.userid} className="wecom-picker-row">
{rowBody}
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleChecked(contact.userid)}
/>
</label>
) : (
<button
key={contact.userid}
type="button"
className="wecom-picker-row"
onClick={() => onConfirm([contact.userid])}
>
{rowBody}
{isSelected ? (
<span className="wecom-picker-current">当前</span>
) : null}
</button>
);
})
)}
</div>
{mode === "multi" && (
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose}>
取消
</button>
<button
type="button"
className="primary-button"
onClick={() => onConfirm(checked)}
>
{checked.length > 0 ? `确定(${checked.length} 人)` : "确定"}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -21,6 +21,11 @@ export const partners = mysqlTable("partners", {
owner: varchar("owner", { length: 255 }).notNull().default("运营组"),
claimedTotal: int("claimed_total").notNull().default(0),
completedTotal: int("completed_total").notNull().default(0),
wecomExternalUserId: varchar("wecom_external_user_id", { length: 128 }),
partnerType: varchar("partner_type", { length: 32 })
.notNull()
.default("external_koc"),
wecomUserid: varchar("wecom_userid", { length: 128 }).notNull().default(""),
createdAt: timestamp("created_at"),
});
@@ -52,6 +57,30 @@ export const tasks = mysqlTable(
mode: "string",
fsp: 3,
}),
projectId: varchar("project_id", { length: 64 }).notNull().default(""),
taskChatId: varchar("task_chat_id", { length: 64 }).notNull().default(""),
ownerUserid: varchar("owner_userid", { length: 64 }).notNull().default(""),
customerChatId: varchar("customer_chat_id", { length: 128 })
.notNull()
.default(""),
customerChatName: varchar("customer_chat_name", { length: 255 })
.notNull()
.default(""),
joinConfigId: varchar("join_config_id", { length: 128 })
.notNull()
.default(""),
joinQrCode: text("join_qr_code").notNull().default(""),
relatedTaskId: varchar("related_task_id", { length: 64 })
.notNull()
.default(""),
recoveryEnabled: int("recovery_enabled").notNull().default(1),
recoveryDueAt: varchar("recovery_due_at", { length: 32 })
.notNull()
.default(""),
recoveryInstructions: text("recovery_instructions").notNull().default(""),
recoveryExampleImages: text("recovery_example_images")
.notNull()
.default("[]"),
createdAt: timestamp("created_at"),
},
(table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)],
@@ -67,6 +96,9 @@ export const contents = mysqlTable("contents", {
status: varchar("status", { length: 32 }).notNull().default("available"),
source: varchar("source", { length: 255 }).notNull().default("飞书内容表"),
sourceRow: int("source_row"),
relatedContentId: varchar("related_content_id", { length: 64 })
.notNull()
.default(""),
createdAt: timestamp("created_at"),
});
@@ -106,6 +138,9 @@ export const claims = mysqlTable(
id: varchar("id", { length: 64 }).primaryKey(),
taskId: varchar("task_id", { length: 64 }).notNull(),
partnerId: varchar("partner_id", { length: 64 }).notNull(),
ownerPartnerId: varchar("owner_partner_id", { length: 64 })
.notNull()
.default(""),
claimantName: varchar("claimant_name", { length: 255 }).notNull(),
claimToken: varchar("claim_token", { length: 128 }).notNull(),
quantity: int("quantity").notNull(),
@@ -174,6 +209,7 @@ export const distributions = mysqlTable("distributions", {
latestComments: int("latest_comments"),
latestCollects: int("latest_collects"),
latestShares: int("latest_shares"),
publicMetricsScreenshotKey: text("public_metrics_screenshot_key"),
collectionStatus: varchar("collection_status", { length: 32 })
.notNull()
.default("pending"),
@@ -220,6 +256,7 @@ export const users = mysqlTable(
passwordSalt: varchar("password_salt", { length: 255 }).notNull(),
passwordIterations: int("password_iterations").notNull(),
role: varchar("role", { length: 32 }).notNull(),
wecomUserid: varchar("wecom_userid", { length: 128 }).notNull().default(""),
createdAt: timestamp("created_at"),
updatedAt: timestamp("updated_at"),
},
@@ -252,6 +289,65 @@ export const mcpExportTokens = mysqlTable(
(table) => [index("mcp_export_tokens_expires_at_idx").on(table.expiresAt)],
);
export const wecomGroupChats = mysqlTable(
"wecom_group_chats",
{
chatId: varchar("chat_id", { length: 128 }).primaryKey(),
name: varchar("name", { length: 512 }).notNull().default(""),
ownerUserId: varchar("owner_user_id", { length: 128 }).notNull(),
memberCount: int("member_count").notNull().default(0),
status: int("status").notNull().default(0),
syncedAt: timestamp("synced_at"),
},
(table) => [index("wecom_group_chats_owner_idx").on(table.ownerUserId)],
);
export const wecomGroupPushes = mysqlTable(
"wecom_group_pushes",
{
id: varchar("id", { length: 64 }).primaryKey(),
taskId: varchar("task_id", { length: 64 }).notNull(),
msgid: varchar("msgid", { length: 128 }).notNull().default(""),
sender: varchar("sender", { length: 128 }).notNull(),
chatIds: text("chat_ids").notNull(),
textContent: text("text_content").notNull(),
linkUrl: text("link_url").notNull(),
failList: text("fail_list").notNull(),
createdAt: timestamp("created_at"),
},
(table) => [index("wecom_group_pushes_task_idx").on(table.taskId)],
);
export const wecomPushLogs = mysqlTable(
"wecom_push_logs",
{
id: varchar("id", { length: 64 }).primaryKey(),
taskId: varchar("task_id", { length: 64 }).notNull(),
projectId: varchar("project_id", { length: 64 }).notNull().default(""),
pushKind: varchar("push_kind", { length: 32 }).notNull().default("task_open"),
mainGroupCount: int("main_group_count").notNull().default(0),
kocUserids: text("koc_userids").notNull(),
kocOkCount: int("koc_ok_count").notNull().default(0),
kocFailList: text("koc_fail_list").notNull(),
groupPushId: varchar("group_push_id", { length: 128 }).notNull().default(""),
detail: text("detail").notNull(),
createdAt: timestamp("created_at"),
},
(table) => [index("wecom_push_logs_task_idx").on(table.taskId, table.pushKind)],
);
export const projects = mysqlTable("projects", {
id: varchar("id", { length: 64 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
brand: varchar("brand", { length: 255 }).notNull().default(""),
mainGroupMemberIds: text("main_group_member_ids").notNull(),
ownerUserid: varchar("owner_userid", { length: 64 }).notNull().default(""),
mainChatId: varchar("main_chat_id", { length: 64 }).notNull().default(""),
status: varchar("status", { length: 32 }).notNull().default("active"),
note: text("note").notNull(),
createdAt: timestamp("created_at"),
});
export const backgroundJobs = mysqlTable(
"background_jobs",
{

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

42
dev.vars_exp Normal file
View File

@@ -0,0 +1,42 @@
KOC_PORTAL_URL=http://localhost:3000
ADMIN_ALLOWED_EMAIL=operator@example.com
ADMIN_INTERNAL_TOKEN=replace-with-a-random-secret
KOC_DEV=1
# Optional override. The production key must be stored as a runtime secret.
AI_TOOL_CENTER_MCP_URL=https://middle-aitool.gbotai.cn/mcp
AI_TOOL_CENTER_MCP_KEY=replace-with-mcp-key
# Feishu custom app credentials. Keep the secret out of source control.
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxxx
FEISHU_APP_SECRET=replace-with-feishu-app-secret
# 企业微信 · 全部选填;不填则临期催办与汇总都跳过,不影响其他功能
# 管理后台 https://work.weixin.qq.com →「我的企业 → 企业信息 → 企业 ID」
WECOM_CORP_ID=
# 管理后台 →「应用管理 → 自建」→ 创建应用「KOC LOOP 催办」后,应用详情页顶部显示
WECOM_AGENT_ID=
# 同一应用详情页 →「Secret」→ 点「发送」→ 企业微信里收到 64 位字符串
# 注意:Secret 只能发送一次,收到立刻保存;丢了只能点「重置 Secret」重新生成
WECOM_SECRET=
# 测试群 → 群设置 → 群机器人 → 添加机器人 → 完整 webhook URL
# 形如 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx
WECOM_ROBOT_WEBHOOK=
# 临期阈值,默认 3;超过则不在催办。取值 1-30
WECOM_NOTIFY_DUE_DAYS=3
# 企业微信通知总开关,默认 true;临时关闭设为 false
WECOM_NOTIFY_ENABLED=true
# 数据回流通知(可选):KOC 提交链接/截图、采集摘要推运营群机器人,默认关闭
WECOM_FEEDBACK_ENABLED=false
# 超期升级催办(可选):超期任务升级运营群 + 项目总群交互卡片,默认关闭
WECOM_ESCALATION_ENABLED=false
# 超期 N 天自动释放(可选):从 due_at 起算,0 = 关闭(默认),最大 90
WECOM_AUTO_RELEASE_DAYS=0
# 企微回调(可选):交互卡片「已处理」回执落库;回调 URL 为 /api/wecom-callback,默认关闭
WECOM_CALLBACK_ENABLED=false
WECOM_CALLBACK_TOKEN=
WECOM_CALLBACK_ENCODING_AES_KEY=
# 本地测试:让 /api/bootstrap 自动 seed 3 个测试 partner + 5 条待发布 distribution
# 上线后改为 false
SEED_DEMO_DATA=false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -547,6 +547,40 @@ input:focus {
line-height: 1.65;
}
.wecom-identity-chip {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--green-soft);
}
.wecom-identity-chip b {
color: var(--green-deep);
font-size: 11px;
}
.wecom-identity-chip span {
color: var(--ink);
font-size: 11px;
font-weight: 600;
}
.wecom-identity-chip button {
margin-left: auto;
border: none;
background: none;
color: var(--muted);
font-size: 10px;
text-decoration: underline;
cursor: pointer;
padding: 0;
}
.recovery-list {
display: flex;
flex-direction: column;
@@ -668,6 +702,19 @@ footer {
linear-gradient(130deg, #142721, #235b47);
}
.hero-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.recovery-progress {
color: #96b8ab;
font-size: 9px;
font-weight: 600;
}
.claimed-hero h1 {
margin: 0;
font-size: clamp(25px, 4vw, 38px);
@@ -684,6 +731,48 @@ footer {
color: #74cfa9;
}
.join-qr-card {
margin-top: 14px;
padding: 18px 20px;
border-radius: 18px;
background: white;
}
.join-qr-body {
display: flex;
align-items: center;
gap: 18px;
}
.join-qr-body img {
width: 132px;
height: 132px;
border-radius: 12px;
border: 1px solid rgb(20 40 34 / 0.1);
background: white;
flex: none;
}
.join-qr-body h2 {
margin: 0;
font-size: 17px;
color: #17241f;
}
.join-qr-body p {
margin: 8px 0 0;
font-size: 12px;
color: rgb(23 36 31 / 0.6);
line-height: 1.6;
}
@media (max-width: 560px) {
.join-qr-body {
flex-direction: column;
text-align: center;
}
}
.completion-ring {
display: grid;
width: 90px;
@@ -1594,6 +1683,28 @@ footer {
line-height: 1.65;
}
/* 公开数据自动采集状态行:让 KOC 明白数据不用每天手动填 */
.auto-collect-line {
margin: 10px 0 0;
padding: 8px 11px;
border-radius: 8px;
background: #e9f5f0;
border: 1px solid #cdeade;
color: #27775b;
font-size: 8px;
line-height: 1.6;
}
/* 公开数据补交(采集失败兜底)区块 */
.metrics-fallback {
border-top-color: #f3c6cf;
}
.metrics-fallback .creator-recovery-heading > span {
background: #ffeef1;
color: #be123c;
}
.screenshot-picker.compact {
min-height: 96px;
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
trailingSlash: true,
images: { unoptimized: true },
turbopack: { root: path.resolve(process.cwd()) },
allowedDevOrigins: ["192.168.30.90", "localhost", "127.0.0.1"],
};
export default nextConfig;

View File

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

View File

@@ -106,7 +106,7 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
assert.match(page, /placeholder="填写截图中的曝光量"\s*\/>/);
assert.match(page, /placeholder="填写截图中的阅读量"\s*\/>/);
assert.doesNotMatch(page, /recognizeCreatorMetrics/);
assert.match(page, /note-index \$\{\(isScreenshotTask \? item\.result_submitted_at : item\.publish_url\) \? "done" : ""\}/);
assert.match(page, /note-index \$\{item\.publish_url \? "done" : ""\}/);
assert.match(packageJson, /"fflate":\s*"0\.7\.4"/);
assert.doesNotMatch(packageJson, /tesseract\.js/);
assert.match(layout, /逐篇查看笔记详情并一一回填/);
@@ -118,18 +118,24 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
await access(new URL("../public/favicon.svg", import.meta.url));
});
test("renders screenshot-only tasks with anonymous delegation and multi-image uploads", async () => {
test("renders merged task recovery screenshots with anonymous delegation and multi-image uploads", async () => {
const [page, styles] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(page, /screenshot_collect/);
assert.match(page, /小红书搜索关键词/);
assert.match(page, /上传小红书 AI 总结截图/);
assert.match(page, /payload\.task\.recoveryEnabled/);
assert.doesNotMatch(page, /screenshot_collect|isScreenshotTask/);
assert.match(page, /上传发布后的笔记截图/);
assert.match(page, /recoveryInstructions \|\| "请提交发布后的笔记截图"/);
assert.match(page, /回收示例截图/);
assert.match(page, /submit_screenshot_result/);
assert.match(page, /submitRecoveryScreenshots/);
assert.match(page, /请先回填这篇笔记的发布链接,再提交回收截图/);
assert.match(page, /回填发布链接后,这里会开放回收截图上传。/);
assert.match(page, /截图已收/);
assert.match(page, /更新回收截图/);
assert.match(page, /"task-result"/);
assert.match(page, /不需要填写发布链接或其他数据/);
assert.match(page, /multiple/);
assert.match(page, /taskResultScreenshots/);
assert.match(page, /task-result-thumb/);
@@ -138,6 +144,7 @@ test("renders screenshot-only tasks with anonymous delegation and multi-image up
assert.match(styles, /\.task-result-gallery/);
assert.match(styles, /\.image-lightbox/);
assert.match(styles, /\.evidence-preview-button/);
assert.match(styles, /\.recovery-progress/);
});
test("shows D1 and MySQL UTC timestamps in Beijing time", () => {
@@ -170,3 +177,29 @@ test("creates anonymous delegation bundles and reuses one-to-one backfill", asyn
assert.match(styles, /\.share-checkbox/);
assert.match(styles, /\.delegation-history/);
});
test("wires silent wecom oauth with full manual fallback", async () => {
const [page, styles] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(page, /NEXT_PUBLIC_WECOM_CORP_ID/);
assert.match(page, /NEXT_PUBLIC_WECOM_AGENT_ID/);
assert.match(page, /snsapi_base/);
assert.match(page, /connect\/oauth2\/authorize/);
assert.match(page, /wxwork\/i\.test\(userAgent\)/);
assert.match(page, /MicroMessenger\/i\.test\(userAgent\)/);
assert.match(page, /"koc-wecom-identity"/);
assert.match(page, /"koc-wecom-auth-failed"/);
assert.match(page, /\/api\/wecom-oauth/);
assert.match(page, /restoreParamsFromWecomState/);
assert.match(page, /buildWecomOAuthState/);
assert.match(page, /identityTicket: wecomIdentity\.ticket/);
assert.match(page, /企微身份已确认/);
assert.match(page, /切换为手动输入/);
assert.match(page, /window\.location\.replace\(/);
assert.match(styles, /\.wecom-identity-chip/);
assert.match(page, /\(!wecomIdentity \|\| manualEntry\)/);
assert.match(page, /claimantIdentifier:\s*claimantName/);
});

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import {
} from "./mcp-collection-client";
import { uid } from "./mvp-db";
import type { DatabaseClient, DatabaseStatement } from "./database";
import { refreshTaskStatus } from "./task-service";
type DistributionForCollection = {
id: string;
@@ -15,7 +16,8 @@ type DistributionForCollection = {
type ScheduledTask = {
id: string;
collection_start_date: string;
task_type: string | null;
collection_start_date: string | null;
collection_days: string;
};
@@ -62,9 +64,9 @@ function dateForScheduleDay(startDate: string, scheduleDay: number) {
.slice(0, 10);
}
function parseDays(value: string) {
function parseDays(value: string | null | undefined) {
try {
const parsed = JSON.parse(value) as unknown;
const parsed = JSON.parse(value ?? "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return [...new Set(parsed.map(Number))]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
@@ -74,12 +76,35 @@ function parseDays(value: string) {
}
}
export function parseCollectionDays(value: string | null | undefined) {
return parseDays(value);
}
// 数据回收(自动采集)兜底:任务未设置采集日时默认连续采集 7 天。
export const DEFAULT_COLLECTION_DAYS = [1, 2, 3, 4, 5, 6, 7];
export function effectiveCollectionDays(value: string | null | undefined) {
const days = parseDays(value);
return days.length > 0 ? days : [...DEFAULT_COLLECTION_DAYS];
}
export function shanghaiDateFromTimestamp(timestamp: number) {
return new Date(timestamp + 8 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
}
// publish_time 等列以 UTC 文本存储(CURRENT_TIMESTAMP),统一换算成上海日期。
function shanghaiDateFromSqlTimestamp(value: string | null | undefined) {
if (!value) return null;
const normalized = value.includes("T")
? value
: `${value.replace(" ", "T")}Z`;
const epoch = Date.parse(normalized);
if (Number.isNaN(epoch)) return null;
return shanghaiDateFromTimestamp(epoch);
}
function shanghaiHourFromTimestamp(timestamp: number) {
return Number(
new Date(timestamp + 8 * 60 * 60 * 1000)
@@ -336,6 +361,7 @@ export async function collectDistributionMetrics(
distributionId,
),
]);
await refreshTaskStatus(current.task_id, db).catch(() => undefined);
return { skipped: false, likes, comments, collects, shares };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
@@ -353,14 +379,25 @@ export async function collectDistributionMetrics(
.bind(message, run.id),
db
.prepare(
// 已有历史数据(此前采集成功过)的分行失败时不降级为 failed:
// 保留 success 状态与数据时间,仅在描述里说明本次刷新失败。
`UPDATE distributions
SET collection_status = 'failed',
collection_status_description = ?,
collection_updated_at = CURRENT_TIMESTAMP,
SET collection_status = CASE
WHEN latest_likes IS NULL THEN 'failed'
ELSE 'success'
END,
collection_status_description = CASE
WHEN latest_likes IS NULL THEN ?
ELSE ?
END,
collection_updated_at = CASE
WHEN latest_likes IS NULL THEN CURRENT_TIMESTAMP
ELSE collection_updated_at
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(message, distributionId),
.bind(message, `${message},沿用上次采集数据`, distributionId),
]);
throw error;
}
@@ -379,6 +416,42 @@ export async function runScheduledCollections(
);
}
async function distributionIdsNeedingCollection(
db: DatabaseClient,
taskId: string,
scheduledDate: string,
distributionId?: string,
) {
const scoped = distributionId ? "AND d.id = ?" : "";
const { results } = await db
.prepare(
`SELECT d.id FROM distributions d
WHERE d.task_id = ?
AND d.publish_url IS NOT NULL
AND d.publish_url != ''
${scoped}
AND NOT EXISTS (
SELECT 1 FROM collection_runs r
WHERE r.distribution_id = d.id
AND r.scheduled_date = ?
AND (
r.status = 'success'
OR (
r.status = 'collecting'
AND r.started_at >= datetime('now', '-30 minutes')
)
OR (
r.status = 'failed'
AND r.completed_at >= datetime('now', '-15 minutes')
)
)
)`,
)
.bind(...(distributionId ? [taskId, distributionId, scheduledDate] : [taskId, scheduledDate]))
.all<{ id: string }>();
return results.map((row) => row.id);
}
export async function runDueScheduledCollections(
db: DatabaseClient,
timestamp: number,
@@ -389,12 +462,9 @@ export async function runDueScheduledCollections(
const currentDate = shanghaiDateFromTimestamp(timestamp);
const tasks = await db
.prepare(
`SELECT id, collection_start_date, collection_days
`SELECT id, task_type, collection_start_date, collection_days
FROM tasks
WHERE collection_start_date IS NOT NULL
AND collection_start_date != ''
AND collection_days IS NOT NULL
AND collection_days != '[]'
WHERE COALESCE(task_type, 'content_publish') != 'screenshot_collect'
AND (? IS NULL OR id = ?)`,
)
.bind(onlyTaskId ?? null, onlyTaskId ?? null)
@@ -404,46 +474,25 @@ export async function runDueScheduledCollections(
let succeeded = 0;
let failed = 0;
for (const task of tasks.results) {
const schedules = dueSchedules(
task.collection_start_date,
parseDays(task.collection_days),
timestamp,
);
const collectDue = async (
taskId: string,
schedules: { scheduleDay: number; scheduledDate: string }[],
distributionId?: string,
) => {
for (const { scheduleDay, scheduledDate } of schedules) {
dueTasks += 1;
const distributions = await db
.prepare(
`SELECT d.id FROM distributions d
WHERE d.task_id = ?
AND d.publish_url IS NOT NULL
AND d.publish_url != ''
AND NOT EXISTS (
SELECT 1 FROM collection_runs r
WHERE r.distribution_id = d.id
AND r.scheduled_date = ?
AND (
r.status = 'success'
OR (
r.status = 'collecting'
AND r.started_at >= datetime('now', '-30 minutes')
)
OR (
r.status = 'failed'
AND r.completed_at >= datetime('now', '-15 minutes')
)
)
)`,
)
.bind(task.id, scheduledDate)
.all<{ id: string }>();
for (const distribution of distributions.results) {
const ids = await distributionIdsNeedingCollection(
db,
taskId,
scheduledDate,
distributionId,
);
for (const id of ids) {
attempted += 1;
try {
await collectDistributionMetrics(
db,
distribution.id,
id,
scheduledDate,
scheduleDay,
source,
@@ -455,6 +504,47 @@ export async function runDueScheduledCollections(
}
}
}
};
for (const task of tasks.results) {
const parsedDays = parseDays(task.collection_days);
if (task.collection_start_date && parsedDays.length > 0) {
await collectDue(
task.id,
dueSchedules(task.collection_start_date, parsedDays, timestamp),
);
continue;
}
// 未设置采集计划:默认按每篇作品的发布时间起连续采集 7 天。
const days = effectiveCollectionDays(task.collection_days);
const published = await db
.prepare(
`SELECT d.id, COALESCE(d.publish_time, d.claimed_at) AS publish_anchor
FROM distributions d
WHERE d.task_id = ?
AND d.publish_url IS NOT NULL
AND d.publish_url != ''`,
)
.bind(task.id)
.all<{ id: string; publish_anchor: string | null }>();
for (const distribution of published.results) {
const anchorDate = shanghaiDateFromSqlTimestamp(
distribution.publish_anchor,
);
if (!anchorDate) continue;
const windowEndDate = dateForScheduleDay(
anchorDate,
days[days.length - 1],
);
// 默认窗口已过(发布超过 7 天且从未配置计划)不再补采历史天,
// 避免 D1 快照与真实发布时间脱节;确需补采时由管理员设置明确计划。
if (!windowEndDate || currentDate > windowEndDate) continue;
await collectDue(
task.id,
dueSchedules(anchorDate, days, timestamp),
distribution.id,
);
}
}
return { currentDate, dueTasks, attempted, succeeded, failed };

View File

@@ -1,9 +1,9 @@
import type { DatabaseClient } from "./database";
import { refreshTaskStatus } from "./task-service";
type ReleasableDistribution = {
id: string;
task_id: string;
task_type: string;
content_id: string;
content_title: string;
partner_id: string;
@@ -27,7 +27,6 @@ export class DistributionReleaseError extends Error {
export function distributionReleaseBlockReason(input: {
publishUrl?: string | null;
resultSubmittedAt?: string | null;
taskType?: string | null;
}) {
if (input.publishUrl) return "已回填发布链接的笔记不能释放";
if (input.resultSubmittedAt) return "已提交结果截图的任务不能释放";
@@ -42,13 +41,12 @@ export async function releaseUnfinishedDistribution(
throw new DistributionReleaseError("请选择需要释放的领取记录", 400);
}
return database.transaction(async (db) => {
const result = await database.transaction(async (db) => {
const distribution = await db
.prepare(
`SELECT
d.id,
d.task_id,
t.task_type,
d.content_id,
c.title AS content_title,
d.partner_id,
@@ -73,7 +71,6 @@ export async function releaseUnfinishedDistribution(
const blocked = distributionReleaseBlockReason({
publishUrl: distribution.publish_url,
resultSubmittedAt: distribution.result_submitted_at,
taskType: distribution.task_type,
});
if (blocked) throw new DistributionReleaseError(blocked, 409);
@@ -150,4 +147,7 @@ export async function releaseUnfinishedDistribution(
partnerName: distribution.partner_name,
};
});
// 释放会让任务从 completed 回到 active(content 重新 available)
await refreshTaskStatus(result.taskId, database).catch(() => undefined);
return result;
}

View File

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

View File

@@ -127,7 +127,7 @@ export function registerMcpOperationTools(server: McpServer, options: Options) {
"collection_plan_set",
{
title: "设置自动采集计划",
description: "为任务设置开始日期及第1至第7天的自动采集日,系统在北京时间09:00执行。",
description: "为任务设置开始日期及第1至第7天的自动采集日,系统在北京时间09:00执行;未设置时默认自每篇发布时间起连续采集7天。",
inputSchema: z.object({
task_id: z.string().min(1),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("北京时间开始日期 YYYY-MM-DD"),

View File

@@ -104,6 +104,10 @@ export async function ensureSchema(database?: DatabaseClient) {
collection_start_date TEXT,
collection_days TEXT NOT NULL DEFAULT '[]',
collection_schedule_updated_at TEXT,
customer_chat_id TEXT NOT NULL DEFAULT '',
customer_chat_name TEXT NOT NULL DEFAULT '',
join_config_id TEXT NOT NULL DEFAULT '',
join_qr_code TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS contents (
@@ -366,6 +370,56 @@ 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",
);
await ensureColumn(
"partners",
"account_id",
"account_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"partners",
"released_account_id",
"released_account_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"users",
"wecom_userid",
"wecom_userid TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"tasks",
"customer_chat_id",
"customer_chat_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"tasks",
"customer_chat_name",
"customer_chat_name TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"tasks",
"join_config_id",
"join_config_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"tasks",
"join_qr_code",
"join_qr_code TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"tasks",
"related_task_id",
"related_task_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"contents",
"related_content_id",
"related_content_id TEXT NOT NULL DEFAULT ''",
);
const tasksWithoutShare = await db
.prepare(
@@ -521,8 +575,8 @@ export async function seedIfEmpty() {
db
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
(id, name, brand, quantity, claimed_quantity, due_at, status, join_qr_code)
VALUES (?, ?, ?, ?, ?, ?, ?, '')`,
)
.bind(
"task-summer",
@@ -536,8 +590,8 @@ export async function seedIfEmpty() {
db
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
(id, name, brand, quantity, claimed_quantity, due_at, status, join_qr_code)
VALUES (?, ?, ?, ?, ?, ?, ?, '')`,
)
.bind(
"task-living",
@@ -591,8 +645,8 @@ export async function seedIfEmpty() {
batch.push(
db
.prepare(
`INSERT INTO contents (id, task_id, title, body, status)
VALUES (?, ?, ?, ?, ?)`,
`INSERT INTO contents (id, task_id, title, body, status, source_row)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
`content-s-${index + 1}`,
@@ -600,6 +654,7 @@ export async function seedIfEmpty() {
title,
"来自飞书内容表的完整笔记正文与素材说明。",
index < 9 ? "allocated" : "available",
index + 1,
),
);
});
@@ -607,8 +662,8 @@ export async function seedIfEmpty() {
batch.push(
db
.prepare(
`INSERT INTO contents (id, task_id, title, body, status)
VALUES (?, ?, ?, ?, ?)`,
`INSERT INTO contents (id, task_id, title, body, status, source_row)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
`content-l-${index + 1}`,
@@ -616,6 +671,7 @@ export async function seedIfEmpty() {
title,
"来自飞书内容表的完整笔记正文与素材说明。",
index < 6 ? "allocated" : "available",
index + 1,
),
);
});
@@ -804,18 +860,90 @@ export async function seedIfEmpty() {
);
}
// 任务卡与详情的「已领取」以 claimed_quantity 为口径,领取/释放路径会
// 增减它;种子阶段的手写数字与实际分发行数容易漂移,最后统一对账。
batch.push(
db
.prepare(
`UPDATE tasks
SET claimed_quantity = (
SELECT COUNT(*) FROM distributions d WHERE d.task_id = tasks.id
)`,
),
);
await db.batch(batch);
}
export async function getDashboardData() {
export async function getDashboardData(ownerUserid?: string) {
const db = getRawDb();
// null=全量;""=已登录未绑定企微(空范围);非空=按负责人过滤
const ownerFilter = ownerUserid === undefined ? null : String(ownerUserid).trim();
const emptyResult = () =>
Promise.resolve({
results: [] as Record<string, unknown>[],
success: true,
meta: { changes: 0, last_row_id: 0 },
});
const tasksBase = `SELECT t.*,
(SELECT c.title FROM contents c WHERE c.task_id = t.id ORDER BY c.source_row LIMIT 1) AS keyword,
(SELECT c.body FROM contents c WHERE c.task_id = t.id ORDER BY c.source_row LIMIT 1) AS instructions,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id) AS dist_total,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.status = 'complete') AS dist_complete,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS dist_submitted
FROM tasks t
LEFT JOIN projects pj ON pj.id = t.project_id`;
const distributionsBase = `SELECT
d.*,
c.title AS content_title,
p.name AS partner_name,
cl.claimant_name AS claimant_name,
a.nickname AS account_nickname,
a.platform AS account_platform,
t.name AS task_name,
t.brand AS task_brand,
t.task_type AS task_type,
t.platform AS task_platform,
t.content_format AS content_format,
t.due_at AS due_at,
t.collection_days AS task_collection_days,
t.collection_start_date AS task_collection_start_date
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id
JOIN tasks t ON t.id = d.task_id
LEFT JOIN projects pj ON pj.id = t.project_id
LEFT JOIN accounts a ON a.id = d.account_id`;
const [partnersResult, tasksResult, accountsResult, distributionsResult] =
await Promise.all([
db.prepare("SELECT * FROM partners ORDER BY created_at DESC").all(),
db.prepare("SELECT * FROM tasks ORDER BY created_at DESC").all(),
db
.prepare(
`SELECT
ownerFilter === null
? db
.prepare(
`SELECT p.*, a.nickname AS account_nickname,
a.public_account_id AS account_public_id,
a.current_contact AS account_contact,
a.profile_url AS account_profile_url
FROM partners p
LEFT JOIN accounts a ON a.id = p.account_id
ORDER BY p.created_at DESC`,
)
.all()
: emptyResult(),
ownerFilter
? db
.prepare(
`${tasksBase} WHERE (t.owner_userid = ? OR (t.project_id != '' AND pj.owner_userid = ?)) ORDER BY t.created_at DESC`,
)
.bind(ownerFilter, ownerFilter)
.all()
: ownerFilter === null
? db.prepare(`${tasksBase} ORDER BY t.created_at DESC`).all()
: emptyResult(),
ownerFilter === null
? db
.prepare(
`SELECT
a.*,
COALESCE(
(
@@ -830,32 +958,23 @@ export async function getDashboardData() {
) AS latest_publish_url
FROM accounts a
ORDER BY a.last_seen_at DESC`,
)
.all(),
db
.prepare(
`SELECT
d.*,
c.title AS content_title,
p.name AS partner_name,
cl.claimant_name AS claimant_name,
a.nickname AS account_nickname,
a.platform AS account_platform,
t.name AS task_name,
t.brand AS task_brand,
t.task_type AS task_type,
t.platform AS task_platform,
t.content_format AS content_format,
t.due_at AS due_at
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id
JOIN tasks t ON t.id = d.task_id
LEFT JOIN accounts a ON a.id = d.account_id
ORDER BY d.updated_at DESC, d.claimed_at DESC`,
)
.all(),
)
.all()
: emptyResult(),
ownerFilter
? db
.prepare(
`${distributionsBase} WHERE (t.owner_userid = ? OR (t.project_id != '' AND pj.owner_userid = ?)) ORDER BY d.updated_at DESC, d.claimed_at DESC`,
)
.bind(ownerFilter, ownerFilter)
.all()
: ownerFilter === null
? db
.prepare(
`${distributionsBase} ORDER BY d.updated_at DESC, d.claimed_at DESC`,
)
.all()
: emptyResult(),
]);
return {

109
lib/owner-scope.ts Normal file
View File

@@ -0,0 +1,109 @@
import type { DatabaseClient } from "./database";
import { getRequestPrincipal } from "./user-auth";
export type OwnerScope =
| { kind: "full" }
| { kind: "owner"; wecomUserId: string };
export async function getOwnerScope(request: Request): Promise<OwnerScope> {
const principal = await getRequestPrincipal(request);
if (!principal) return { kind: "owner", wecomUserId: "" };
if (principal.kind === "internal") return { kind: "full" };
if (
principal.user.role === "super_admin" ||
principal.user.role === "admin"
) {
return { kind: "full" };
}
return { kind: "owner", wecomUserId: principal.user.wecom_userid ?? "" };
}
export function ownerScopeUserid(scope: OwnerScope): string | undefined {
return scope.kind === "owner" ? scope.wecomUserId : undefined;
}
export async function isTaskOwner(
db: DatabaseClient,
taskId: string,
wecomUserId: string,
options?: { strict?: boolean },
) {
const id = String(taskId ?? "").trim();
if (!id || !wecomUserId) return false;
const row = await db
.prepare(
options?.strict
? "SELECT id FROM tasks WHERE id = ? AND owner_userid = ? LIMIT 1"
: `SELECT t.id
FROM tasks t
LEFT JOIN projects p ON p.id = t.project_id
WHERE t.id = ? AND (t.owner_userid = ? OR p.owner_userid = ?)
LIMIT 1`,
)
.bind(...(options?.strict ? [id, wecomUserId] : [id, wecomUserId, wecomUserId]))
.first<{ id: string }>();
return Boolean(row);
}
export async function isDistributionOwner(
db: DatabaseClient,
distributionId: string,
wecomUserId: string,
) {
const id = String(distributionId ?? "").trim();
if (!id || !wecomUserId) return false;
const row = await db
.prepare(
`SELECT d.id
FROM distributions d
JOIN tasks t ON t.id = d.task_id
LEFT JOIN projects p ON p.id = t.project_id
WHERE d.id = ? AND (t.owner_userid = ? OR p.owner_userid = ?)
LIMIT 1`,
)
.bind(id, wecomUserId, wecomUserId)
.first<{ id: string }>();
return Boolean(row);
}
export async function isProjectOwner(
db: DatabaseClient,
projectId: string,
wecomUserId: string,
) {
const id = String(projectId ?? "").trim();
if (!id || !wecomUserId) return false;
const row = await db
.prepare("SELECT id FROM projects WHERE id = ? AND owner_userid = ? LIMIT 1")
.bind(id, wecomUserId)
.first<{ id: string }>();
return Boolean(row);
}
export async function isTaskProjectOwner(
db: DatabaseClient,
taskId: string,
wecomUserId: string,
) {
const id = String(taskId ?? "").trim();
if (!id || !wecomUserId) return false;
const row = await db
.prepare(
`SELECT t.id
FROM tasks t
JOIN projects p ON p.id = t.project_id
WHERE t.id = ? AND p.owner_userid = ?
LIMIT 1`,
)
.bind(id, wecomUserId)
.first<{ id: string }>();
return Boolean(row);
}
export function ownerForbidden() {
return Response.json({ error: "该任务未分配给您,无权操作" }, { status: 403 });
}
export function projectForbidden() {
return Response.json({ error: "您不是该项目负责人,无权操作" }, { status: 403 });
}

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

@@ -0,0 +1,194 @@
import type { DatabaseClient } from "./database";
import { getRuntimeEnv } from "./runtime-env";
import {
sendAppChatMessage,
sendWecomAppMessage,
WecomClientError,
type WecomConfig,
} from "./wecom-client";
import {
ensureTaskChatForMembers,
sendTaskChatMessage,
} from "./task-group-service";
import { getProjectDetail, ProjectError } from "./project-service";
import type { TaskPushRow } from "./wecom-group-push-service";
type FetchLike = typeof fetch;
export type AppMessageResult = {
userid: string;
ok: boolean;
error: string;
};
export type MainChatMode = "group" | "per_member" | "skipped";
export type TaskChatPushResult = {
sent: boolean;
chatId: string;
reason?: string;
};
export type ProjectPushOutcome = {
mainChatMode: MainChatMode;
appResults: AppMessageResult[];
taskChat: TaskChatPushResult;
skippedReasons: string[];
linkUrl: string;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export function buildProjectAppMessage(
task: Pick<
TaskPushRow,
| "name"
| "brand"
| "quantity"
| "due_at"
| "task_type"
| "platform"
| "content_format"
| "share_token"
>,
portalUrl: string,
): string {
const portal = text(portalUrl).replace(/\/$/, "");
const claimUrl =
portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
const formatLabel = task.content_format === "video" ? "视频" : "图文";
return [
`【项目任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
`平台:${task.platform}|形式:${formatLabel}`,
"请把任务转发到你负责的项目任务群,并跟进群内成员领取与发布。",
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
].join("\n");
}
const TASK_CHAT_SKIP_REASONS: Record<string, string> = {
no_task: "任务不存在",
no_owner: "任务与项目都未填负责人",
not_enough_members: "任务群成员不足 2 人",
no_credentials: "企业微信未配置",
};
export async function pushProjectTask(
db: DatabaseClient,
params: {
taskId: unknown;
sendMainGroup?: unknown;
sendGroupChats?: unknown;
text?: unknown;
},
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<ProjectPushOutcome> {
const taskId = text(params.taskId);
if (!taskId) throw new ProjectError("缺少 taskId");
const sendMainGroup = params.sendMainGroup !== false;
const sendGroupChats = params.sendGroupChats !== false;
if (!sendMainGroup && !sendGroupChats) {
throw new ProjectError("请至少选择一个发送目标(项目总群或任务群)");
}
const task = await db
.prepare(
`SELECT id, name, brand, quantity, due_at, task_type, platform,
content_format, share_token, project_id
FROM tasks WHERE id = ?`,
)
.bind(taskId)
.first<TaskPushRow & { project_id: string }>();
if (!task) throw new ProjectError("任务不存在", 404);
if (!text(task.project_id)) {
throw new ProjectError("该任务未挂项目,请先在任务中心关联项目");
}
const detail = await getProjectDetail(db, task.project_id);
const portalUrl = text(getRuntimeEnv().KOC_PORTAL_URL);
const fallback = buildProjectAppMessage(task, portalUrl);
const message = text(params.text) || fallback;
const skippedReasons: string[] = [];
const appResults: AppMessageResult[] = [];
let mainChatMode: MainChatMode = "skipped";
const sendPerMember = async () => {
const members = detail.project.mainGroupMemberIds;
if (members.length === 0) {
skippedReasons.push("项目总群未建且未配置成员");
return;
}
for (const userid of members) {
try {
await sendWecomAppMessage([userid], message, config, fetchImpl);
appResults.push({ userid, ok: true, error: "" });
} catch (error) {
appResults.push({
userid,
ok: false,
error: error instanceof Error ? error.message : "发送失败",
});
}
}
mainChatMode = "per_member";
};
if (sendMainGroup) {
if (detail.project.mainChatId) {
try {
await sendAppChatMessage(
config,
{ chatid: detail.project.mainChatId, text: message },
fetchImpl,
);
mainChatMode = "group";
} catch (error) {
skippedReasons.push(
`项目总群群发失败(${
error instanceof Error ? error.message : "未知错误"
}),已降级逐人私信`,
);
await sendPerMember();
}
} else {
skippedReasons.push("项目总群未建,已逐人私信");
await sendPerMember();
}
}
let taskChat: TaskChatPushResult = { sent: false, chatId: "" };
if (sendGroupChats) {
const ensure = await ensureTaskChatForMembers(db, { taskId }, config, fetchImpl);
if (ensure.chatId) {
const sent = await sendTaskChatMessage(db, taskId, message, config, fetchImpl);
taskChat = sent
? { sent: true, chatId: ensure.chatId }
: { sent: false, chatId: ensure.chatId, reason: "消息发送失败" };
if (!sent) skippedReasons.push("任务群消息发送失败");
} else {
const reason =
TASK_CHAT_SKIP_REASONS[ensure.reason ?? ""] ?? "任务群暂不可用";
taskChat = { sent: false, chatId: "", reason };
skippedReasons.push(`任务群未发送:${reason}`);
}
}
const linkUrl =
task.share_token && portalUrl
? `${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`
: "";
return {
mainChatMode,
appResults,
taskChat,
skippedReasons,
linkUrl,
};
}
export { WecomClientError };

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

@@ -0,0 +1,486 @@
import type { DatabaseClient } from "./database";
import {
createAppChat,
getAppChat,
hasAppCredentials,
sanitizeChatid,
updateAppChat,
type WecomConfig,
} from "./wecom-client";
type FetchLike = typeof fetch;
export type ProjectRow = {
id: string;
name: string;
brand: string;
main_group_member_ids: string;
owner_userid: string;
main_chat_id: string;
status: string;
note: string;
created_at: string;
};
export type ProjectSummary = {
id: string;
name: string;
brand: string;
status: string;
note: string;
createdAt: string;
mainGroupMemberIds: string[];
ownerUserid: string;
mainChatId: string;
taskCount: number;
};
export type ProjectTaskRow = {
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
task_type: string;
platform: string;
content_format: string;
share_token: string | null;
owner_userid: string;
task_chat_id: string;
recovery_enabled: number;
recovery_due_at: string;
dist_total: number;
recovery_submitted: number;
};
export type ProjectMainChat = {
chatId: string;
members: string[];
};
export type ProjectDetail = {
project: ProjectSummary;
tasks: ProjectTaskRow[];
mainChat: ProjectMainChat;
};
export type MainChatEnsureReason =
| "no_project"
| "no_credentials"
| "not_enough_members";
export type MainChatEnsureResult = {
chatId: string | null;
members: string[];
reason?: MainChatEnsureReason;
warning?: string;
};
export type GroupOwnerPartnerRow = {
id: string;
name: string;
wecom_userid: string;
partner_type: string;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export class ProjectError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.status = status;
}
}
export function parseMemberIds(value: unknown): string[] {
const raw = Array.isArray(value)
? value.map((item) => text(item))
: text(value).split(/[\s,;,;]+/);
const seen = new Set<string>();
for (const item of raw) {
if (item) seen.add(item);
}
return [...seen];
}
function parseJsonArray(value: string): string[] {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
} catch {
return [];
}
}
export function effectiveProjectMembers(
memberIdsValue: unknown,
ownerUserid: unknown,
): string[] {
const raw =
typeof memberIdsValue === "string" && memberIdsValue.trim().startsWith("[")
? parseJsonArray(memberIdsValue)
: memberIdsValue;
const members = parseMemberIds(raw);
const owner = text(ownerUserid);
if (owner && !members.includes(owner)) members.push(owner);
return members;
}
export function projectMainChatId(projectId: string) {
return `pgc${sanitizeChatid(text(projectId))}`;
}
export async function ensureProjectMainChat(
db: DatabaseClient,
projectId: string,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<MainChatEnsureResult> {
const id = text(projectId);
if (!id) return { chatId: null, members: [], reason: "no_project" };
const project = await db
.prepare(
"SELECT id, name, main_group_member_ids, owner_userid, main_chat_id FROM projects WHERE id = ?",
)
.bind(id)
.first<ProjectRow>();
if (!project) return { chatId: null, members: [], reason: "no_project" };
const members = effectiveProjectMembers(
project.main_group_member_ids,
project.owner_userid,
);
const existingChatId = text(project.main_chat_id);
if (!hasAppCredentials(config)) {
return {
chatId: existingChatId || null,
members,
reason: existingChatId ? undefined : "no_credentials",
};
}
if (existingChatId) {
try {
const info = await getAppChat(config, existingChatId, fetchImpl);
if (info) {
const inGroup = new Set(info.userlist);
const addUserList = members.filter((userid) => !inGroup.has(userid));
const delUserList = info.userlist.filter(
(userid) => !members.includes(userid),
);
if (addUserList.length > 0 || delUserList.length > 0) {
await updateAppChat(
config,
{ chatid: existingChatId, addUserList, delUserList },
fetchImpl,
);
}
} else {
// appchat/get 要求应用可见范围为根部门,查不到成员时降级为只增同步(重复添加按成功处理)
await updateAppChat(
config,
{ chatid: existingChatId, addUserList: members },
fetchImpl,
);
}
return { chatId: existingChatId, members };
} catch (error) {
return {
chatId: existingChatId,
members,
warning: `项目总群成员同步失败:${error instanceof Error ? error.message : String(error)}`,
};
}
}
if (members.length < 3) {
return { chatId: null, members, reason: "not_enough_members" };
}
const candidate = projectMainChatId(id);
try {
const probed = await getAppChat(config, candidate, fetchImpl);
let chatId = candidate;
if (!probed) {
const created = await createAppChat(
config,
{ name: `【总群】${project.name}`, userlist: members, chatid: candidate },
fetchImpl,
);
chatId = created.chatid || candidate;
}
await db
.prepare("UPDATE projects SET main_chat_id = ? WHERE id = ?")
.bind(chatId, id)
.run();
return { chatId, members };
} catch (error) {
return {
chatId: null,
members,
warning: `项目总群创建失败:${error instanceof Error ? error.message : String(error)}`,
};
}
}
export async function listProjects(
db: DatabaseClient,
ownerUserid?: string,
): Promise<ProjectSummary[]> {
// undefined=全量;""=已登录未绑定企微(空范围,不能绑定 '' 否则漏出无主项目)
if (ownerUserid !== undefined && String(ownerUserid).trim() === "") {
return [];
}
const ownerFilter = String(ownerUserid ?? "").trim();
const result = await (ownerFilter
? db
.prepare(
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p
WHERE p.owner_userid = ?
ORDER BY p.created_at DESC`,
)
.bind(ownerFilter)
: db
.prepare(
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p
ORDER BY p.created_at DESC`,
)
).all<ProjectRow & { task_count: number }>();
return result.results.map((row) => ({
id: row.id,
name: row.name,
brand: row.brand,
status: row.status,
note: row.note,
createdAt: row.created_at,
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
ownerUserid: row.owner_userid ?? "",
mainChatId: row.main_chat_id ?? "",
taskCount: Number(row.task_count),
}));
}
export async function getProjectDetail(
db: DatabaseClient,
projectId: string,
): Promise<ProjectDetail> {
const id = text(projectId);
const project = await db
.prepare(
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, status, note, created_at
FROM projects WHERE id = ?`,
)
.bind(id)
.first<ProjectRow>();
if (!project) throw new ProjectError("项目不存在", 404);
const tasks = await db
.prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, platform, content_format, share_token, owner_userid, task_chat_id,
recovery_enabled, recovery_due_at,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id) AS dist_total,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS recovery_submitted
FROM tasks t WHERE t.project_id = ?
ORDER BY t.created_at DESC`,
)
.bind(id)
.all<ProjectTaskRow>();
return {
project: {
id: project.id,
name: project.name,
brand: project.brand,
status: project.status,
note: project.note,
createdAt: project.created_at,
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
ownerUserid: project.owner_userid ?? "",
mainChatId: project.main_chat_id ?? "",
taskCount: tasks.results.length,
},
tasks: tasks.results,
mainChat: {
chatId: project.main_chat_id ?? "",
members: effectiveProjectMembers(
project.main_group_member_ids,
project.owner_userid,
),
},
};
}
export type SaveProjectInput = {
id?: unknown;
name?: unknown;
brand?: unknown;
mainGroupMemberIds?: unknown;
ownerUserid?: unknown;
note?: unknown;
};
export async function saveProject(
db: DatabaseClient,
input: SaveProjectInput,
config?: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<{ project: ProjectSummary; mainChat: MainChatEnsureResult }> {
const id = text(input.id);
const name = text(input.name);
const brand = text(input.brand);
const note = text(input.note ?? "");
const ownerUserid = text(input.ownerUserid).slice(0, 64);
const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
if (!name) throw new ProjectError("请填写项目名称");
const now = new Date().toISOString();
let projectId = id;
if (id) {
const result = await db
.prepare(
`UPDATE projects
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?
WHERE id = ?`,
)
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, id)
.run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
} else {
projectId = `proj-${crypto.randomUUID().slice(0, 12)}`;
await db
.prepare(
`INSERT INTO projects
(id, name, brand, main_group_member_ids, owner_userid, status, note, created_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
)
.bind(
projectId,
name,
brand,
JSON.stringify(memberIds),
ownerUserid,
note,
now,
)
.run();
}
const mainChat = config
? await ensureProjectMainChat(db, projectId, config, fetchImpl)
: {
chatId: null,
members: memberIds,
reason: "no_credentials" as MainChatEnsureReason,
};
const detail = await getProjectDetail(db, projectId);
return { project: detail.project, mainChat };
}
export async function deleteProject(
db: DatabaseClient,
projectId: string,
): Promise<void> {
const id = text(projectId);
if (!id) throw new ProjectError("缺少项目 ID");
const result = await db
.prepare("DELETE FROM projects WHERE id = ?")
.bind(id)
.run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
await db
.prepare("UPDATE tasks SET project_id = '' WHERE project_id = ?")
.bind(id)
.run();
}
export async function listGroupOwnerPartners(
db: DatabaseClient,
): Promise<GroupOwnerPartnerRow[]> {
const result = await db
.prepare(
`SELECT id, name, wecom_userid, partner_type
FROM partners
WHERE partner_type = 'group_owner'
ORDER BY created_at DESC`,
)
.all<GroupOwnerPartnerRow>();
return result.results;
}
export type SavePartnerInput = {
id?: unknown;
name?: unknown;
wecomUserid?: unknown;
};
export async function saveGroupOwnerPartner(
db: DatabaseClient,
input: SavePartnerInput,
): Promise<GroupOwnerPartnerRow> {
const id = text(input.id);
const name = text(input.name);
const wecomUserid = text(input.wecomUserid);
if (!name) throw new ProjectError("请填写合作方名称");
if (!wecomUserid) throw new ProjectError("请填写企业微信 userid");
let partnerId = id;
if (id) {
const result = await db
.prepare(
`UPDATE partners
SET name = ?, wecom_userid = ?, partner_type = 'group_owner'
WHERE id = ?`,
)
.bind(name, wecomUserid, id)
.run();
if (result.meta.changes === 0) throw new ProjectError("合作方不存在", 404);
} else {
partnerId = `partner-go-${crypto.randomUUID().slice(0, 12)}`;
await db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total,
partner_type, wecom_userid, created_at)
VALUES (?, ?, '', '运营组', 0, 0, 'group_owner', ?, ?)`,
)
.bind(partnerId, name, wecomUserid, new Date().toISOString())
.run();
}
const row = await db
.prepare(
"SELECT id, name, wecom_userid, partner_type FROM partners WHERE id = ?",
)
.bind(partnerId)
.first<GroupOwnerPartnerRow>();
if (!row) throw new ProjectError("合作方保存失败", 500);
return row;
}
export async function findOrCreateGroupOwnerPartner(
db: DatabaseClient,
wecomUserid: string,
): Promise<GroupOwnerPartnerRow> {
const userid = text(wecomUserid).slice(0, 128);
if (!userid) throw new ProjectError("缺少企业微信 userid");
const existing = await db
.prepare(
`SELECT id, name, wecom_userid, partner_type
FROM partners
WHERE partner_type = 'group_owner' AND wecom_userid = ?
ORDER BY created_at
LIMIT 1`,
)
.bind(userid)
.first<GroupOwnerPartnerRow>();
if (existing) return existing;
return saveGroupOwnerPartner(db, { name: userid, wecomUserid: userid });
}

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 =

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

@@ -0,0 +1,578 @@
import { type DatabaseClient } from "./database";
import { ensureSchema, getRawDb, hashText } 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));
}
}
const POOL_PARTNER_PLACEHOLDER_NICKNAME = "待识别账号";
export const POOL_PARTNER_SYNC_BATCH_SIZE = 500;
const POOL_PARTNER_SYNC_MAX_ITERATIONS = 30;
type PoolAccountRow = {
id: string;
platform: string;
platform_uid: string;
public_account_id: string;
nickname: string;
};
type ShellPartnerNameRow = PoolAccountRow & {
partner_id: string;
partner_name: string;
};
export function poolPartnerDisplayName(account: {
nickname?: string | null;
public_account_id?: string | null;
platform_uid?: string | null;
id: string;
}) {
const nickname = (account.nickname ?? "").trim();
if (nickname && nickname !== POOL_PARTNER_PLACEHOLDER_NICKNAME) return nickname;
return (
(account.public_account_id ?? "").trim() ||
(account.platform_uid ?? "").trim() ||
account.id
);
}
/**
* 资源池中的 KOC 默认加入合作方:为尚未绑定合作方的资源账号自动创建
* external_koc 合作方。运营显式解绑或被换绑顶替过的账号
* (partners.released_account_id)不回填,保留人工决定。
* 合作方 id 由账号身份哈希派生,配合 INSERT OR IGNORE 保证并发下幂等。
*/
export async function ensurePartnersForPoolAccounts(database?: DatabaseClient) {
const db = database ?? getRawDb();
for (
let iteration = 0;
iteration < POOL_PARTNER_SYNC_MAX_ITERATIONS;
iteration += 1
) {
const unbound = await db
.prepare(
`SELECT a.id, a.platform, a.platform_uid, a.public_account_id, a.nickname
FROM accounts a
WHERE NOT EXISTS (SELECT 1 FROM partners p WHERE p.account_id = a.id)
AND NOT EXISTS (
SELECT 1 FROM partners r WHERE r.released_account_id = a.id
)
ORDER BY a.first_seen_at, a.id
LIMIT ${POOL_PARTNER_SYNC_BATCH_SIZE}`,
)
.all<PoolAccountRow>();
if (unbound.results.length === 0) break;
const statements = unbound.results.map((account) =>
db
.prepare(
`INSERT OR IGNORE INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total,
partner_type, wecom_userid, account_id, created_at)
VALUES (?, ?, '', '运营组', 0, 0, 'external_koc', '', ?, ?)`,
)
.bind(
`partner-koc-${hashText(`${account.platform}:${account.platform_uid}`)}`,
poolPartnerDisplayName(account),
account.id,
new Date().toISOString(),
),
);
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));
}
if (unbound.results.length < POOL_PARTNER_SYNC_BATCH_SIZE) break;
}
await refreshPoolPartnerNames(db);
}
/**
* 补全默认合作方的展示名:仅处理无企微绑定、无领取/分发记录的空壳合作方,
* 昵称补全后把占位名(待识别账号)或账号 ID 兜底名更新为当前昵称,
* 不碰运营可能手工维护过含义的名称。
*/
async function refreshPoolPartnerNames(db: DatabaseClient) {
const rows = await db
.prepare(
`SELECT p.id AS partner_id, p.name AS partner_name,
a.id, a.nickname, a.public_account_id, a.platform_uid
FROM partners p
JOIN accounts a ON a.id = p.account_id
WHERE p.partner_type = 'external_koc'
AND (p.wecom_external_user_id IS NULL OR p.wecom_external_user_id = '')
AND p.wecom_userid = ''
AND NOT EXISTS (SELECT 1 FROM claims c WHERE c.partner_id = p.id)
AND NOT EXISTS (SELECT 1 FROM distributions d WHERE d.partner_id = p.id)
AND (
p.name IN ('', '${POOL_PARTNER_PLACEHOLDER_NICKNAME}')
OR (p.name IN (a.public_account_id, a.platform_uid)
AND a.nickname NOT IN ('', '${POOL_PARTNER_PLACEHOLDER_NICKNAME}'))
)`,
)
.all<ShellPartnerNameRow>();
const updates = rows.results
.filter(
(row) => poolPartnerDisplayName(row) !== row.partner_name.trim(),
)
.map((row) =>
db
.prepare(`UPDATE partners SET name = ? WHERE id = ?`)
.bind(poolPartnerDisplayName(row), row.partner_id),
);
for (
let index = 0;
index < updates.length;
index += RESOURCE_IMPORT_DB_BATCH_SIZE
) {
await db.batch(updates.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE));
}
}

View File

@@ -13,19 +13,23 @@ export type RuntimeEnv = {
ADMIN_INTERNAL_TOKEN?: string;
KOC_MCP_API_KEY?: string;
KOC_LOOP_MCP_API_KEY?: string;
MCP_API_KEY?: string;
FEISHU_APP_ID?: string;
FEISHU_APP_SECRET?: string;
AI_TOOL_CENTER_MCP_URL?: string;
AI_TOOL_CENTER_MCP_KEY?: string;
COLLECTION_MCP_URL?: string;
COLLECTION_MCP_KEY?: string;
AI_TAGGING_TOOL?: string;
WECOM_CORP_ID?: string;
WECOM_AGENT_ID?: string;
WECOM_SECRET?: string;
WECOM_ROBOT_WEBHOOK?: string;
WECOM_NOTIFY_DUE_DAYS?: string;
WECOM_NOTIFY_ENABLED?: string;
WECOM_CALLBACK_TOKEN?: string;
WECOM_CALLBACK_ENCODING_AES_KEY?: string;
WECOM_CALLBACK_ENABLED?: string;
WECOM_FEEDBACK_ENABLED?: string;
WECOM_ESCALATION_ENABLED?: string;
WECOM_AUTO_RELEASE_DAYS?: string;
SEED_DEMO_DATA?: string;
ENABLE_SCHEDULER?: string;
};

View File

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

View File

@@ -0,0 +1,111 @@
import type { DatabaseClient } from "./database";
import {
addTaskJoinWay,
deleteJoinWay,
getJoinWayQrCode,
updateTaskJoinWay,
type WecomConfig,
} from "./wecom-client";
type FetchLike = typeof fetch;
function text(value: unknown) {
return String(value ?? "").trim();
}
export type BindTaskCustomerChatResult = {
customerChatId: string;
customerChatName: string;
joinConfigId: string;
joinQrCode: string;
qrError?: string;
};
export async function bindTaskCustomerChat(
db: DatabaseClient,
params: { taskId: string; chatId: string; chatName: string },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<BindTaskCustomerChatResult> {
const taskId = text(params.taskId);
const chatId = text(params.chatId);
const chatName = text(params.chatName).slice(0, 255);
if (!taskId) throw new Error("缺少 taskId");
if (!chatId) throw new Error("请选择要绑定的客户群");
const task = await db
.prepare("SELECT id, name, join_config_id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string; name: string; join_config_id: string | null }>();
if (!task) throw new Error("任务不存在");
const joinInput = {
chatIdList: [chatId],
roomBaseName: task.name,
state: `task-${taskId}`,
};
let configId = text(task.join_config_id);
if (configId) {
try {
await updateTaskJoinWay(config, { ...joinInput, configId }, fetchImpl);
} catch {
// 活码配置可能已被企微侧清理,降级为重新创建
configId = (await addTaskJoinWay(config, joinInput, fetchImpl)).configId;
}
} else {
configId = (await addTaskJoinWay(config, joinInput, fetchImpl)).configId;
}
let joinQrCode = "";
let qrError: string | undefined;
try {
joinQrCode = (await getJoinWayQrCode(config, configId, fetchImpl)).qrCode;
} catch (error) {
qrError = `活码二维码获取失败:${error instanceof Error ? error.message : String(error)}`;
}
await db
.prepare(
`UPDATE tasks
SET customer_chat_id = ?, customer_chat_name = ?, join_config_id = ?, join_qr_code = ?
WHERE id = ?`,
)
.bind(chatId, chatName, configId, joinQrCode, taskId)
.run();
return {
customerChatId: chatId,
customerChatName: chatName,
joinConfigId: configId,
joinQrCode,
qrError,
};
}
export async function unbindTaskCustomerChat(
db: DatabaseClient,
taskId: string,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<void> {
const id = text(taskId);
if (!id) throw new Error("缺少 taskId");
const task = await db
.prepare("SELECT join_config_id FROM tasks WHERE id = ?")
.bind(id)
.first<{ join_config_id: string | null }>();
if (!task) throw new Error("任务不存在");
const configId = text(task.join_config_id);
if (configId) {
await deleteJoinWay(config, configId, fetchImpl);
}
await db
.prepare(
`UPDATE tasks
SET customer_chat_id = '', customer_chat_name = '', join_config_id = '', join_qr_code = ''
WHERE id = ?`,
)
.bind(id)
.run();
}

267
lib/task-group-service.ts Normal file
View File

@@ -0,0 +1,267 @@
import type { DatabaseClient } from "./database";
import {
effectiveProjectMembers,
ensureProjectMainChat,
findOrCreateGroupOwnerPartner,
} from "./project-service";
import {
createAppChat,
getAppChat,
hasAppCredentials,
sanitizeChatid,
sendAppChatMessage,
updateAppChat,
type WecomConfig,
} from "./wecom-client";
type FetchLike = typeof fetch;
export type TaskChatEnsureResult = {
chatId: string | null;
created: boolean;
reason?: "no_task" | "no_owner" | "not_enough_members" | "no_credentials";
};
type TaskChatRow = {
id: string;
name: string;
task_chat_id: string | null;
task_owner_userid: string | null;
project_owner_userid: string | null;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export function taskChatId(taskId: string) {
return `tgc${sanitizeChatid(text(taskId))}`;
}
function chatNameForTask(taskName: string) {
return `【任务】${text(taskName)}`.slice(0, 20);
}
async function syncMembers(
config: WecomConfig,
chatid: string,
members: string[],
fetchImpl: FetchLike,
) {
const info = await getAppChat(config, chatid, fetchImpl);
if (!info) {
// appchat/get 要求应用可见范围为根部门,查不到时降级为只增同步(重复添加成员企微按成功处理)
await updateAppChat(config, { chatid, addUserList: members }, fetchImpl);
return;
}
const existing = new Set(info.userlist);
const missing = members.filter((userid) => !existing.has(userid));
if (missing.length > 0) {
await updateAppChat(config, { chatid, addUserList: missing }, fetchImpl);
}
}
export async function ensureTaskChatForMembers(
db: DatabaseClient,
params: { taskId: string; addUserids?: string[] },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<TaskChatEnsureResult> {
const taskId = text(params.taskId);
if (!taskId) return { chatId: null, created: false, reason: "no_task" };
if (!hasAppCredentials(config)) {
return { chatId: null, created: false, reason: "no_credentials" };
}
const task = await db
.prepare(
`SELECT t.id, t.name, t.task_chat_id, t.owner_userid AS task_owner_userid,
p.owner_userid AS project_owner_userid
FROM tasks t
LEFT JOIN projects p ON p.id = t.project_id
WHERE t.id = ?`,
)
.bind(taskId)
.first<TaskChatRow>();
if (!task) return { chatId: null, created: false, reason: "no_task" };
const ownerUserid = text(task.project_owner_userid);
const taskOwnerUserid = text(task.task_owner_userid);
if (!ownerUserid && !taskOwnerUserid) {
return { chatId: null, created: false, reason: "no_owner" };
}
const members = [
...new Set(
[
ownerUserid,
taskOwnerUserid,
...(params.addUserids ?? []).map((id) => text(id)),
].filter(Boolean),
),
];
let chatId = text(task.task_chat_id);
let createdNow = false;
if (!chatId) {
chatId = taskChatId(taskId);
const existing = await getAppChat(config, chatId, fetchImpl);
if (!existing) {
if (members.length < 2) {
return { chatId: null, created: false, reason: "not_enough_members" };
}
const created = await createAppChat(
config,
{ name: chatNameForTask(task.name), userlist: members, chatid: chatId },
fetchImpl,
);
chatId = created.chatid;
createdNow = true;
}
await db
.prepare("UPDATE tasks SET task_chat_id = ? WHERE id = ?")
.bind(chatId, taskId)
.run();
}
await syncMembers(config, chatId, members, fetchImpl);
return { chatId, created: createdNow };
}
export type TaskOwnerAssignment = {
ownerUserid: string;
partnerId: string;
chatId: string | null;
chatCreated: boolean;
warning?: string;
};
export async function assignTaskOwner(
db: DatabaseClient,
params: { taskId: string; ownerUserid: string },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<TaskOwnerAssignment> {
const taskId = text(params.taskId);
const ownerUserid = text(params.ownerUserid).slice(0, 64);
if (!taskId) throw new Error("缺少 taskId");
if (!ownerUserid) throw new Error("请填写任务负责人");
const task = await db
.prepare(
"SELECT id, name, brand, quantity, due_at, project_id FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<{
id: string;
name: string;
brand: string;
quantity: number;
due_at: string;
project_id: string;
}>();
if (!task) throw new Error("任务不存在");
await db
.prepare("UPDATE tasks SET owner_userid = ? WHERE id = ?")
.bind(ownerUserid, taskId)
.run();
const partner = await findOrCreateGroupOwnerPartner(db, ownerUserid);
const warnings: string[] = [];
const projectId = text(task.project_id);
if (projectId) {
const project = await db
.prepare(
"SELECT main_group_member_ids, owner_userid FROM projects WHERE id = ?",
)
.bind(projectId)
.first<{ main_group_member_ids: string; owner_userid: string }>();
if (project) {
// 负责人不在成员列表则并入,保持「负责人都在总群」
const stored = effectiveProjectMembers(project.main_group_member_ids, "");
if (!stored.includes(ownerUserid)) {
await db
.prepare("UPDATE projects SET main_group_member_ids = ? WHERE id = ?")
.bind(JSON.stringify([...stored, ownerUserid]), projectId)
.run();
}
const mainChat = await ensureProjectMainChat(db, projectId, config, fetchImpl);
if (mainChat.warning) warnings.push(mainChat.warning);
}
}
let ensure: TaskChatEnsureResult = { chatId: null, created: false };
try {
ensure = await ensureTaskChatForMembers(db, { taskId }, config, fetchImpl);
if (ensure.chatId && ensure.created) {
await sendTaskChatMessage(
db,
taskId,
taskChatIntroText({
name: task.name,
brand: task.brand,
quantity: task.quantity,
dueAt: task.due_at,
}),
config,
fetchImpl,
);
}
} catch (error) {
warnings.push(`任务群创建失败:${error instanceof Error ? error.message : String(error)}`);
}
return {
ownerUserid,
partnerId: partner.id,
chatId: ensure.chatId,
chatCreated: ensure.created,
warning: warnings.length > 0 ? warnings.join(";") : undefined,
};
}
export async function sendTaskChatMessage(
db: DatabaseClient,
taskId: string,
message: string,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<boolean> {
try {
const content = text(message);
if (!content) return false;
const row = await db
.prepare("SELECT task_chat_id FROM tasks WHERE id = ?")
.bind(text(taskId))
.first<{ task_chat_id: string | null }>();
const chatId = text(row?.task_chat_id);
if (!chatId) return false;
await sendAppChatMessage(config, { chatid: chatId, text: content }, fetchImpl);
return true;
} catch (error) {
console.error(
`[KOC LOOP] 任务群消息发送失败(task=${taskId})`,
error instanceof Error ? error.message : error,
);
return false;
}
}
export function taskClaimBroadcastText(
taskName: string,
claimantName: string,
quantity: number,
) {
return `【领取】${text(claimantName)} 认领 ${quantity} 份 · ${text(taskName)}`;
}
export function taskChatIntroText(task: {
name: string;
brand: string;
quantity: number;
dueAt: string;
}) {
return [
`【新任务】${text(task.name)}`,
`品牌:${text(task.brand)}|数量:${task.quantity} 份|截止:${text(task.dueAt)}`,
].join("\n");
}

View File

@@ -1,4 +1,5 @@
import { ensureSchema, getRawDb, uid } from "./mvp-db";
import type { DatabaseClient } from "./database";
import {
readFeishuSource,
type FeishuBindings,
@@ -12,17 +13,15 @@ export type CreateDistributionTaskInput = {
dueAt: string;
platform?: "小红书" | "抖音";
contentFormat?: "image_text" | "video";
projectId?: string;
ownerUserid?: string;
recoveryEnabled?: boolean;
recoveryDueAt?: string;
recoveryInstructions?: string;
recoveryExampleImageKey?: string;
};
export type CreateScreenshotTaskInput = {
name: string;
brand: string;
dueAt: string;
keyword: string;
instructions: string;
quantity: number;
exampleImageKey?: string;
};
;
export type DistributionTaskCreation = {
created: boolean;
@@ -39,15 +38,7 @@ export type DistributionTaskCreation = {
contentFormat: "image_text" | "video";
};
export type ScreenshotTaskCreation = {
created: true;
taskId: string;
shareToken: string;
name: string;
brand: string;
dueAt: string;
quantity: number;
};
type TaskRow = {
id: string;
@@ -129,9 +120,25 @@ async function findExistingTask(
.first<TaskRow>();
}
type RecoveryConfig = {
enabled: boolean;
dueAt: string;
instructions: string;
exampleImages: string;
};
function recoveryExampleImagesJson(exampleImageKey: string) {
return exampleImageKey.startsWith("task-assets/")
? JSON.stringify([
{ index: 1, key: exampleImageKey, width: null, height: null },
])
: "[]";
}
async function insertTaskFromSource(
source: FeishuSource,
input: CreateDistributionTaskInput,
recovery: RecoveryConfig,
) {
const db = getRawDb();
const taskId = uid("task");
@@ -140,10 +147,12 @@ async function insertTaskFromSource(
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
platform, content_format,
platform, content_format, project_id, owner_userid,
source_url, source_sheet_id, source_sheet_name, source_synced_at,
share_token)
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?)`,
share_token, join_qr_code,
recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images)
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?, ?, '',
?, ?, ?, ?)`,
)
.bind(
taskId,
@@ -153,11 +162,17 @@ async function insertTaskFromSource(
input.dueAt,
input.platform ?? "小红书",
input.contentFormat ?? "image_text",
input.projectId,
input.ownerUserid,
source.url,
source.sheetId,
source.sheetName,
source.syncedAt,
shareToken,
recovery.enabled ? 1 : 0,
recovery.dueAt,
recovery.instructions,
recovery.exampleImages,
)
.run();
@@ -220,6 +235,12 @@ export async function createDistributionTask(
dueAt: normalizedDueDate(rawInput.dueAt),
platform: rawInput.platform === "抖音" ? "抖音" : "小红书",
contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text",
projectId: normalizedValue(rawInput.projectId ?? ""),
ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64),
recoveryEnabled: rawInput.recoveryEnabled !== false,
recoveryDueAt: normalizedValue(rawInput.recoveryDueAt ?? ""),
recoveryInstructions: normalizedValue(rawInput.recoveryInstructions ?? ""),
recoveryExampleImageKey: normalizedValue(rawInput.recoveryExampleImageKey ?? ""),
};
if (!input.feishuUrl || !input.name || !input.brand) {
throw new Error("请补全飞书链接、任务名称和品牌/项目");
@@ -252,7 +273,17 @@ export async function createDistributionTask(
) {
throw new Error("视频任务中存在未识别到视频的内容行,请检查飞书“视频”列");
}
const inserted = await insertTaskFromSource(source, input);
const recoveryDueAt = normalizedValue(rawInput.recoveryDueAt ?? "");
const inserted = await insertTaskFromSource(source, input, {
enabled: rawInput.recoveryEnabled !== false,
dueAt: recoveryDueAt ? normalizedDueDate(recoveryDueAt) : input.dueAt,
instructions:
normalizedValue(rawInput.recoveryInstructions ?? "") ||
"请提交发布后的笔记截图",
exampleImages: recoveryExampleImagesJson(
normalizedValue(rawInput.recoveryExampleImageKey ?? ""),
),
});
return {
created: true,
taskId: inserted.taskId,
@@ -269,92 +300,164 @@ export async function createDistributionTask(
};
}
export async function createScreenshotTask(
rawInput: CreateScreenshotTaskInput,
): Promise<ScreenshotTaskCreation> {
await ensureSchema();
export type UpdateTaskInput = {
taskId: string;
name: string;
brand: string;
dueAt: string;
projectId?: string;
platform?: "小红书" | "抖音";
contentFormat?: "image_text" | "video";
recoveryEnabled?: boolean;
recoveryDueAt?: string;
recoveryInstructions?: string;
recoveryExampleImageKey?: string;
};
export type TaskUpdateResult = {
taskId: string;
quantity: number;
claimedQuantity: number;
};
type UpdatableTaskRow = {
id: string;
quantity: number;
claimed_quantity: number;
platform: "小红书" | "抖音";
content_format: "image_text" | "video";
recovery_enabled: number;
recovery_due_at: string;
recovery_instructions: string | null;
recovery_example_images: string;
};
export async function updateTask(
rawInput: UpdateTaskInput,
db: DatabaseClient = getRawDb(),
): Promise<TaskUpdateResult> {
const taskId = normalizedValue(rawInput.taskId);
if (!taskId) throw new Error("缺少 taskId");
const input = {
name: normalizedValue(rawInput.name),
brand: normalizedValue(rawInput.brand),
dueAt: normalizedDueDate(rawInput.dueAt),
keyword: normalizedValue(rawInput.keyword),
instructions: normalizedValue(rawInput.instructions),
quantity: Math.floor(Number(rawInput.quantity)),
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
projectId: normalizedValue(rawInput.projectId ?? ""),
platform: rawInput.platform === "抖音" || rawInput.platform === "小红书"
? rawInput.platform
: undefined,
contentFormat: rawInput.contentFormat === "video" || rawInput.contentFormat === "image_text"
? rawInput.contentFormat
: undefined,
recoveryExampleImageKey: normalizedValue(rawInput.recoveryExampleImageKey ?? ""),
};
if (!input.name || !input.brand || !input.keyword || !input.instructions) {
throw new Error("请补全任务名称、品牌/项目、搜索关键词和任务说明");
}
if (!Number.isInteger(input.quantity) || input.quantity < 1 || input.quantity > 500) {
throw new Error("任务数量需为 1—500 份");
}
if (input.exampleImageKey && !input.exampleImageKey.startsWith("task-assets/")) {
throw new Error("示例截图无效,请重新上传");
if (!input.name || !input.brand) {
throw new Error("请补全任务名称和品牌");
}
const db = getRawDb();
const taskId = uid("task");
const shareToken = crypto.randomUUID().replaceAll("-", "");
const imageAssets = input.exampleImageKey
? JSON.stringify([
{ index: 1, key: input.exampleImageKey, width: null, height: null },
])
: "[]";
const row = await db
.prepare(
`SELECT id, quantity, claimed_quantity, platform, content_format,
recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images
FROM tasks WHERE id = ?`,
)
.bind(taskId)
.first<UpdatableTaskRow>();
if (!row) throw new Error("任务不存在");
const quantity = Number(row.quantity);
const claimed = Number(row.claimed_quantity);
const recoveryEnabled =
rawInput.recoveryEnabled === undefined
? row.recovery_enabled
: rawInput.recoveryEnabled
? 1
: 0;
const recoveryDueAtInput = normalizedValue(rawInput.recoveryDueAt ?? "");
const recoveryDueAt = recoveryDueAtInput
? normalizedDueDate(recoveryDueAtInput)
: row.recovery_due_at || input.dueAt;
const recoveryInstructions =
normalizedValue(rawInput.recoveryInstructions ?? "") ||
row.recovery_instructions ||
"";
const recoveryExampleImages = input.recoveryExampleImageKey
? recoveryExampleImagesJson(input.recoveryExampleImageKey)
: row.recovery_example_images;
await db
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, source_url, source_sheet_id, source_sheet_name,
share_token, collection_days)
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', '', '', '', ?, '[]')`,
`UPDATE tasks
SET name = ?, brand = ?, due_at = ?, project_id = ?,
platform = ?, content_format = ?, quantity = ?,
recovery_enabled = ?, recovery_due_at = ?, recovery_instructions = ?,
recovery_example_images = ?
WHERE id = ?`,
)
.bind(
taskId,
input.name,
input.brand,
input.quantity,
input.dueAt,
shareToken,
input.projectId,
input.platform ?? row.platform,
input.contentFormat ?? row.content_format,
quantity,
recoveryEnabled,
recoveryDueAt,
recoveryInstructions,
recoveryExampleImages,
taskId,
)
.run();
await refreshTaskStatus(taskId, db).catch(() => undefined);
try {
const statements = Array.from({ length: input.quantity }, (_, index) =>
db
.prepare(
`INSERT INTO contents
(id, task_id, title, body, image_assets, video_assets, status, source, source_row)
VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?)`,
)
.bind(
uid("content"),
taskId,
input.keyword,
input.instructions,
imageAssets,
index + 1,
),
);
for (let index = 0; index < statements.length; index += 100) {
await db.batch(statements.slice(index, index + 100));
}
} catch (error) {
await db.batch([
db.prepare("DELETE FROM contents WHERE task_id = ?").bind(taskId),
db.prepare("DELETE FROM tasks WHERE id = ?").bind(taskId),
]);
throw error;
}
return { taskId, quantity, claimedQuantity: claimed };
}
return {
created: true,
taskId,
shareToken,
name: input.name,
brand: input.brand,
dueAt: input.dueAt,
quantity: input.quantity,
};
export type EnableRecoveryInput = {
taskId: string;
recoveryDueAt?: string;
recoveryInstructions?: string;
recoveryExampleImageKey?: string;
};
export async function enableTaskRecovery(
rawInput: EnableRecoveryInput,
db: DatabaseClient = getRawDb(),
): Promise<{ taskId: string; recoveryDueAt: string }> {
const taskId = normalizedValue(rawInput.taskId);
if (!taskId) throw new Error("缺少 taskId");
const row = await db
.prepare(
"SELECT id, due_at, recovery_example_images FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<{
id: string;
due_at: string;
recovery_example_images: string;
}>();
if (!row) throw new Error("任务不存在");
const dueAtInput = normalizedValue(rawInput.recoveryDueAt ?? "");
const dueAt = dueAtInput ? normalizedDueDate(dueAtInput) : row.due_at;
const exampleImageKey = normalizedValue(rawInput.recoveryExampleImageKey ?? "");
await db
.prepare(
`UPDATE tasks SET recovery_enabled = 1, recovery_due_at = ?,
recovery_instructions = ?, recovery_example_images = ?
WHERE id = ?`,
)
.bind(
dueAt,
normalizedValue(rawInput.recoveryInstructions ?? "") ||
"请提交发布后的笔记截图",
exampleImageKey
? recoveryExampleImagesJson(exampleImageKey)
: row.recovery_example_images,
taskId,
)
.run();
return { taskId, recoveryDueAt: dueAt };
}
export function buildClaimUrl(portalOrigin: string, shareToken: string) {
@@ -364,3 +467,54 @@ export function buildClaimUrl(portalOrigin: string, shareToken: string) {
url.searchParams.set("task", shareToken);
return url.toString();
}
type TaskStatusRow = {
id: string;
status: string;
};
type DistributionProgressRow = {
total: number;
complete_count: number;
};
// 纯重算任务状态:条件满足置 completed,不满足回 active(释放重开等场景);
// importing 是导入中间态,不参与推导。完成判定取发布阶段口径(complete_count)
export async function refreshTaskStatus(
taskId: string,
db: DatabaseClient = getRawDb(),
): Promise<string> {
const row = await db
.prepare("SELECT id, status FROM tasks WHERE id = ?")
.bind(taskId)
.first<TaskStatusRow>();
if (!row || row.status === "importing") return row?.status ?? "";
const progress = await db
.prepare(
`SELECT COUNT(*) AS total,
COALESCE(SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END), 0) AS complete_count
FROM distributions WHERE task_id = ?`,
)
.bind(taskId)
.first<DistributionProgressRow>();
const available = await db
.prepare(
"SELECT COUNT(*) AS available_count FROM contents WHERE task_id = ? AND status = 'available'",
)
.bind(taskId)
.first<{ available_count: number }>();
const total = Number(progress?.total ?? 0);
const availableCount = Number(available?.available_count ?? 0);
const completed =
total > 0 &&
availableCount === 0 &&
Number(progress?.complete_count ?? 0) === total;
const next = completed ? "completed" : "active";
if (next !== row.status) {
await db
.prepare("UPDATE tasks SET status = ? WHERE id = ?")
.bind(next, taskId)
.run();
}
return next;
}

View File

@@ -9,6 +9,7 @@ export type AuthUser = {
id: string;
username: string;
role: UserRole;
wecom_userid: string;
};
export type RequestPrincipal =
@@ -244,19 +245,31 @@ export async function getUserFromSessionToken(token: string): Promise<AuthUser |
await ensureSchema();
const row = await getRawDb()
.prepare(
`SELECT u.id, u.username, u.role
`SELECT u.id, u.username, u.role, u.wecom_userid
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ?
LIMIT 1`,
)
.bind(await sha256(token), new Date().toISOString())
.first<{ id: string; username: string; role: string }>();
.first<{
id: string;
username: string;
role: string;
wecom_userid: string | null;
}>();
if (!row || !["super_admin", "admin", "user"].includes(row.role)) return null;
return { id: row.id, username: row.username, role: row.role as UserRole };
return {
id: row.id,
username: row.username,
role: row.role as UserRole,
wecom_userid: String(row.wecom_userid ?? ""),
};
}
export async function getUserFromCookieHeader(cookieHeader: string | null) {
export async function getUserFromCookieHeader(
cookieHeader: string | null,
): Promise<AuthUser | null> {
return getUserFromSessionToken(sessionCookieFromHeader(cookieHeader));
}
@@ -266,12 +279,18 @@ function hasInternalToken(request: Request) {
return Boolean(expected && received && safeEqual(expected, received));
}
// Turbopack dev 会把 `!user` 误判为编译期常量并删掉判空分支,这里用不透明函数调用兜住
function isNullish(value: unknown): value is null | undefined {
return value === null || value === undefined;
}
export async function getRequestPrincipal(
request: Request,
): Promise<RequestPrincipal | null> {
if (hasInternalToken(request)) return { kind: "internal" };
const user = await getUserFromCookieHeader(request.headers.get("cookie"));
return user ? { kind: "user", user } : null;
if (isNullish(user)) return null;
return { kind: "user", user };
}
export async function isAppRequest(request: Request) {

View File

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

View File

@@ -0,0 +1,86 @@
import type { DatabaseClient } from "./database";
export type WecomCallbackFields = Record<string, string>;
export type WecomCallbackEventResult = {
recognized: boolean;
action: string;
taskId: string;
btnKey: string;
operator: string;
};
export function parseWecomCallbackXml(xml: string): WecomCallbackFields {
const fields: WecomCallbackFields = {};
const source = String(xml ?? "");
const pattern =
/<([A-Za-z][A-Za-z0-9_]*)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
const tag = match[1];
const value = match[2] ?? match[3] ?? "";
if (!(tag in fields)) fields[tag] = value.trim();
}
return fields;
}
export function extractWecomEncryptField(body: string): string {
const fields = parseWecomCallbackXml(body);
return fields.Encrypt ?? "";
}
export async function recordCardAck(
db: DatabaseClient,
params: { cardTaskId: string; btnKey: string; operator: string },
) {
const taskId = /^task:([^:]+):/.exec(params.cardTaskId)?.[1] ?? "";
await db
.prepare(
`INSERT INTO wecom_push_logs
(id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
`wpl-${crypto.randomUUID().slice(0, 12)}`,
taskId || params.cardTaskId,
"",
"card_ack",
0,
"[]",
0,
"[]",
"",
JSON.stringify({
cardTaskId: params.cardTaskId,
btnKey: params.btnKey,
operator: params.operator,
}),
)
.run();
}
export async function handleWecomCallbackEvent(
db: DatabaseClient,
fields: WecomCallbackFields,
): Promise<WecomCallbackEventResult> {
const event = fields.Event ?? "";
const taskId = fields.TaskId ?? "";
const btnKey = fields.BtnKey ?? "";
const operator = fields.FromUserName ?? "";
if (fields.MsgType === "event" && event === "template_card_event") {
if (btnKey === "mark_handled") {
await recordCardAck(db, { cardTaskId: taskId, btnKey, operator });
console.info("[KOC LOOP] wecom card ack recorded", { taskId, operator });
return { recognized: true, action: "card_ack", taskId, btnKey, operator };
}
return { recognized: true, action: "ignored", taskId, btnKey, operator };
}
console.info("[KOC LOOP] wecom callback event ignored", {
msgType: fields.MsgType ?? "",
event,
});
return { recognized: false, action: "ignored", taskId, btnKey, operator };
}

View File

@@ -25,6 +25,32 @@ type WecomEnvelope = {
access_token?: string;
expires_in?: number;
invaliduser?: string;
userid?: string;
follow_user?: Array<{ userid: string }>;
external_userid?: string[];
external_contact?: {
external_userid?: string;
name?: string;
avatar?: string;
corp_fullname?: string;
};
next_cursor?: string;
group_chat_list?: Array<{ chat_id?: string; status?: number }>;
group_chat?: {
name?: string;
owner?: string;
member_count?: number;
};
fail_list?: string[];
msgid?: string;
};
export type WecomExternalContact = {
externalUserId: string;
name: string;
avatar: string;
corpName: string;
ownerUserId: string;
};
type CachedAccessToken = {
@@ -70,7 +96,12 @@ function parseDueDays(value: string | undefined) {
return Math.min(30, Math.floor(parsed));
}
function hasAppCredentials(config: WecomConfig) {
// 企微 appchat 的 chatid 只接受字母和数字(下划线/连字符都会报 86001 chatid invalid)
export function sanitizeChatid(value: string) {
return String(value ?? "").replace(/[^a-zA-Z0-9]/g, "");
}
export function hasAppCredentials(config: WecomConfig) {
return Boolean(config.corpId && config.agentId && config.secret);
}
@@ -92,9 +123,10 @@ async function readEnvelope(
function ensureOk(
payload: WecomEnvelope,
fallbackMessage: string,
allowErrcodes: number[] = [],
) {
const code = Number(payload.errcode ?? 0);
if (code === 0) return;
if (code === 0 || allowErrcodes.includes(code)) return;
const message = safeMessage(payload.errmsg) || fallbackMessage;
if (code === 40014 || code === 42001) {
throw new WecomClientError(`企业微信 access_token 无效:${message}`, 401);
@@ -217,3 +249,772 @@ export async function sendWecomAppMessage(
export function clearWecomAccessTokenCacheForTests() {
cachedAccessToken = null;
}
export type WecomTemplateCardButton = {
key: string;
name: string;
url?: string;
};
export async function sendWecomTemplateCard(
config: WecomConfig,
params: {
touser: string[];
taskId: string;
title: string;
description: string;
url: string;
buttons: WecomTemplateCardButton[];
},
fetchImpl: FetchLike = fetch,
): Promise<{ sent: number; failed: number; skipped: boolean }> {
const normalized = params.touser
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (normalized.length === 0) {
return { sent: 0, failed: 0, skipped: true };
}
if (!hasAppCredentials(config)) {
return { sent: 0, failed: 0, skipped: true };
}
const taskId = bindingValue(params.taskId);
if (!taskId) {
throw new WecomClientError("模板卡片缺少 task_id(回调识别用)", 400);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
touser: normalized.join("|"),
msgtype: "template_card",
agentid: Number(config.agentId),
template_card: {
card_type: "text_notice",
source: {
desc: "KOC LOOP",
desc_color: 0,
},
main_title: {
title: safeMessage(params.title),
desc: safeMessage(params.description).slice(0, 120),
},
card_action: {
type: 1,
url: bindingValue(params.url),
},
button_list: params.buttons.map((button) => ({
type: button.url ? 1 : 0,
text: safeMessage(button.name),
key: bindingValue(button.key),
...(button.url ? { url: button.url } : {}),
})),
task_id: taskId,
},
}),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "企业微信模板卡片推送失败");
if (!response.ok) {
throw new WecomClientError(
`企业微信模板卡片推送失败(HTTP ${response.status})`,
502,
);
}
ensureOk(payload, "企业微信模板卡片推送失败");
const invalid = bindingValue(payload.invaliduser).split("|").filter(Boolean);
return {
sent: Math.max(0, normalized.length - invalid.length),
failed: invalid.length,
skipped: false,
};
}
export type WecomGroupChat = {
chatId: string;
name: string;
ownerUserId: string;
memberCount: number;
status: number;
};
export async function listCustomerGroupChats(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
ownerUserids?: string[],
): Promise<WecomGroupChat[]> {
if (!hasAppCredentials(config)) return [];
const token = await fetchAccessToken(config, fetchImpl);
const ownerFilterUserids = (ownerUserids ?? [])
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
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,
...(ownerFilterUserids.length > 0
? { owner_filter: { userid_list: ownerFilterUserids } }
: {}),
}),
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 as
| {
name?: unknown;
owner?: unknown;
member_count?: unknown;
member_list?: unknown[];
}
| undefined;
// 企微实际响应不带 member_count(只有 member_list),以成员列表长度为准
const memberCount = Array.isArray(detail?.member_list)
? detail.member_list.length
: Number(detail?.member_count ?? 0);
groups.push({
chatId,
name: bindingValue(detail?.name),
ownerUserId: bindingValue(detail?.owner),
memberCount,
status,
});
}
return groups;
}
export type WecomGroupMsgLink = {
title: string;
desc?: string;
url: string;
picurl?: string;
};
export async function createGroupMsgTemplate(
config: WecomConfig,
params: {
sender: string;
chatIdList: string[];
text?: string;
link?: WecomGroupMsgLink;
},
fetchImpl: FetchLike = fetch,
): Promise<{ msgid: string; failList: string[] }> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const chatIdList = params.chatIdList.map(bindingValue).filter(Boolean);
if (chatIdList.length === 0) {
throw new WecomClientError("客户群列表为空", 400);
}
const sender = bindingValue(params.sender);
if (!sender) {
throw new WecomClientError("发送成员(群主 userid)不能为空", 400);
}
const text = String(params.text ?? "").trim();
const link = params.link;
if (!text && !link) {
throw new WecomClientError("文本与图文附件不能同时为空", 400);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/add_msg_template?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
chat_type: "group",
chat_id_list: chatIdList,
sender,
...(text ? { text: { content: text } } : {}),
...(link
? {
attachments: [
{
msgtype: "link",
link: {
title: link.title,
desc: link.desc ?? "",
url: link.url,
picurl: link.picurl ?? "",
},
},
],
}
: {}),
}),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "创建企业群发失败");
if (!response.ok) {
throw new WecomClientError(
`创建企业群发失败(HTTP ${response.status})`,
502,
);
}
ensureOk(payload, "创建企业群发失败");
return {
msgid: bindingValue(payload.msgid),
failList: (payload.fail_list ?? []).filter(Boolean),
};
}
export async function listExternalContacts(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<WecomExternalContact[]> {
if (!hasAppCredentials(config)) return [];
const token = await fetchAccessToken(config, fetchImpl);
const followUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get_follow_user_list?access_token=${encodeURIComponent(token)}`;
const followResp = await fetchImpl(followUrl, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const followPayload = await readEnvelope(
followResp,
"获取企业微信跟进人列表失败",
);
if (!followResp.ok) {
throw new WecomClientError(
`获取企业微信跟进人列表失败(HTTP ${followResp.status})`,
502,
);
}
ensureOk(followPayload, "获取企业微信跟进人列表失败");
const ownerUserIds = (followPayload.follow_user ?? [])
.map((user) => bindingValue(user.userid))
.filter(Boolean);
const ownerToExternalIds: Array<{ externalUserId: string; ownerUserId: string }> = [];
for (const ownerUserId of ownerUserIds) {
const listUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/list?access_token=${encodeURIComponent(token)}&userid=${encodeURIComponent(ownerUserId)}`;
const listResp = await fetchImpl(listUrl, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const listPayload = await readEnvelope(
listResp,
"获取企业微信外部联系人列表失败",
);
if (!listResp.ok) continue;
if (Number(listPayload.errcode ?? 0) !== 0) continue;
const externalIds = (listPayload.external_userid ?? []).filter(Boolean);
for (const externalUserId of externalIds) {
ownerToExternalIds.push({ externalUserId, ownerUserId });
}
}
const contacts: WecomExternalContact[] = [];
for (const { externalUserId, ownerUserId } of ownerToExternalIds) {
const getUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get?access_token=${encodeURIComponent(token)}&external_userid=${encodeURIComponent(externalUserId)}`;
const getResp = await fetchImpl(getUrl, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const getPayload = await readEnvelope(
getResp,
"获取企业微信外部联系人详情失败",
);
if (!getResp.ok) continue;
if (Number(getPayload.errcode ?? 0) !== 0) continue;
const info = getPayload.external_contact;
if (!info) continue;
contacts.push({
externalUserId,
name: bindingValue(info.name),
avatar: bindingValue(info.avatar),
corpName: bindingValue(info.corp_fullname) || bindingValue(info.name),
ownerUserId,
});
}
return contacts;
}
export function buildWecomOAuthUrl(params: {
corpId: string;
agentId: string;
redirectUri: string;
state: string;
}): string {
const query = new URLSearchParams({
appid: params.corpId,
redirect_uri: params.redirectUri,
response_type: "code",
scope: "snsapi_base",
state: params.state,
agentid: params.agentId,
});
return `https://open.weixin.qq.com/connect/oauth2/authorize?${query.toString()}#wechat_redirect`;
}
export type WecomOAuthIdentity = {
userId?: string;
externalUserId?: string;
};
export async function exchangeWecomOAuthCode(
config: WecomConfig,
code: string,
fetchImpl: FetchLike = fetch,
): Promise<WecomOAuthIdentity> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const authorizationCode = bindingValue(code);
if (!authorizationCode) {
throw new WecomClientError("缺少企业微信授权 code", 400);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/auth/getuserinfo?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ code: authorizationCode }),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "企业微信授权身份换取失败");
if (!response.ok) {
throw new WecomClientError(
`企业微信授权身份换取失败(HTTP ${response.status})`,
502,
);
}
if (Number(payload.errcode ?? 0) === 40029) {
throw new WecomClientError("企业微信授权 code 无效或已过期", 400);
}
ensureOk(payload, "企业微信授权身份换取失败");
const userId = bindingValue(payload.userid);
const externalUserId =
typeof payload.external_userid === "string"
? bindingValue(payload.external_userid)
: "";
if (!userId && !externalUserId) {
throw new WecomClientError("企业微信未返回有效身份", 400);
}
return {
...(userId ? { userId } : {}),
...(externalUserId ? { externalUserId } : {}),
};
}
export async function fetchWecomExternalContactName(
config: WecomConfig,
externalUserId: string,
fetchImpl: FetchLike = fetch,
): Promise<string | null> {
if (!hasAppCredentials(config)) return null;
const externalId = bindingValue(externalUserId);
if (!externalId) return null;
try {
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get?access_token=${encodeURIComponent(token)}&external_userid=${encodeURIComponent(externalId)}`;
const response = await fetchImpl(url, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "获取外部联系人详情失败");
if (!response.ok) return null;
if (Number(payload.errcode ?? 0) !== 0) return null;
const name = bindingValue(payload.external_contact?.name);
return name || null;
} catch {
return null;
}
}
async function postAppChat(
config: WecomConfig,
path: string,
body: Record<string, unknown>,
errorLabel: string,
fetchImpl: FetchLike,
allowErrcodes: number[] = [],
): Promise<Record<string, unknown>> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}${path}?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, errorLabel);
if (!response.ok) {
throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502);
}
ensureOk(payload, errorLabel, allowErrcodes);
return payload;
}
export async function createAppChat(
config: WecomConfig,
params: { name: string; userlist: string[]; chatid: string },
fetchImpl: FetchLike = fetch,
): Promise<{ chatid: string }> {
const userlist = params.userlist
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (userlist.length < 2) {
throw new WecomClientError("创建群聊会话至少需要 2 名成员", 400);
}
const chatid = bindingValue(params.chatid);
if (!chatid || chatid.length > 32) {
throw new WecomClientError("群聊 chatid 缺失或超过 32 字符", 400);
}
const name = bindingValue(params.name).slice(0, 20);
const payload = await postAppChat(
config,
"/cgi-bin/appchat/create",
// owner 不传:默认建群应用自己为群主,避免把 agentId 误当 userid
{ name, userlist, chatid },
"创建企业微信群聊会话失败",
fetchImpl,
// 86215 = chatid 已存在:chatid 由本项目派生(pgc/tgc+ID),视为本应用已建过的群
[86215],
);
return { chatid: bindingValue(payload.chatid) || chatid };
}
export async function updateAppChat(
config: WecomConfig,
params: {
chatid: string;
addUserList?: string[];
delUserList?: string[];
},
fetchImpl: FetchLike = fetch,
): Promise<void> {
const body: Record<string, unknown> = {
chatid: bindingValue(params.chatid),
};
const addUserList = (params.addUserList ?? [])
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
const delUserList = (params.delUserList ?? [])
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (addUserList.length === 0 && delUserList.length === 0) return;
if (addUserList.length > 0) body.add_user_list = addUserList;
if (delUserList.length > 0) body.del_user_list = delUserList;
await postAppChat(
config,
"/cgi-bin/appchat/update",
body,
"更新企业微信群聊会话失败",
fetchImpl,
);
}
export async function getAppChat(
config: WecomConfig,
chatid: string,
fetchImpl: FetchLike = fetch,
): Promise<{ chatId: string; name: string; userlist: string[] } | null> {
try {
const payload = await postAppChat(
config,
"/cgi-bin/appchat/get",
{ chatid: bindingValue(chatid) },
"获取企业微信群聊会话失败",
fetchImpl,
);
const detail = payload.chat_info as
| { chatid?: unknown; name?: unknown; userlist?: unknown[] }
| undefined;
if (!detail) return null;
return {
chatId: bindingValue(detail.chatid),
name: bindingValue(detail.name),
userlist: Array.isArray(detail.userlist)
? detail.userlist.map((id: unknown) => bindingValue(id)).filter(Boolean)
: [],
};
} catch {
return null;
}
}
export async function sendAppChatMessage(
config: WecomConfig,
params: { chatid: string; text: string },
fetchImpl: FetchLike = fetch,
): Promise<void> {
const chatid = bindingValue(params.chatid);
const content = bindingValue(params.text);
if (!chatid || !content) return;
await postAppChat(
config,
"/cgi-bin/appchat/send",
{ chatid, safe: 0, msgtype: "text", text: { content } },
"发送企业微信群聊消息失败",
fetchImpl,
);
}
export type TaskJoinWayInput = {
chatIdList: string[];
roomBaseName: string;
state: string;
};
function joinWayBody(input: TaskJoinWayInput): Record<string, unknown> {
return {
scene: 2,
chat_id_list: input.chatIdList
.map((id) => bindingValue(id))
.filter((id) => id.length > 0)
.slice(0, 5),
auto_create_room: 1,
room_base_name: Array.from(input.roomBaseName).slice(0, 40).join(""),
state: Array.from(input.state).slice(0, 30).join(""),
};
}
export async function addTaskJoinWay(
config: WecomConfig,
input: TaskJoinWayInput,
fetchImpl: FetchLike = fetch,
): Promise<{ configId: string }> {
const payload = await postAppChat(
config,
"/cgi-bin/externalcontact/groupchat/add_join_way",
joinWayBody(input),
"创建企业微信进群活码失败",
fetchImpl,
);
const configId = bindingValue(payload.config_id);
if (!configId) {
throw new WecomClientError("创建企业微信进群活码失败(未返回 config_id)", 502);
}
return { configId };
}
export async function getJoinWayQrCode(
config: WecomConfig,
configId: string,
fetchImpl: FetchLike = fetch,
): Promise<{ qrCode: string }> {
const payload = await postAppChat(
config,
"/cgi-bin/externalcontact/groupchat/get_join_way",
{ config_id: bindingValue(configId) },
"获取企业微信进群活码二维码失败",
fetchImpl,
);
const joinWay = payload.join_way as { qr_code?: unknown } | undefined;
const qrCode = bindingValue(joinWay?.qr_code);
if (!qrCode) {
throw new WecomClientError("获取企业微信进群活码二维码失败(未返回二维码)", 502);
}
return { qrCode };
}
export async function updateTaskJoinWay(
config: WecomConfig,
input: TaskJoinWayInput & { configId: string },
fetchImpl: FetchLike = fetch,
): Promise<void> {
const configId = bindingValue(input.configId);
if (!configId) {
throw new WecomClientError("更新企业微信进群活码失败(缺少 config_id)", 400);
}
// update_join_way 为覆盖式更新,需重传全部字段(含 scene / auto_create_room)
await postAppChat(
config,
"/cgi-bin/externalcontact/groupchat/update_join_way",
{ ...joinWayBody(input), config_id: configId },
"更新企业微信进群活码失败",
fetchImpl,
);
}
export async function deleteJoinWay(
config: WecomConfig,
configId: string,
fetchImpl: FetchLike = fetch,
): Promise<void> {
const configIdValue = bindingValue(configId);
if (!configIdValue) return;
try {
await postAppChat(
config,
"/cgi-bin/externalcontact/groupchat/del_join_way",
{ config_id: configIdValue },
"删除企业微信进群活码失败",
fetchImpl,
);
} catch (error) {
// 活码配置可能已被企微侧清理(群解散等),删除是尽力而为,不阻断本地解绑
if (error instanceof WecomClientError) return;
throw error;
}
}
async function getWecomJson(
config: WecomConfig,
path: string,
params: Record<string, string>,
errorLabel: string,
fetchImpl: FetchLike,
): Promise<Record<string, unknown>> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const token = await fetchAccessToken(config, fetchImpl);
const query = new URLSearchParams({ access_token: token, ...params });
const response = await fetchImpl(`${WECOM_API_ORIGIN}${path}?${query}`, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, errorLabel);
if (!response.ok) {
throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502);
}
ensureOk(payload, errorLabel);
return payload as Record<string, unknown>;
}
export type WecomInternalContact = {
userid: string;
name: string;
departmentNames: string[];
};
const INTERNAL_CONTACT_DEPARTMENT_LIMIT = 100;
const INTERNAL_CONTACT_CURSOR_ROUNDS = 10;
export async function listInternalContacts(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<WecomInternalContact[]> {
const deptPayload = await getWecomJson(
config,
"/cgi-bin/department/list",
{},
"获取企业微信部门列表失败",
fetchImpl,
);
const departments = (
Array.isArray(deptPayload.department)
? (deptPayload.department as { id?: unknown; name?: unknown }[])
: []
).slice(0, INTERNAL_CONTACT_DEPARTMENT_LIMIT);
const departmentNames = new Map<string, string>();
for (const dept of departments) {
const id = String(dept?.id ?? "");
if (id) departmentNames.set(id, bindingValue(dept?.name));
}
const contacts = new Map<string, WecomInternalContact>();
for (const dept of departments) {
const departmentId = String(dept?.id ?? "");
if (!departmentId) continue;
let cursor = "";
for (let round = 0; round < INTERNAL_CONTACT_CURSOR_ROUNDS; round += 1) {
const params: Record<string, string> = {
department_id: departmentId,
fetch_child: "0",
};
if (cursor) params.cursor = cursor;
const payload = await getWecomJson(
config,
"/cgi-bin/user/simplelist",
params,
"获取企业微信部门成员失败",
fetchImpl,
);
const userlist = Array.isArray(payload.userlist)
? (payload.userlist as {
userid?: unknown;
name?: unknown;
department?: unknown[];
}[])
: [];
for (const user of userlist) {
const userid = bindingValue(user?.userid);
if (!userid) continue;
const name = bindingValue(user?.name);
const deptIds = (
Array.isArray(user?.department)
? user.department.map((id) => String(id)).filter(Boolean)
: [departmentId]
);
const existing = contacts.get(userid);
if (existing) {
if (!existing.name && name) existing.name = name;
for (const deptId of deptIds) {
const deptName = departmentNames.get(deptId);
if (deptName && !existing.departmentNames.includes(deptName)) {
existing.departmentNames.push(deptName);
}
}
} else {
contacts.set(userid, {
userid,
name,
departmentNames: deptIds
.map((id) => departmentNames.get(id))
.filter((deptName): deptName is string => Boolean(deptName)),
});
}
}
const nextCursor = bindingValue(payload.next_cursor);
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
}
return [...contacts.values()].sort((a, b) =>
`${a.name}${a.userid}`.localeCompare(`${b.name}${b.userid}`, "zh"),
);
}

View File

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

View File

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

View File

@@ -3,9 +3,12 @@ import { shanghaiDateFromTimestamp } from "./collection-service";
import {
sendWecomAppMessage,
sendWecomRobotMessage,
sendWecomTemplateCard,
type WecomConfig,
} from "./wecom-client";
import { sendTaskChatMessage } from "./task-group-service";
import { getRuntimeEnv } from "./runtime-env";
import { releaseUnfinishedDistribution } from "./distribution-release-service";
type FetchLike = typeof fetch;
@@ -22,11 +25,31 @@ type DueSoonRow = {
partner_id: string;
partner_name: string;
wecom_external_user_id: string | null;
task_id: string;
task_name: string;
due_at: string;
pending_phase: "publish" | "recovery" | "metrics";
pending_due_at: string;
content_title: string;
};
function pendingActionLabel(pendingPhase: string) {
if (pendingPhase === "recovery") return "截图待提交";
if (pendingPhase === "metrics") return "公开数据待补交";
return "待发布";
}
function pendingSummary(rows: DueSoonRow[]) {
const shot = rows.filter((row) => row.pending_phase === "recovery").length;
const metrics = rows.filter((row) => row.pending_phase === "metrics").length;
const publish = rows.length - shot - metrics;
const parts: string[] = [];
if (publish > 0) parts.push(`${publish} 条笔记待发布`);
if (shot > 0) parts.push(`${shot} 份截图待提交`);
if (metrics > 0) parts.push(`${metrics} 条公开数据待补交`);
if (parts.length === 0) return `${rows.length} 条待跟进`;
return parts.join("、");
}
export function computeDueCutoff(today: string, dueDays: number) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today);
if (!match) return today;
@@ -42,32 +65,57 @@ export async function runDueSoonWecomNotifications(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
taskId?: string,
): Promise<WecomNotifySummary> {
const today = shanghaiDateFromTimestamp(now);
const cutoff = computeDueCutoff(today, config.dueDays);
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
const taskFilter = String(taskId ?? "").trim();
const result = await db
.prepare(
`SELECT
// 双阶段催办:发布阶段(未回填链接)看 due_at;回收阶段(已发布未收截图)看 recovery_due_at;
// 采集失败兜底:已发布但公开数据采集失败时催 KOC 补交数据截图(不限日期,尽快补)
const dueSoonBase = `SELECT
d.id AS distribution_id,
d.partner_id,
p.name AS partner_name,
p.wecom_external_user_id,
t.id AS task_id,
t.name AS task_name,
t.due_at,
CASE
WHEN d.publish_url IS NULL OR d.publish_url = '' THEN 'publish'
WHEN d.collection_status = 'failed' THEN 'metrics'
ELSE 'recovery'
END AS pending_phase,
CASE
WHEN d.publish_url IS NULL OR d.publish_url = '' THEN t.due_at
WHEN d.collection_status = 'failed'
THEN COALESCE(NULLIF(t.recovery_due_at, ''), t.due_at)
ELSE t.recovery_due_at
END AS pending_due_at,
c.title AS content_title
FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id
JOIN contents c ON c.id = d.content_id
WHERE (d.publish_url IS NULL OR d.publish_url = '')
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at <= ?
ORDER BY t.due_at ASC, p.name ASC`,
WHERE (
((d.publish_url IS NULL OR d.publish_url = '')
AND t.due_at IS NOT NULL AND t.due_at != '' AND t.due_at <= ?)
OR (t.recovery_enabled = 1
AND d.publish_url IS NOT NULL AND d.publish_url != ''
AND d.result_submitted_at IS NULL
AND t.recovery_due_at IS NOT NULL AND t.recovery_due_at != ''
AND t.recovery_due_at <= ?)
OR (d.publish_url IS NOT NULL AND d.publish_url != ''
AND d.collection_status = 'failed')
)`;
const statement = db
.prepare(
taskFilter
? `${dueSoonBase} AND t.id = ? ORDER BY pending_due_at ASC, p.name ASC`
: `${dueSoonBase} ORDER BY pending_due_at ASC, p.name ASC`,
)
.bind(cutoff)
.all<DueSoonRow>();
.bind(...(taskFilter ? [cutoff, cutoff, taskFilter] : [cutoff, cutoff]));
const result = await statement.all<DueSoonRow>();
const grouped = new Map<
string,
@@ -102,13 +150,13 @@ export async function runDueSoonWecomNotifications(
? [entry.externalUserId]
: [];
const lines = entry.rows.slice(0, 5).map((row) => {
return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(截止 ${row.due_at})`;
return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(${pendingActionLabel(row.pending_phase)},截止 ${row.pending_due_at})`;
});
const overflow =
entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : "";
const link = portalUrl ? `\n领取链接:${portalUrl}` : "";
const content =
`${entry.partnerName},你有 ${entry.rows.length} 条内容待发布:\n${lines.join("\n")}${overflow}${link}`;
`${entry.partnerName},你有 ${pendingSummary(entry.rows)}:\n${lines.join("\n")}${overflow}${link}`;
const appResult = await sendWecomAppMessage(
external,
content,
@@ -128,14 +176,39 @@ export async function runDueSoonWecomNotifications(
} else {
dueSoonSent += 1;
}
const earliestDue = entry.rows[0]?.due_at ?? "";
const earliestDue = entry.rows[0]?.pending_due_at ?? "";
digestTasks.push(
`· ${entry.partnerName}(${entry.rows.length} 条,最近截止 ${earliestDue})`,
);
}
const byTask = new Map<
string,
{ taskName: string; dueAt: string; rows: DueSoonRow[] }
>();
for (const row of result.results) {
const entry = byTask.get(row.task_id) ?? {
taskName: row.task_name,
dueAt: row.pending_due_at,
rows: [],
};
entry.rows.push(row);
byTask.set(row.task_id, entry);
}
for (const [groupTaskId, entry] of byTask) {
const partners = [...new Set(entry.rows.map((row) => row.partner_name))];
const overflow = partners.length > 5 ? ` 等 ${partners.length} 位` : "";
await sendTaskChatMessage(
db,
groupTaskId,
`【催办】《${truncate(entry.taskName, 24)}》有 ${pendingSummary(entry.rows)}(截止 ${entry.dueAt}):\n${partners.slice(0, 5).join("、")}${overflow}`,
config,
fetchImpl,
);
}
let digestSent = false;
if (config.robotWebhook && digestTasks.length > 0) {
if (config.robotWebhook && digestTasks.length > 0 && !taskFilter) {
const digest =
`今日待发布催办(${today},截止 ≤ ${cutoff}):\n${digestTasks.join("\n")}`;
try {
@@ -162,6 +235,266 @@ function bindingValue(value: unknown) {
return String(value ?? "").trim();
}
export function resolveAutoReleaseDays(value: string | undefined) {
const parsed = Number(String(value ?? "").trim());
if (!Number.isFinite(parsed) || parsed < 1) return 0;
return Math.min(90, Math.floor(parsed));
}
type OverdueRow = {
id: string;
task_id: string;
task_name: string;
pending_phase: "publish" | "recovery";
pending_due_at: string;
project_id: string;
partner_id: string;
partner_name: string;
};
export type OverdueEscalationSummary = {
overdueTasks: number;
overdueDistributions: number;
robotSent: boolean;
cardSent: number;
cardFailed: number;
};
async function queryOverdueUnpublished(
db: DatabaseClient,
cutoff: string,
options: { phases: "publish" | "all" } = { phases: "all" },
): Promise<OverdueRow[]> {
const publishBranch = `(d.publish_url IS NULL OR d.publish_url = '')
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at < ?`;
const recoveryBranch = `t.recovery_enabled = 1
AND d.publish_url IS NOT NULL AND d.publish_url != ''
AND d.result_submitted_at IS NULL
AND t.recovery_due_at IS NOT NULL AND t.recovery_due_at != ''
AND t.recovery_due_at < ?`;
const where =
options.phases === "publish"
? publishBranch
: `(${publishBranch}) OR (${recoveryBranch})`;
const result = await db
.prepare(
`SELECT d.id, t.id AS task_id, t.name AS task_name,
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN 'publish' ELSE 'recovery' END AS pending_phase,
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN t.due_at ELSE t.recovery_due_at END AS pending_due_at,
t.project_id, p.id AS partner_id, p.name AS partner_name
FROM distributions d
JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id
WHERE ${where}
ORDER BY pending_due_at ASC, t.name ASC`,
)
.bind(...(options.phases === "publish" ? [cutoff] : [cutoff, cutoff]))
.all<OverdueRow>();
return result.results;
}
export async function runOverdueEscalation(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
): Promise<OverdueEscalationSummary> {
const today = shanghaiDateFromTimestamp(now);
const rows = await queryOverdueUnpublished(db, today);
const byTask = new Map<
string,
{
taskName: string;
dueAt: string;
phase: "publish" | "recovery";
projectId: string;
count: number;
partners: Set<string>;
}
>();
for (const row of rows) {
const key = `${row.task_id}:${row.pending_phase}`;
const entry = byTask.get(key) ?? {
taskName: row.task_name,
dueAt: row.pending_due_at,
phase: row.pending_phase,
projectId: bindingValue(row.project_id),
count: 0,
partners: new Set<string>(),
};
entry.count += 1;
entry.partners.add(row.partner_name || row.partner_id);
byTask.set(key, entry);
}
const summary: OverdueEscalationSummary = {
overdueTasks: byTask.size,
overdueDistributions: rows.length,
robotSent: false,
cardSent: 0,
cardFailed: 0,
};
if (rows.length === 0) return summary;
if (config.robotWebhook) {
const lines = [...byTask.values()].map((entry) => {
const partners = [...entry.partners].slice(0, 5).join("、");
const overflow = entry.partners.size > 5 ? ` 等 ${entry.partners.size} 位` : "";
const label = entry.phase === "recovery" ? "截图超期未提交" : "超期未发布";
return `· ${truncate(entry.taskName, 24)}(截止 ${entry.dueAt}):${entry.count} 条${label} — ${partners}${overflow}`;
});
try {
await sendWecomRobotMessage(
`【超期未发布升级】${today}\n${lines.join("\n")}\n请运营跟进催办或释放。`,
config,
fetchImpl,
);
summary.robotSent = true;
} catch (error) {
console.warn("[KOC LOOP] overdue escalation robot failed", {
error: safeError(error),
});
}
}
const env = getRuntimeEnv();
const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL);
for (const [key, entry] of byTask) {
const taskId = key.split(":")[0];
const overdueText =
entry.phase === "recovery"
? `${entry.count} 份截图超期未提交`
: `${entry.count} 条内容超期未发布`;
await sendTaskChatMessage(
db,
taskId,
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${overdueText},请跟进催办或释放。`,
config,
fetchImpl,
);
if (!entry.projectId) continue;
try {
const project = await db
.prepare("SELECT main_group_member_ids FROM projects WHERE id = ?")
.bind(entry.projectId)
.first<{ main_group_member_ids: string }>();
const members = safeParseIdList(project?.main_group_member_ids);
if (members.length === 0) continue;
const sent = await sendWecomTemplateCard(
config,
{
touser: members,
taskId: `task:${taskId}:due`,
title: `超期催办:${truncate(entry.taskName, 20)}`,
description: `${entry.phase === "recovery" ? `${entry.count} 份截图超期未提交` : `${entry.count} 条内容超期未发布`}(截止 ${entry.dueAt}),请跟进催办或释放`,
url: cardUrl,
buttons: [
{ key: "view_task", name: "查看任务", url: cardUrl },
{ key: "mark_handled", name: "已处理" },
],
},
fetchImpl,
);
if (!sent.skipped) {
summary.cardSent += sent.sent;
summary.cardFailed += sent.failed;
}
} catch (error) {
summary.cardFailed += 1;
console.warn("[KOC LOOP] overdue escalation card failed", {
taskId,
error: safeError(error),
});
}
}
return summary;
}
export type AutoReleaseSummary = {
enabled: boolean;
days: number;
cutoff: string;
released: number;
blocked: number;
failed: number;
robotSent: boolean;
};
export async function runOverdueAutoRelease(
db: DatabaseClient,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
): Promise<AutoReleaseSummary> {
const days = resolveAutoReleaseDays(getRuntimeEnv().WECOM_AUTO_RELEASE_DAYS);
const summary: AutoReleaseSummary = {
enabled: days > 0,
days,
cutoff: "",
released: 0,
blocked: 0,
failed: 0,
robotSent: false,
};
if (days <= 0) return summary;
const today = shanghaiDateFromTimestamp(now);
summary.cutoff = computeDueCutoff(today, -days);
// 自动释放只处理发布阶段超期:回收超期的笔记已发布,释放重派无意义
const rows = await queryOverdueUnpublished(db, summary.cutoff, {
phases: "publish",
});
for (const row of rows) {
try {
await releaseUnfinishedDistribution(db, row.id);
summary.released += 1;
} catch (error) {
const status = (error as { status?: number }).status;
if (status === 409) {
summary.blocked += 1;
} else {
summary.failed += 1;
console.warn("[KOC LOOP] auto release failed", {
distributionId: row.id,
error: safeError(error),
});
}
}
}
if (summary.released > 0 && config.robotWebhook) {
try {
await sendWecomRobotMessage(
`【超期自动释放】截止 ${summary.cutoff} 前仍未发布的内容已释放 ${summary.released} 条回库存(跳过 ${summary.blocked} 条已提交、失败 ${summary.failed} 条),可重新领取。`,
config,
fetchImpl,
);
summary.robotSent = true;
} catch (error) {
console.warn("[KOC LOOP] auto release robot failed", {
error: safeError(error),
});
}
}
return summary;
}
function safeParseIdList(value: string | null | undefined) {
try {
const parsed = JSON.parse(String(value ?? "[]"));
if (!Array.isArray(parsed)) return [];
return parsed.map((item) => String(item)).filter(Boolean);
} catch {
return [];
}
}
function truncate(value: string, max: number) {
return value.length > max ? `${value.slice(0, max)}…` : value;
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
ALTER TABLE `projects` ADD COLUMN `owner_userid` VARCHAR(64) NOT NULL DEFAULT '' AFTER `main_group_member_ids`;
-- statement-breakpoint
ALTER TABLE `tasks` ADD COLUMN `task_chat_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `project_id`;
-- statement-breakpoint
ALTER TABLE `claims` ADD COLUMN `owner_partner_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `partner_id`;

View File

@@ -0,0 +1,5 @@
ALTER TABLE `projects` ADD COLUMN `main_chat_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `owner_userid`;
-- statement-breakpoint
ALTER TABLE `tasks` ADD COLUMN `owner_userid` VARCHAR(64) NOT NULL DEFAULT '' AFTER `task_chat_id`;
-- statement-breakpoint
DROP TABLE IF EXISTS `project_group_chats`;

View File

@@ -0,0 +1 @@
ALTER TABLE `users` ADD COLUMN `wecom_userid` VARCHAR(128) NOT NULL DEFAULT '' AFTER `role`;

View File

@@ -0,0 +1,7 @@
ALTER TABLE `tasks` ADD COLUMN `customer_chat_id` VARCHAR(128) NOT NULL DEFAULT '' AFTER `owner_userid`;
-- statement-breakpoint
ALTER TABLE `tasks` ADD COLUMN `customer_chat_name` VARCHAR(255) NOT NULL DEFAULT '' AFTER `customer_chat_id`;
-- statement-breakpoint
ALTER TABLE `tasks` ADD COLUMN `join_config_id` VARCHAR(128) NOT NULL DEFAULT '' AFTER `customer_chat_name`;
-- statement-breakpoint
ALTER TABLE `tasks` ADD COLUMN `join_qr_code` TEXT NOT NULL AFTER `join_config_id`;

View File

@@ -0,0 +1,5 @@
ALTER TABLE `tasks` ADD COLUMN `related_task_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `join_qr_code`;
-- statement-breakpoint
ALTER TABLE `contents` ADD COLUMN `related_content_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `source_row`;
-- statement-breakpoint
CREATE INDEX `contents_related_content_idx` ON `contents` (`related_content_id`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `projects` ADD COLUMN `parent_id` VARCHAR(64) NOT NULL DEFAULT '';
-- statement-breakpoint
ALTER TABLE `projects` ADD INDEX `projects_parent_idx` (`parent_id`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `partners` ADD COLUMN `account_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER wecom_userid;
-- statement-breakpoint
ALTER TABLE `partners` ADD INDEX `partners_account_idx` (`account_id`);

View File

@@ -0,0 +1 @@
ALTER TABLE `projects` DROP COLUMN `parent_id`;

View File

@@ -0,0 +1,125 @@
-- 任务模型物理合并:回收成为分发任务的第二阶段(单任务双阶段)
-- 上线前请先备份生产库;本迁移有 DELETE,回滚需依赖备份
ALTER TABLE tasks ADD COLUMN recovery_enabled INTEGER NOT NULL DEFAULT 1 AFTER related_task_id;
-- statement-breakpoint
ALTER TABLE tasks ADD COLUMN recovery_due_at TEXT NOT NULL DEFAULT ('') AFTER recovery_enabled;
-- statement-breakpoint
ALTER TABLE tasks ADD COLUMN recovery_instructions TEXT NOT NULL DEFAULT ('') AFTER recovery_due_at;
-- statement-breakpoint
ALTER TABLE tasks ADD COLUMN recovery_example_images TEXT NOT NULL DEFAULT ('[]') AFTER recovery_instructions;
-- statement-breakpoint
-- 老分发任务默认关回收;下方为有伴生回收的父任务重新置 1
UPDATE tasks SET recovery_enabled = 0 WHERE task_type = 'content_publish';
-- statement-breakpoint
-- 配对回收:回收字段并入父任务(说明/示例图取回收任务首条内容,截止取回收任务截止)
UPDATE tasks parent
JOIN tasks child ON child.related_task_id = parent.id
JOIN (
SELECT task_id, body, image_assets
FROM contents
WHERE id IN (SELECT MIN(id) FROM contents GROUP BY task_id)
) first_content ON first_content.task_id = child.id
SET parent.recovery_due_at = child.due_at,
parent.recovery_instructions = first_content.body,
parent.recovery_example_images = first_content.image_assets,
parent.recovery_enabled = 1
WHERE child.task_type = 'screenshot_collect';
-- statement-breakpoint
-- 配对回收:父任务无内容兜底(回收任务没有 contents 行时仍开通回收)
UPDATE tasks parent
JOIN tasks child ON child.related_task_id = parent.id
SET parent.recovery_due_at = child.due_at,
parent.recovery_instructions = '请提交发布后的笔记截图',
parent.recovery_example_images = '[]',
parent.recovery_enabled = 1
WHERE child.task_type = 'screenshot_collect'
AND parent.recovery_enabled = 0;
-- statement-breakpoint
-- 配对回收:截图提交记录搬到父任务同 content+partner 的分发行(父行已有提交时保留父行)
UPDATE distributions target
JOIN contents legacy_content ON legacy_content.id = target.content_id
JOIN distributions legacy ON legacy.content_id = legacy_content.related_content_id
AND legacy.partner_id = target.partner_id
JOIN tasks legacy_task ON legacy_task.id = legacy.task_id
SET target.result_screenshot_key = legacy.result_screenshot_key,
target.result_submitted_at = legacy.result_submitted_at
WHERE legacy_task.task_type = 'screenshot_collect'
AND legacy_task.related_task_id = target.task_id
AND target.result_submitted_at IS NULL;
-- statement-breakpoint
-- 配对回收:父任务里没有对应分发行时,保留为一条只挂回收字段的分发记录
INSERT INTO distributions (id, task_id, content_id, partner_id, status, result_screenshot_key, result_submitted_at, claimed_at)
SELECT
CONCAT('dist-m', LEFT(REPLACE(UUID(), '-', ''), 20)),
legacy_task.related_task_id,
legacy_content.related_content_id,
legacy.partner_id,
'claimed',
legacy.result_screenshot_key,
legacy.result_submitted_at,
legacy.claimed_at
FROM distributions legacy
JOIN contents legacy_content ON legacy_content.id = legacy.content_id
JOIN tasks legacy_task ON legacy_task.id = legacy.task_id
WHERE legacy_task.task_type = 'screenshot_collect'
AND legacy_task.related_task_id != ''
AND legacy_content.related_content_id != ''
AND NOT EXISTS (
SELECT 1 FROM distributions parent_dist
WHERE parent_dist.task_id = legacy_task.related_task_id
AND parent_dist.content_id = legacy_content.related_content_id
AND parent_dist.partner_id = legacy.partner_id
);
-- statement-breakpoint
-- 配对回收:清理伴生回收任务的分发/领取/内容/任务行
DELETE FROM distributions
WHERE task_id IN (SELECT id FROM (SELECT id FROM tasks WHERE task_type = 'screenshot_collect' AND related_task_id != '') AS doomed);
-- statement-breakpoint
DELETE FROM claims
WHERE task_id IN (SELECT id FROM (SELECT id FROM tasks WHERE task_type = 'screenshot_collect' AND related_task_id != '') AS doomed);
-- statement-breakpoint
DELETE FROM contents
WHERE task_id IN (SELECT id FROM (SELECT id FROM tasks WHERE task_type = 'screenshot_collect' AND related_task_id != '') AS doomed);
-- statement-breakpoint
DELETE FROM tasks WHERE task_type = 'screenshot_collect' AND related_task_id != '';
-- statement-breakpoint
-- 独立回收(无关联分发):无领取记录的直接删除
DELETE FROM contents
WHERE task_id IN (
SELECT id FROM (
SELECT t.id FROM tasks t
WHERE t.task_type = 'screenshot_collect'
AND (t.related_task_id IS NULL OR t.related_task_id = '')
AND NOT EXISTS (SELECT 1 FROM distributions d WHERE d.task_id = t.id)
) AS doomed
);
-- statement-breakpoint
DELETE FROM tasks
WHERE task_type = 'screenshot_collect'
AND (related_task_id IS NULL OR related_task_id = '')
AND id NOT IN (SELECT DISTINCT task_id FROM distributions);
-- statement-breakpoint
-- 独立回收(有领取记录):原地转正为单模型任务(发布阶段字段留空,仅存历史数据)
UPDATE tasks standalone
JOIN (
SELECT task_id, body, image_assets
FROM contents
WHERE id IN (SELECT MIN(id) FROM contents GROUP BY task_id)
) first_content ON first_content.task_id = standalone.id
SET standalone.task_type = 'content_publish',
standalone.recovery_due_at = standalone.due_at,
standalone.recovery_instructions = first_content.body,
standalone.recovery_example_images = first_content.image_assets,
standalone.recovery_enabled = 1
WHERE standalone.task_type = 'screenshot_collect';
-- statement-breakpoint
UPDATE tasks standalone
SET standalone.task_type = 'content_publish',
standalone.recovery_due_at = standalone.due_at,
standalone.recovery_instructions = '请提交发布后的笔记截图',
standalone.recovery_example_images = '[]',
standalone.recovery_enabled = 1
WHERE standalone.task_type = 'screenshot_collect';
-- statement-breakpoint
UPDATE contents SET related_content_id = '' WHERE related_content_id IS NOT NULL AND related_content_id != '';

View File

@@ -0,0 +1,13 @@
-- 校准任务领取计数:0020 物理合并删除了伴生回收任务的分发/领取行、并搬运了
-- 部分回收到父任务分发行,但没有同步父任务 claimed_quantity,任务卡「已领取」
-- 会与实际分发行数漂移。领取(+1)/释放(-1) 路径共同维护的不变式是:
-- content_publish 任务的 claimed_quantity = 当前分发行数,此处按此对账。
UPDATE tasks
SET claimed_quantity = (
SELECT COUNT(*) FROM distributions d
WHERE d.task_id = tasks.id
)
WHERE claimed_quantity <> (
SELECT COUNT(*) FROM distributions d
WHERE d.task_id = tasks.id
);

View File

@@ -0,0 +1,2 @@
-- 公开数据采集失败兜底:KOC 补交数据截图,运营核对后补录
ALTER TABLE distributions ADD COLUMN public_metrics_screenshot_key TEXT;

View File

@@ -0,0 +1,5 @@
-- 资源池 KOC 默认加入合作方:released_account_id 记录运营显式解绑/被顶替的资源账号,
-- 自动同步 ensurePartnersForPoolAccounts 不再为这些账号回填默认合作方
ALTER TABLE `partners` ADD COLUMN `released_account_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER account_id;
-- statement-breakpoint
ALTER TABLE `partners` ADD INDEX `partners_released_account_idx` (`released_account_id`);

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

Some files were not shown because too many files have changed in this diff Show More