Files
live-hub-py/docs/自动化与资料收集规划.md
T
yml2213 98a9f0e0bc chore(merge): 账号事件迁移改号 0041,并去重排查报告
dev 与 main 从 294c6cd 分叉后,各自新增了一个 0040 迁移(都从 20261002_0039 起链):
- main:20261003_0040 douyu_tasks.account_assigned_to(生产已应用)
- dev: 20261002_0040 account_events(未上生产)

两者合并即两个 alembic head,upgrade head 会直接失败。因为账号事件迁移从未被生产库
应用过,改号是安全的:

1. account_events 迁移改名为 20261003_0041,down_revision 指向 20261003_0040;
2. tests/test_migrations.py 的 HEAD_REVISION 同步改 20261003_0041;
3. 迁移保持幂等,本机开发库(alembic_version 已是 20261003_0040、account_events 表已存在)
   实跑 upgrade 通过,仅推进版本号、数据未动;
4. 顺带修正规划/解耦设计里「下一个迁移编号」的过期取值(现为 20261003_0042)。

排查报告去重:两个会话各自写了一份《生产服务器高负载排查…》报告,内容重叠且互相矛盾
(一处写 8 核/16GB,另一处有 nproc 原始输出为 4 vCPU/7.1GiB)。合并为单一文档
docs/生产服务器高负载排查与优化报告.md,以带原始命令输出的事实为准,删除重复的那份,
并修正引用它的迁移方案文档。
2026-10-04 13:20:36 +08:00

1461 lines
108 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自动化与资料收集规划
更新时间:2026-10-02
状态:**P1a(CK 巡检)+ P1b(CK 自动重登)已上线**;**P0 适配层已完成并真机验证(无接口/页面、开关仍关、生产未跑迁移)**;下一步 P2。产品决策已确认(见 §4),施工单见附录 C。
> ### 🔜 续作入口(下一个会话先看这里)
>
> 1. **当前该做 P2(资料收集链接 + OSS 上传)**,施工顺序见 **§C.5**,下一个迁移编号 **`20261003_0042`**;
> 2. P0 的适配层已就绪(`services/external_order_source.py`,`lookup_order()` 直接可用),
> P2 做「用户填订单号后回显商品」时**直接调它**,不要重写跨库 SQL;
> 3. P0 遗留三件事(要真正启用时才做):生产跑迁移 0039 建表、`.env` 开 `ORDER_SITE_LOOKUP_ENABLED`、补查询接口/页面(见 §C.4-bis);
> 4. 上手前必读 **§C.2 通用约定**(方言、时区、环境变量三处同步、迁移编号取号规则、
> 「动手前先核实外部库真实表结构」)与 **§C.3 已核实的环境事实**。
---
## 1. 背景
当前后台的日常动作基本都要人工点:判断 CK 是否失效、失效后重登、和用户来回沟通收集资料、手工记录兑换过的皮肤。人工环节一多就会出现三个问题:
1. **CK 维护不及时** —— 账号放着不管,等到客服要跑任务时才发现 CK 失效;临时重登又可能赶上邮箱验证码配额被打满;
2. **信息靠人转述** —— 用户在聊天里发订单号、游戏名、主页截图,客服再抄进系统,容易抄错、抄漏、无法追溯;
3. **兑换历史无台账** —— 谁在什么时候兑过什么皮肤只看聊天记录,无法执行「一年内同一件皮肤只兑一次」的规则。
目标是把这几块沉淀成系统能力,最终拼成一条半自动流水线:**客服只做「发链接」和「点执行」两件事**。
> 现状是**全人工**,因此本规划的基本原则是「逐步减少人工」,不是一步到位全自动(见 §5.1 第 5 条)。
## 2. 术语表
| 术语 | 含义 |
| --- | --- |
| **订单号 / order_no** | 用户在快手(或拼多多)下单后拿到的平台订单号,如 `2627501841217849` |
| **订单快照** | 从 order_site 读到的订单信息(platform / sku_code / sku_name / order_status)在本地留档,防止外部数据变动导致无法追溯 |
| **route** | 订单去向判定(只看 order_site 的客观事实):`unhandled`(既无履约任务也无接单平台工单,**没人管 → 本系统候选**)/ `auto_fulfilled`(有履约任务,发货平台已处理)/ `worker_platform`(有接单平台工单,打手已处理) |
| **皮肤 / skin** | 游戏内可兑换的外观类商品(套装 / 头盔 / 背包 / 枪皮等),是本系统实际要兑换的东西 |
| **skin_key** | 皮肤**标准名**,365 天规则的唯一判定键(由 C 桶皮肤类的 923 个原始商品名归一而来,具体收敛数待分析) |
| **别名** | 同一件商品的各种写法:快手 `relSkuId` / `relItemId` / `skuNick`、斗鱼商品名 |
| **skuNick** | 快手 SKU 规格名 —— **买家真正买的那件东西**(比店铺标题 `itemTitle` 精确,§3.6) |
| **relItemId / relSkuId** | 快手商品 ID / SKU ID,`(itemId, skuId)` 是单个订单的精确定位键 |
| **工单 / exchange_order** | 一笔兑换任务的载体:订单快照 + 用户资料 + 执行账号 + 状态 + 结果 |
| **资料模板** | 配置「要用户补哪些字段」,本系统固定为 游戏编号 / 游戏昵称 / 主页截图 |
| **兑换规则** | 配置「什么订单 → 对应哪个皮肤 → 用哪个斗鱼商品兑换」 |
| **台账** | `account_exchange_records`:账号 × 皮肤 × 时间的兑换历史 |
| **CK** | 斗鱼账号的登录 Cookie;本系统用「CK 有效/失效」描述账号状态 |
| **attempt** | 一次登录/重登尝试,落在 `login_tasks.attempt_*` 字段上,与账号持有的 CK 分离 |
## 3. 现状盘点
### 3.1 可直接复用的能力
| 能力 | 现状 | 位置 |
| --- | --- | --- |
| CK 有效性判定 | `check_douyu_cookie()` 双接口(鱼丸 + 等级)同时通过才算有效 | `web/backend/services/cookie_check_service.py` |
| 检测结果持久化 | `login_tasks.ck_check_status / ck_check_result / ck_checked_at` | 已落库 |
| 批量检测 | `POST /api/cookies/check`、`/api/cookies/operations/check` | `web/backend/routers/cookies.py` |
| 批量重登无效 CK | `POST /api/cookies/operations/relogin-invalid` | 同上 |
| 重登执行器 | `LoginBatchRunner(mode="relogin")`:成功才替换 CK、`attempt_*` 状态机、可停止、重启会收敛 | `web/backend/services/login_service.py` |
| 邮箱配额信号 | `EmailQuotaError`(斗鱼返回「发送次数已达上限」时抛出,**只影响该账号**) | `core/douyu/login.py` |
| 商品/皮肤快照 | `douyu_goods_snapshot`(名称、积分、库存、raw)—— **实测只有 78 个可兑换商品**(皮肤类 60),这就是我们能兑的全部(§3.5) | 已落库 |
| 斗鱼侧兑换记录 | `client.exchange_records(manual_id)` + `query_exchange_records` 任务 | `core/douyu/activity_client.py:950` |
| 任务编排 | `douyu_tasks`(批次/账号/类型/结果/时间)+ `DouyuBatchRunner` | 已成型 |
| 权限与审计 | `PERMISSIONS` / `ROLE_PERMISSIONS` + `audit_logs` | 已成型 |
| 部署形态 | 单容器(应用 + worker);Caddy 反代 `live.khhao.com → douyu-login:8800`;`./data`、`./logs` 持久化 | `docker-compose.yml` |
### 3.2 缺口
1. **没有任何定时/调度机制** —— 全项目只有 HTTP 触发 + 进程内线程,没有 scheduler;
2. **没有文件上传与存储** —— `UploadFile` 全项目未使用,也没有对象存储依赖(`oss2` 未安装),还没有公开(无鉴权)页面;
3. **没有兑换台账** —— 兑换历史散落在 `douyu_tasks.result` 里,无法按「账号 × 皮肤 × 时间」查询;
4. **没有订单概念** —— 用户手里的「订单号 → 商品/皮肤」不在本系统,需与 order_site 整合(见 §3.3);
5. **`POST /api/login/batch` 没有审计记录** —— 自动化要可追溯,需补上。
### 3.3 外部依赖:order_site(快手自动发货系统)
项目:`/Users/yml/codes/order_site`、仓库 `gitea.hao858.com/yml/order_site.git`,与本系统**同一台服务器**部署(`order_site-web-1` / `order_site-backend-1` / `order_site-postgres-1`,Caddy 反代 `worker.khhao.com`)。
**它已经持有「订单号 → 商品/皮肤」这层数据**(2026-10-02 线上 PostgreSQL 实测):
| 事实 | 数据 |
| --- | --- |
| 订单量 | 271,032 单(快手 268,436 / 拼多多 2,596) |
| 订单与商品明细 | **1 单 = 1 件商品**(271,024 单为 1:1) |
| 关键字段 | `orders.platform_order_id`(订单号)+ `order_items.sku_code` / `sku_name` |
| 实例 | `2627501841217849` → `suit_shadow_gothic` / `套装-暗影哥特` |
| 商品名种类 | distinct `sku_name` **1,514** 个;distinct `sku_code` **4,241** 个 |
| 语义化皮肤码 | 非纯数字 code 共 **173** 个(`suit_*` / `helmet_*` / `bag_*` / 中文名) |
**两条关键结论(直接决定本规划的数据模型)**
1. **统计归类要看名称,单订单定位要看 SKU**:同一个商品名(如「指挥官密钥5个」)会对应 30~45 个不同数字 `sku_code`(不同店铺/不同时期的 item 各挂一个),所以**按名称做归类统计**;但单个订单的 `(relItemId, relSkuId)` 精确指向一件具体商品(详见 §3.6);
2. **同一件皮肤有多种写法**:实测「套装-浪漫天命」至少三种形态——
`sku_code=套装-浪漫天命 / sku_name=套装-浪漫天命`、`sku_code=suit_romantic_destiny / sku_name=套装-浪漫天命`、`sku_code=套装-浪漫天命 / sku_name=浪漫天命`。
按名称当 key,365 天规则会失效(同一件被当成三件)—— 所以判定键最终落在**斗鱼商品**上(§3.5)。
**它已有的、与本规划同形但语义不同的能力**
| order_site 能力 | 说明 | 与本规划的关系 |
| --- | --- | --- |
| `/api/collect/lookup · submit · upload` | 凭**订单号**查待完善单 → 动态字段 → 上传截图 → 提交 | 面向**打手工单**(`work_orders`),字段由 `fulfillment_profile_requirements` 决定,语义不同;**实现模式与资料模板结构可直接借鉴** |
| `file_assets` + `/api/v1/files/object` | **阿里云 OSS(私有桶 `order-site-assets`)**,`sharp` 生成 thumb(480)/medium(1280) 变体;DB 只存 `object_key`,图片通过**自己的后端代理**读取(带 ETag/304) | **P2 的图片存储照这个模式做**(§8.3) |
| `claim_tokens` + `/api/claims/:token` | 用户领取页令牌(有效期 / 次数) | P2 的 token 设计可对齐 |
| `POST /api/open91/orders/query` | 面向 91 卡券的**签名鉴权**订单查询 | 证明跨系统调用可行,但返回的是发货状态,不含 sku |
| 限流中间件 `createRateLimitMiddleware` | 公开接口按字段/参数限流 | P2 可参考阈值设计 |
| 接单平台的规则/模板/状态机 | 见 §10.0 | **P4 的整体设计蓝本** |
**当前没有**:面向内部系统的通用 API Key 鉴权、按订单号返回商品明细的内部接口。(`/api/admin/orders` 需要后台管理员 session。)
### 3.4 order_site 的订单路由:决定「哪些订单需要来这边」
实测(2026-10-02):order_site 把订单**二选一**路由,`fulfillment_tasks` 与 `work_orders` **完全不重叠**(both = 0,共 271,134 单):
| 桶 | 判据 | 规模 | 典型商品 | 来 live-hub-py? |
| --- | --- | --- | --- | --- |
| A 已自动发货 | 有 `fulfillment_tasks` | 85,338 | 套装-浪漫天命 / 套装-暗影哥特 / 国宝胖达头盔 / 萌熊伴侣背包 | ❌ **不要**(发货平台已处理) |
| B 接单平台 | 有 `work_orders`(打手众包) | 96,755 | 指挥官密钥 / 荣耀专属礼盒 / 苏尔南钻石 / 暮色之下礼盒 | ❌ 不要(打手已处理) |
| **C 无人处理** | 两张表都没有 | **89,542** | **兔兔白日梦 / 狂战萌兔 / 青涩年华** / 黑色ROCK长袖 / 幸运币2个 | ✅ **要(本系统的目标)** |
**为什么 A 桶不要**(`fulfillment_tasks.delivery_status` 实测):
| 通道 | delivered | cancelled | closed | pending |
| --- | --- | --- | --- | --- |
| affiliate_dash | 29,713 | 1,044 | 433 | 569 |
| kuaishou_ct_assisted | 39,697 | 3,154 | 2,452 | 1,591 |
| kuaishou_feifei | 6,216 | 507 | 113 | 171 |
| **合计** | **75,626(88.3%)** | 4,705 | 2,998 | 2,331 |
**判据方向:A、B 都是「已经有人管」的订单(发货平台 / 打手),只有 C 是「没人管」——才轮到我们。**
(这一版推翻了本规划早期把 A 当成目标的判断:A 桶 88.3% 已 delivered,是发货平台的活。)
**C 桶内部分类**(1,227 个商品名 / 89,542 件,按商品名关键词粗分):
| 子类 | 件数 | 名字数 | 典型商品 | 处理 |
| --- | --- | --- | --- | --- |
| ① 服装类 | 17,704 | 19 | 黑色ROCK长袖+10幸运币 / 蓝色运动服上衣-黑色ROCK(2选1) | ⏸ **后面单独处理**(负责人已定) |
| ② 卡券/道具类 | 17,948 | 202 | 幸运币2个 / 9月改名卡 / 指挥官密钥 / 礼盒 | ❓ 待确认(疑似走电子凭证自动发码) |
| ③ 纯数字商品名 | 4,491 | 83 | 100089 / 99999 / 100033 | ❓ 待确认(需人工识别是什么) |
| ④ **皮肤/套装类** | **49,400** | **923** | 兔兔白日梦 / 狂战萌兔 / 青涩年华 / 风蚀行者 / 猫布灵 / 雪山精英 / AUG-魔性柴柴 / 浪漫波比 / 暮夜私语 | ✅ **现在做** |
> ④ 里混着大量 SEO 堆词的商品名(如「和平精英皮肤小店入口浪漫天命黑色上衣暗影哥特雪国小丑木乃伊」「和平精英熔岩游骑兵胖达团团圆圆萌熊伴侣背包雪山精英兔兔白日梦」),**923 个原始名归一后应明显收敛**,具体收敛到多少要先跑一遍归一分析(见 §9.2)。
**准入判据(两层,缺一不可)**
| 层 | 判据 | 回答的问题 |
| --- | --- | --- |
| 第一层:外部事实 | `fulfillment_tasks` 与 `work_orders` **都不存在**(`route = unhandled`) | 这单是不是**没人管**? |
| 第二层:我们的配置 | 商品能匹配到「皮肤兑换规则」(§10.1) | 这单的皮肤我们**做不做**? |
两层串起来正好把「P0 的 route 判定」和「P4 的规则匹配」接上:route 决定候选范围,规则匹配决定最终放行。
> **分阶段落地**:P2(收集链接)上线时 P4 的规则匹配还没有,此时只能做第一层(`route=unhandled` 放行 + 客服受理时人工把关);P3/P4 上线后再加第二层收窄。
### 3.5 我们实际能兑什么:斗鱼商品目录才是真正的边界
`douyu_goods_snapshot` 实测(2026-10-02):**只有 78 个可兑换商品**,全部来自手册 `20260120QYOOB`(精英宝典)—— **皮肤类 60 个**、非皮肤 18 个(体验卡 / 改名卡 / 幸运币礼包 / 精英火箭等)。
把 C 桶皮肤类那 923 个快手商品名往这 60 个皮肤类商品上映射:
| 类别 | 快手名 | 件数 | 占比 | 处理方式 |
| --- | --- | --- | --- | --- |
| ✅ **唯一命中**斗鱼商品 | 62 | 13,678 | 27.7% | 订单可自动确定皮肤 → 直接建单 |
| ⚠️ **命中多个** | 14 | 6,122 | 12.4% | 必须让用户在链接里**选一件** |
| ❌ **斗鱼兑不了** | 847 | 29,612 | 59.9% | 拒绝(斗鱼没有对应商品) |
**结论:斗鱼现有商品只能覆盖 40.1% 的 C 桶皮肤订单。** 剩下近 6 成(青涩年华、风蚀行者、雪山精英、AUG-魔性柴柴、浪漫波比、暮夜私语、顽皮橙 …)斗鱼根本没有对应商品,**做不了**。
> **锚点必须是斗鱼商品,不是快手商品名。** 快手那 923 个名字最终都要落到这几十个商品上:映射得上 = 能做,映射不上 = 做不了。
> 完整商品清单、可映射的 76 个快手名、兑不了的 Top 40 见 **[皮肤准入映射分析](皮肤准入映射分析.md)**。
### 3.6 SKU 才是精确定位键(匹配层按这个来)
`order_items.spec_json` **已经带了定位所需的全部字段**,不需要解析商品名文本:
```jsonc
{
"skuId": "192758405127439", // 快手 SKU ID
"skuNick": "(3选1)甜心娇蕊-清雅山茶-紫梦罗兰", // SKU 规格名(比 itemTitle 精确)
"sellerId": "4661340439",
"relItemId": "25228845231439", // 快手商品 ID
"relSkuId": "",
"itemTitle": "和平精英皮肤小店入口浪漫天命黑色上衣暗影哥特雪国小丑木乃伊", // 店铺商品标题(SEO 堆词,不可靠)
"source": "kuaishou_send_code",
"paymentFen": 10300
}
```
**一个 item(皮肤小店)挂多个 SKU,每个 SKU 就是一件具体商品** —— 实测同一 `relItemId=25228845231439` 下:
| skuId | skuNick |
| --- | --- |
| `187748927889439` | 兔兔白日梦 |
| `187748927890439` | 狂战萌兔 |
| `192651207500439` | 黑色ROCK长袖+10幸运币 |
| `193618786956439` | 冥河战士大礼包 |
| `177839563418439` | 青涩年华 |
| `191455593788439` | 风蚀行者 |
所以:**`itemTitle` 是店铺标题(堆词,没用),`skuNick` / `skuId` 才是买家真正买的那件东西。**
实测 4,092 个数字 `skuId` 中 89% 只对应一个 `skuNick`;另外 11% 是跨 item 复用,用 `(relItemId, relSkuId)` 组合即可区分。
**接单平台就是按 SKU 定位的**(`work_product_rule_mappings` 实测):
| mapping_type | 定位字段 | 真实例子 |
| --- | --- | --- |
| `sku_exact` | `rel_sku_id`(单个或多个快手 SKU ID) | `191555220780642` → `小金人+金AUG+金M338`;`191555220783642` → `红雪隼AA12` |
| `sku_exact` | `rel_item_id`(多个 item ID)+ `sku_nick` | rule 237:11 个 item ID + `指挥官密钥` |
| `sku_series` | `sku_nick` 前缀 | `指挥官` / `碎梦浮光珍宝礼盒` / `突击兵` |
| `product_default` | `rel_item_id` 或 `item_title` | 兜底 |
> **本系统的映射层照抄这个结构**(§10.1):精确 SKU 映射覆盖高频商品,文本归一只做兜底。
> 顺带修正本规划早期的一个错误结论:不是「数字 skuId 不能用」,而是「跨 item 会重复,所以要用 `(itemId, skuId)`」。
## 4. 产品决策(已确认)
| # | 决策 |
| --- | --- |
| 1 | 邮箱验证码配额打满**只针对单个账号**:跳过该账号、按天退避,**不暂停整个批次** |
| 2 | CK 巡检频率默认 **6 小时**,可在后台配置 |
| 3 | 收集链接**不做复杂进度回执**;兑换成功后用户通过同一个链接能看到结果 |
| 4 | 同一皮肤判定用**滚动 365 天**;判定键是**斗鱼商品**(`skin_catalog.commodity_id`),不是快手商品名 —— 因为一个斗鱼商品对应多个快手写法(§3.5) |
| 5 | 用户填的订单号只做基础处理:**非空 + 唯一 + 去除空白字符** |
| 6 | 命中 365 天规则时允许**强制兑换**,但**仅超管**且**必填原因**(写审计) |
| 7 | 收集提交的可见范围:**归属客服 + 运营/超管** |
| 8 | 资料模板字段固定为 **游戏编号 + 游戏昵称 + 主页截图**;**皮肤优先由订单号自动对应**;仅当订单命中多个斗鱼商品时(「3选1」「小店自选」类,占 12.4%),才让用户在链接里**选一件** |
| 9 | 兑换**不涉及「加好友后赠送」**,状态机不需要"等待可赠送"等待态 |
| 10 | 订单准入**分两层**:① 订单在 order_site 里**既无履约任务、也无接单平台工单**(`route=unhandled`,即「没人管」)才进入候选;② 候选订单的商品必须**命中皮肤兑换规则**才放行。其余一律提示「该订单暂不支持,请联系客服」,客服可在后台人工放行(留审计) |
| 11a | **重登失败按原因分型**(`relogin_failure_kind`),不是笼统「失败」:极验/凭证问题转人工,取码/中断/网络自动重试,配额满退避到次日(详见 §7.4.3) |
| 11 | **准入白名单以斗鱼商品目录为准**:能映射到 `douyu_goods_snapshot` 的才放行;映射不到(占 C 桶皮肤订单 59.9%)直接拒绝 —— 因为斗鱼兑不出来。**不匹配就不建单**,后续按实际数据逐步放宽 |
| 12 | **匹配优先按 SKU 精确定位**(`relSkuId` / `skuNick`),商品名文本只作兜底(§3.6)—— 接单平台已是这个做法 |
## 5. 总体架构决策
### 5.1 五条原则
1. **调度器放进程内,状态全部落库**
新增一个 60s tick 的 daemon 线程;作业定义、开关、间隔、上次/下次运行时间、每轮结果都写库(`automation_jobs` / `automation_runs`)。进程重启不丢状态,也不会因为重启而重复跑一轮。
2. **自动化只做「决策 + 排队」,执行仍走现有批次**
复用 `LoginBatchRunner` / `DouyuBatchRunner`,不另写执行器。这样人工和自动共用同一条安全链路(`attempt_*` 状态机、停止信号、启动清理、CK 只在成功时替换)。
3. **公开入口单独一条链路**
收集链接是全新的无鉴权入口,按「最小暴露 + 强限流 + 图片二次编码」设计,不与内部接口共用鉴权中间件。
4. **单容器前提**
不做分布式锁;用「数据库里的在途状态 + 进程内作业锁」避免重入即可。将来若多副本部署,再引入 DB 级 advisory lock。
5. **渐进上线:每一步都保留人工入口,自动化只是"少点几下"**
现状是**全人工**,目标是逐步减少人工,所以每条自动化都必须:
- 有独立开关,**默认关闭**,观察一段时间再打开;
- 人工入口原样保留(人工检测 / 重登 / 建单 / 执行都还能走);
- 出问题能回退到人工,**不做不可回退的自动执行**(尤其涉及花钱的支付环节,永远人工扫码确认);
- 先做「只读 / 只提示」,确认数据无误后再做「自动动作」。
### 5.2 与 order_site 的整合方式
**结论:只整合「订单号 → 商品/皮肤」这一段;收集链接在 live-hub-py 自建。**
理由:P4 的工单、P3 的台账、兑换执行全在 live-hub-py,让用户提交的数据与工单同库,链路最短;order_site 的 collect 是打手侧语义,硬套会把两套业务搅在一起。
**原则:order_site 一行代码都不改,也不改它的部署**,把「读订单」的能力放在 live-hub-py 一侧。
#### 方案对比(核心诉求:改需求时只改一处)
| 方案 | 要改 order_site? | 契约 | 契约稳定性(实测) | 运行时依赖 | 改需求时改几处 |
| --- | --- | --- | --- | --- | --- |
| A. 在 order_site 加内部 HTTP 接口 | **要**(写接口 + 鉴权 + 发版) | 我们设计的 HTTP 字段 | 中:接口由我们定,但每次调整都要对方配合发版 | 强 | **2 处** ❌ 已否决 |
| **B. live-hub-py 只读直连 PG** ✅ 采用 | **不要**(仅一次性建只读账号) | `orders` / `order_items` / `fulfillment_tasks` / `work_orders` 的少数几列(只判存在性 + 取 sku) | **极高**:88 个迁移里 `orders`/`order_items` **从未 ALTER** | 强(有缓存与降级开关) | **1 处** |
| C. 定时同步进本地表 | 不要(读法同 B) | 同 B + 同步器 | 高 | 弱(本地有副本) | 1 处(多一个同步作业,可复用 P1 调度器) |
| D. 完全不整合,用户从斗鱼商品下拉选皮肤 | 不要 | 无 | — | 无 | 1 处 ❌ 已否决(决策 8:皮肤由订单决定) |
**A 之所以「改两处」,不是因为要读数据,而是因为把读接口的实现放进了对方系统。** B/C 把读的能力收回自己这边,order_site 就彻底静止了。
#### 实测支撑(2026-10-02)
- **网络已通,不需要动 order_site 的部署**:`live-hub-py-douyu-login-1` 同时挂在 `live-hub-py_default` 与 **`order_site_default`** 两个网络上,实测可直连 `order_site-postgres-1:5432`(172.19.0.2)。这是 Caddy 反代时加的,属于既有事实;
- **表结构极稳**:order_site 共 88 个迁移,`orders` / `order_items` **自 001 基线以来从未被 ALTER**;我们只需要 `platform_order_id`、`sku_code`、`sku_name`、`order_status`(外加 `platform`),全部是外部平台透传字段;
- **查询走索引**:`idx_orders_platform_order_id btree (platform_order_id)` 存在,单按订单号查也能命中(另有 `(provider, platform, platform_order_id)` 复合索引;线上 provider/platform 只有 `91kaquan/kuaishou` 与 `pdd/pdd`)。
> 具体实现见 §6 P0。
## 6. P0 · 订单读取适配层
### 6.1 目标
让本系统能「凭订单号知道这是什么订单、要不要我们处理、对应哪件皮肤」,同时对 order_site **零代码改动、零部署改动**,且出问题时能一键降级。
### 6.2 数据模型(迁移 1 个)
**`external_order_cache`(订单快照)**:`platform_order_id` + `source_order_id`(联合唯一;对应 order_site `orders.platform_order_id` 与 `orders.id`)/ `platform` / `provider` / `shop_id` / `order_status` / `sku_code` / `sku_name` / **`seller_id` / `rel_item_id` / `rel_sku_id` / `sku_nick` / `item_title`** / `quantity` / `route` / `payload`(JSON) / `fetched_at`
> **⚠️ 2026-10-02 修订**:order_site 没有 `order_no` 列,主键改为 `platform_order_id` + `source_order_id`。
> 后者不可省 —— 同一个 `platform_order_id` 有 541 组重复(详见 §6.5.1),按单列存会互相覆盖。
>
> 用途:用户提交订单号时立即回显商品;工单与台账留档;order_site 不可用时降级。
> 缓存有效期建议 24 小时(订单状态可能变化,过期后重查)。
### 6.3 适配层接口
**所有跨库 SQL 只允许出现在一个文件里**:`web/backend/services/external_order_source.py`。将来表结构或数据源变化,只改这一个文件。
对外只暴露一个语义化方法:
```python
lookup_order(order_no) -> {
order_no, platform, provider, order_status,
sku_code, sku_name, route, # route ∈ {unhandled, auto_fulfilled, worker_platform}
seller_id, rel_item_id, rel_sku_id, # SKU 定位键(取自 spec_json,§3.6)
sku_nick, item_title, # SKU 规格名 / 店铺标题
cached: bool, error: str | None,
}
```
- `route` 由 §3.4 的互斥判据得出:有 `fulfillment_tasks` → `auto_fulfilled`;有 `work_orders` → `worker_platform`;都没有 → `unhandled`。**业务代码只认 `route`,不认 SQL 细节**;
> **⚠️ 按 §6.5.1 的决策 2,判定要在「订单号维度」聚合**:按 `platform_order_id` 取到全部行,
> 任一行有 `fulfillment_tasks` 即 `auto_fulfilled`(540 个已发货的重复组靠这条拦住)。
- 查询顺序:先查 `external_order_cache`(未过期)→ 未命中再查 PG → 回写缓存。
- 入参 `order_no` 实际就是 `platform_order_id`(对外仍叫 order_no,保持接口语义稳定)。
### 6.4 准入规则(用户填订单号时)
| `route` | 第一层处理 | 第二层(命中皮肤兑换规则?) |
| --- | --- | --- |
| `unhandled`(两张表都无) | ✅ **候选**,继续第二层 | 命中 → 放行建单;未命中 → 拒绝或人工归类 |
| `auto_fulfilled`(有 `fulfillment_tasks`) | ❌ 拒绝:「该订单已由发货平台处理,无需在此提交」 | —— |
| `worker_platform`(有 `work_orders`) | ❌ 拒绝:「该订单由接单平台处理,无需在此提交」 | —— |
| 查不到订单 | ❌ 拒绝:「订单号不存在,请核对」 | —— |
| order_site 不可用 | ⚠️ 允许提交但快照为空、标记 `order_unverified`,交客服人工确认 | —— |
> 第二层依赖 P4 的规则匹配;P2 阶段先只做第一层,客服受理时人工把关(见 §3.4 分阶段落地)。
### 6.5 只读保护(对对方生产库)
**✅ 已在生产落地(2026-10-02)**:
| 项 | 实际值 |
| --- | --- |
| 角色 | `livehub_ro`(`CONNECTION LIMIT 5`) |
| 授权 | `SELECT` on `orders` / `order_items` / `fulfillment_tasks` / `work_orders`(仅这 4 张) |
| 超时 | `statement_timeout = 3s`、`idle_in_transaction_session_timeout = 15s` |
| 写保护 | `default_transaction_read_only = on` —— **物理上写不了**(实测 `CREATE TABLE` 被拒) |
| 越权 | 实测查 `admin_users` → `permission denied` ✅ |
| 连接串 | `ORDER_SITE_DATABASE_URL` 已写入 `/root/live-hub-py/.env`(`ORDER_SITE_LOOKUP_ENABLED=false`,默认关) |
应用侧仍需遵守:只走单行索引查询、禁止全表扫描与聚合;**独立连接池且极小(2 条)**,不影响本系统主库连接池。
> **⚠️ 实现前提:应用容器里没有任何 PostgreSQL 驱动**(`psycopg` / `psycopg2` / `asyncpg` / `pg8000` 全未安装,只有 SQLAlchemy 本体)。
> P0 必须往 `pyproject.toml` 加一个驱动,三选一:
> - ✅ **`psycopg[binary]`(已定)**,SQLAlchemy 2.0 原生支持,URL 用 `postgresql+psycopg://`
> - (备选:`psycopg2-binary` / `pg8000` 纯 Python)
>
> 适配层要把 `.env` 里的 `postgresql://` 归一成带驱动的 URL(如 `postgresql+psycopg://`)后再交给 SQLAlchemy。
### 6.5.1 ⚠️ 实测偏差:没有 `order_no`,且同一订单号跨桶重复(2026-10-02 上机核实)
在生产机(`order+douyu_huya`)用只读账号 `livehub_ro` 实测,§6.2/§6.4 的两处假设与真实库不符。**P0 必须按本节实现**:
| 项 | 规划原文 | 实测 |
| --- | --- | --- |
| 订单号列 | `order_no`(PK) | ❌ **不存在**。只有 `id`(内部 bigint PK)与 `platform_order_id`(用户手上的快手单号) |
| `platform_order_id` 唯一性 | 隐含唯一 | ❌ **非唯一**:276,371 行 / 275,830 个号 → **541 个号重复** |
| 重复的形态 | —— | 541 组**全部**是 `n=2, shops=2`:`shop_id='91kaquan'` 行 + 一个真实店铺行 |
| 重复组的分桶 | —— | **540 组跨 A/C 桶**:`91kaquan` 行有 `fulfillment_tasks`(`delivery_status` = delivered/closed)→ A;真实店铺行无 A/B → C |
| 影响面 | C 桶 89,542 | C 桶实测 **91,029 行 / 91,028 个号**,其中 **540 个号的同一单已被 91kaquan 发货** |
**决策(已确认)**
1. **查询入口 = `platform_order_id`**(用户/客服填的快手订单号),走 `idx_orders_platform_order_id` 索引;`orders.id` 只作内部关联,不当对外入口;
2. **route 判定改为「订单号维度聚合」**:按 `platform_order_id` 取**全部行**,
任一行命中 `fulfillment_tasks` → `auto_fulfilled`;否则任一行命中 `work_orders` → `worker_platform`;都没有 → `unhandled`。
> 不这么做,这 540 个**已发货**订单会被误判为「没人管」而放行重复履约 —— 与 §3.4「A 桶不要」直接矛盾。
3. 回显的 SKU 取**真实店铺行**(`shop_id != '91kaquan'`,带 `sku_code` / `sku_name` / `spec_json`);
`91kaquan` 是发货方内部行(`provider=91kaquan`、`automation_mode=manual`、`sku_code` 形如 `suit_shadow_gothic`),不用于展示。
> 缓存表主键相应改为**订单号 + 店铺行**(见 §6.2 的 `order_no` → `platform_order_id`,并带 `source_order_id`),避免多行互相覆盖。
### 6.6 降级开关
`ORDER_SITE_LOOKUP_ENABLED=false` 时完全不查外部,退化成「客服手工填订单信息 + 人工确认」,主流程照常可用。
> 若将来想要「零运行时依赖」,把「按需查」升级为「定时增量同步」(§5.2 方案 C),同步作业直接挂到 P1 的调度器上,不引入新机制。
### 6.7 验收标准
1. 输入一个 A 桶订单号(有履约任务)→ `route=auto_fulfilled`,被正确拒绝;
2. 输入一个 B 桶订单号(有接单平台工单)→ `route=worker_platform`,被正确拒绝;
3. 输入一个 C 桶订单号(如「兔兔白日梦」的订单)→ `route=unhandled`,进入候选并带出 `sku_name`;
4. 输入不存在的订单号 → 明确提示「订单号不存在」;
5. 关掉 `ORDER_SITE_LOOKUP_ENABLED` → 系统仍可正常走手工流程;
6. 查一次订单对 order_site 的负载可忽略(单行索引查询,实测 < 50ms);
7. **输入一个「重复订单号」(`91kaquan` 行已发货 + 店铺行无人管,共 540 个)→ `route=auto_fulfilled`,不被放行**(§6.5.1)。
## 7. P1 · CK 自动管理
### 7.1 目标(分两步,不要一次上)
- **P1a(先做)**:把「哪些账号 CK 失效了」自动找出来并展示 —— **只检测、只标记、只提示,不改动任何 CK**;重登仍然人工点。
- **P1b(观察后再开)**:把「失效账号自动重登」也自动化 —— 有退避、有熔断、有开关,**默认关闭**。
> **P1a 已落地(2026-10-02)**,实际落点:
> `services/automation/scheduler.py`(调度线程 + 到点判定 + 运行记录)、
> `services/automation/jobs/ck_health.py`(巡检作业)、
> `routers/automation.py`(4 个接口)、`permissions.py`(`automation:view` / `automation:manage`)、
> `migrations/versions/20261002_0037_automation_jobs.py`、
> 前端 `pages/AutomationPage.tsx`(菜单「自动巡检」)、
> `tests/test_automation_ck_health.py`(22 条)。
> 端到端已用 3 个真实账号验证:巡检结果与直接检测逐条一致,且 `cookie` / `status` / `message` / `attempt_status` 全程未变。
**生产上线记录(2026-10-02)**
按三段走完,全部通过:
| 段 | 动作 | 结果 |
| --- | --- | --- |
| ① | `AUTOMATION_ENABLED=true` + 重启 | 容器内变量生效,日志「自动化调度器已启动,tick=300s」 |
| ② | 手动跑一轮(每轮 50 条,作业开关仍关) | `success`,50 条 / 7 秒,**斗鱼无风控反应**(无验证码、无频控、无超时) |
| ③ | 每轮 200 条 + 打开作业开关 | 调度器自动接管:12:27:26 → 12:27:48 跑完 200 条(22 秒),`next_run_at` 按 tick 推进到 +5 分钟 |
**上线过程中抓到(并已修)两个只有生产才会暴露的问题**,都已在 §C.2 记档:
1. `NULLS FIRST` 在 SQLite 合法、**MySQL 报 1064** → 改用 `col IS NULL DESC`,并加了一条按 MySQL 方言 `compile()` 的回归测试;
2. compose 的 `environment:` 是**显式列表**,只写 `.env` 不会传进容器 → `AUTOMATION_ENABLED` 起初根本没生效(顺带发现早先写入的 `ORDER_SITE_*` / `OSS_*` 也全都没传进去)。
**首轮数据结论(供客服侧预期管理)**:前几轮 100% `invalid` 属正常且正是价值所在 —— 选行是「从没检测过的优先」,而积压的 1,897 条未检测记录里大部分是 **2026-08-04 一次性批量导入、`attempt_status` 为空(从未尝试登录)**、CK 已放两个月的陈旧账号。错误信息统一是斗鱼侧的「用户未登录」,不是风控。按 200 条/5 分钟,约 5.4 小时扫完全库积压后进入稳态。
> **注意**:巡检会把这些陈旧账号的 `ck_check_status` 从空翻成 `invalid`,客服在「CK 检测与重登」页会突然看到大批失效账号 —— 这是预期行为,不是故障。
### 7.2 数据模型(迁移 1 个,P1a/P1b 共用)
**新表 `automation_jobs`**
| 字段 | 说明 |
| --- | --- |
| `key` (PK) | 作业标识:`ck_health` / `ck_relogin` |
| `title` | 展示名 |
| `enabled` | 开关(**默认 false**) |
| `interval_seconds` | **单账号重检间隔**(CK 巡检默认 6h)—— 注意不是作业运行周期,作业每个 tick 都跑(见 §7.3) |
| `batch_size` | 单轮处理上限(`ck_health` 默认 **200**,按「5 分钟一轮 × 200 条 = 40 条/分钟 ≥ 需要的 36 条/分钟」反推,见 §7.3;`ck_relogin` 默认 20) |
| `concurrency` | 单轮并发 |
| `config` (JSON) | 作业私有配置(退避表、每日上限、失败率阈值等) |
| `last_run_at` / `next_run_at` | 调度用 |
| `last_status` / `last_message` | 最近结果 |
| `updated_at` | |
**新表 `automation_runs`**:`id` / `job_key`(索引) / `started_at` / `finished_at` / `status` / `scanned` / `valid` / `invalid` / `relogin_started` / `relogin_success` / `skipped` / `message` / `detail`(JSON)
**`login_tasks` 增字段**:`relogin_attempts`(int, 默认 0) / `relogin_next_at`(datetime, 可空) / `relogin_last_error`(string)
**`accounts` 增字段**:`auto_managed`(bool, 默认 true) —— 给特殊账号留「不自动动」的后门
### 7.3 P1a · 作业一:`ck_health`(巡检,只检测)
- **选取范围**:从**持有 CK 的 `login_tasks` 行**里挑(即现有 `cookie_holding_filter()` 的口径:`cookie is not null and cookie != ''`)—— CK 是挂在登录记录上的,不是挂在 `accounts` 上;
- **调度**:**每 5 分钟跑一轮**(`TICK_SECONDS = 300`);每轮挑「距上次检测超过 `interval_seconds`(默认 6h)」的行,最多 `batch_size`(默认 200)个。
这样全库是**滚动覆盖**的,负载平稳,不会出现「整点扫 1.2 万个账号」的尖峰;
- **⚠️ 两个「间隔」不能混**:`TICK_SECONDS` 是**作业多久跑一轮**;`job.interval_seconds` 是**同一行数据多久重新检测一次**(只用于选行 cutoff)。
如果误把作业周期设成 `interval_seconds`,就变成「200 条/6 小时 = 800 条/天」,全库 12,881 条要 16 天才能过一轮 —— 等于没巡检(P1a 实现时踩过一次,已修);
- **排除**:`auto_managed = false`;`attempt_status in (pending, running)`(正在被批次处理);最近 10 分钟内刚检测过(防重入);
- **动作**:调用现有 `check_douyu_cookie()`(两个只读接口,直连),写回 `ck_check_status / ck_check_result / ck_checked_at`;
- **⚠️ 三类判定(关键防护)**:检测失败不等于 CK 失效,结果必须分三类处理,否则斗鱼接口抖动一小时就能把上千个好账号标成失效、让客服误判成「CK 大面积挂掉」:
| 判定 | 依据 | 处理 |
| --- | --- | --- |
| `valid` | 两个接口都通过 | 写 `ck_check_status='valid'` |
| `invalid` | 斗鱼**明确**回「未登录 / 登陆异常 / 请重新登陆」 | 写 `ck_check_status='invalid'` |
| `error` | 其余失败(`请求失败:` 超时、`响应异常`、未知结构、走异常兜底) | **不覆盖该行原结论**,只更新 `ck_check_result`(带 `verdict`)与 `ck_checked_at`,计入 `skipped` |
- **自动暂停**:一轮里 `error` 占比 > 30% 且条数 ≥ 20 时,作业把自己 `enabled` 置 false 并在 `message` 写明原因(`detail.paused=true`)—— 大面积「查不到」通常是接口抖动或被风控,继续跑只会污染更多账号;
> 注意:**真实死号占比高不触发暂停**。首轮 100% `invalid` 是合法且预期的情况(选行是「从没检测过的优先」,积压的多是两个月前批量导入、从未登录的陈旧账号)。
- **并发**:默认 5,沿用现有 `ThreadPoolExecutor` 模式;
- **不做**:不改 CK、不建重登批次、不动 `login_tasks.status`。
**容量测算(真实数据,2026-10-02 实测)**
| 指标 | 数值 |
| --- | --- |
| 持有 CK 的 `login_tasks` 行 | **12,881**(12,880 个账号;总账号 13,529) |
| 当前状态分布 | valid **10,079** / invalid **905** / **从未检测 1,897** |
| 历史检测时间 | 最早 2026-08-07,最新 2026-10-02(人工零星跑的) |
按「单账号 6 小时内重检一次」折算:
```
需要吞吐 = 12,881 ÷ 6h ≈ 36 行/分钟
一个 tick = 5 分钟 → 每轮需要 ≈ 180 行
单行耗时 ≈ 0.13s(实测 3 个账号 0.4s,并发 3)→ 180 行并发 5 约 5 秒/轮 ← 很轻松
```
**`batch_size` 默认 200**(比需要的 180 留一点余量):
- **冷启动**:全库积压 12,881 条,按 200 条/5 分钟 ≈ **5.4 小时**跑完第一遍,随后进入稳态;
- **稳态**:每分钟约 36 条到期,每轮扫到一百多条,负载很轻;
- 取行按 `ck_checked_at ASC`(最久没查的优先,`NULLS FIRST`),自然错峰,不会同一时刻全量到期。
### 7.4 P1b · 作业二:`ck_relogin`(自愈,默认关闭)
> **这一节 2026-10-02 按线上实测数据重写过。** 原来的设计只有「退避 + 熔断 + 开关」,
> 数据表明那样不够:**失败必须分型**,否则 64% 本可自动解决的失败会被当成人工活。
#### 7.4.1 为什么必须和 P1a 联动(数据)
巡检只产出「谁失效了」的清单,不兑现修复,1,000+ 个账号还是要人工逐个选集→点重登→过滑块。
线上实测(2026-10-02):
| 项 | 数值 | 依据 |
| --- | --- | --- |
| 当前失效 | **1,356**(还在涨,巡检在排空积压) | `ck_check_status='invalid'` |
| 其中「还没在 attempt_* 时代重试过」 | 941 | 纯新机会 |
| 其中「最近一次重登失败过」 | 415 | 已有失败记录 |
| 历史重登**真实成功率** | **≈78%** | 成功 1,767 ÷ (1,767 + 真实失败 508),已排除「中断/停止」这类非真失败 |
| 预计可自动救回 | **≈1,050** | 1,356 × 78% |
| 预计需人工 | **≈300** | 主要是极验滑块 |
**关键结论:失效是「会话级」而不是「凭证级」。** 斗鱼对这批账号回的是「用户未登录」,
不是「密码错误」;而这些行**本来都是登录成功的产物**(见下)。
> **「持有 CK 的行 = 真实登录成功过」的依据**:`LoginTask` 全项目**只在一处创建**
> —— `services/login_service.py`(登录成功写回 CK 处);**导入账号不建 LoginTask、
> 不写 cookie**(已 grep 核实)。因此 `cookie` 非空且 `status='success'` 的行,
> 只可能是某次真实登录成功留下的。那批 `attempt_status=''` 不是「从未登录」,
> 而是「登录成功发生在 `attempt_*` 字段存在之前」(字段是迁移 `20261001_0035` 才加的)。
#### 7.4.2 候选筛选
- 每 5 分钟 tick,取满足以下条件的行,最多 `batch_size`(默认 20)个:
`ck_check_status = 'invalid'` 且 `relogin_next_at <= now`(或为空)
且 `relogin_attempts < max_attempts_per_day`(默认 2)
且 `auto_managed = true` 且 `attempt_status` 不在途;
- **不再按「有没有成功过」筛**:持 CK 的行按定义都成功过,这个条件没有区分度;
真正有区分度的是「上一次为什么失败」(下一步的分型)。
#### 7.4.3 失败分型(本次新增的核心)
重登失败不能笼统地「再试一次」或「丢给人工」。按原因落 **`login_tasks.relogin_failure_kind`**
(枚举字段,新增迁移;**不要靠 `attempt_message` 做字符串匹配** —— 文案一改就失效,
且页面要按类型筛选),不同分型走不同后续:
| kind | 触发(线上实测条数) | 后续动作 |
| --- | --- | --- |
| `captcha` | 极验滑块失败(144 / 35%) | **转人工队列,不再自动重试**(反复撞只会加重风控) |
| `email_code` | 验证码错误 / 等待超时(144 / 35%) | **自动重试**(走退避表);这批多半正是 `20261001` 修掉的取码 bug 造成的 |
| `interrupted` | 服务重启 / 人工停止(121 / 29%) | **不是真失败** → 自动重试 |
| `email_quota` | 「发送次数已达上限」 | 退避到**次日零点 + 随机抖动 ±30 分钟**(决策 1:只影响该账号,批次继续) |
| `credential` | 密码错误 / 账号异常 | **转人工队列**(需核对密码或邮箱) |
| `network` | 请求失败 / 超时 / 连接异常 | 自动重试(退避) |
> 64% 的线上「失败」(`email_code` + `interrupted`)其实是可自动解决的;不分型就会把它们
> 一起推给人工,白费 265 个可救名额。
#### 7.4.4 退避与熔断
- **退避**:`relogin_attempts` 1→1h、2→3h、3→12h、4+→24h(写 `relogin_next_at`);
- **极验即停**:同一账号连续 2 次 `captcha` → 直接转人工,不再自动重试;
- **系统性故障刹车(与决策 1 不冲突)**:代理 API 不可用 / 单轮失败率超阈值(默认 80%)
/ 单轮异常数超阈值 → **自动暂停作业并告警**。这些不是单账号问题,继续跑只会浪费资源。
#### 7.4.5 速率与风控
| 约束 | 实测/建议 |
| --- | --- |
| 邮箱配额 | **13,618 个账号对应 13,618 个独立邮箱(1:1,实测)** → 单账号配额满不影响他人,决策 1 成立;也不存在「打爆共享邮箱」的问题 |
| IP 才是瓶颈 | 1,356 次登录 + 1,356 封验证码,短时间集中做会触发风控 → **走现有代理池 + 限速每小时 30~60 个**(约 1~2 天跑完,后台慢慢来完全可以接受) |
| 单轮上限 | `batch_size` 默认 20(5 分钟一轮 = 上限 240/小时,实际由退避与每日上限压住) |
#### 7.4.6 页面呈现(对应「失败单独标记提示」)
`/automation` 页在作业卡片之外增加两块:
- **自动重登中**:有退避倒计时、下次重试时间、当前 `relogin_attempts`;
- **待人工处理**:按 `relogin_failure_kind` 分组 ——
- 极验失败(要人工过滑块)
- 凭证问题(要核对密码/邮箱)
- 多次失败(要人工查账号可用性)
#### 7.4.7 上线方式(三段,同 P1a 的谨慎程度)
1. **小规模实测** ✅ **已完成(2026-10-02),结果远好于预期**:
| 项 | 结果 |
| --- | --- |
| 样本 | **20 个** invalid,两组各 10:A「还没重试过」/ B「取码 bug 时代失败过」 |
| 成功率 | **20 / 20 = 100%**(历史推断只有 78%) |
| 耗时 | 2 分 13 秒(≈ 9 个/分钟) |
| 极验滑块 | **0 次** |
| 邮箱取码 | 全部正常 → **`20261001` 的取码修复在生产验证通过**(B 组全过,证实那 144 个「验证码失败」就是旧 bug 造成的) |
| CK 替换 | 20 个全部换成新 CK;`ck_check_status` 由登录流程自动改回 `valid`(`login_service.py` 成功分支) |
**结论**:会话确实只是过期,重登几乎必定成功;可救量从估的 1,050 上调到
**≈1,350**(1,356 × 近 100%)。失败分型仍然要做,但属少数路径(极验/配额/凭证)。
实测 9 个/分钟且零极验,说明斗鱼当前风控容忍度比预想高,不过全量仍建议限速
(先按每小时 100~200 个开,观察极验率再决定是否加速);
2. **限量开作业**:按实测成功率定 `batch_size` 与日上限,先只跑一个分组;
3. **全量**:观察若干轮,确认分型与退避符合预期后再放开。
### 7.5 并发与互斥
- 同一账号不允许人工与自动同时操作:依据 `attempt_status` 在途 + 现有 `cookie_relogin_registry`;
- 作业级:进程内 `threading.Lock` 保证同一作业不会重叠执行;
- 批次级:`ck_relogin` 一轮只起一个 `LoginBatchRunner`,与人工批次并存但目标账号互不重叠。
### 7.6 接口与权限
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/automation/jobs` | 列出作业与配置、最近一次结果 |
| PATCH | `/api/automation/jobs/{key}` | 改开关 / 间隔 / 批量大小 / 并发 |
| POST | `/api/automation/jobs/{key}/run` | 立即跑一轮(手动触发) |
| GET | `/api/automation/runs` | 运行历史(分页、按作业过滤) |
权限点:`automation:view`(运营/超管)、`automation:manage`(仅超管,见 §11.1)。
### 7.7 前端
新页 `/automation`:作业卡片(开关 / 间隔 / 上次与下次运行 / 最近结果统计)、运行历史表格、手动「立即跑一次」。
Dashboard 增加一张「自动化」卡片(今日巡检数、无效数、自愈成功数、待重登数)。
### 7.8 验收标准
**P1a**
1. 人为让一批账号 CK 失效 → 一个巡检周期内被检出并标记 `ck_check_status='invalid'`;
2. 巡检过程中**没有任何 CK 被改动**(可对比巡检前后的 `login_tasks` 行);
3. 关闭作业开关后立即停止调度;
4. `/automation` 能看到每一轮的明细(扫描数 / 有效 / 无效 / 跳过)。
**P1b**
5. 开启自愈后,失效账号按退避表排队重登,不会每轮都撞同一个账号;
6. 命中邮箱配额上限时,只有该账号被跳过并退避到次日,其余账号正常完成;
7. 连续失败率超阈值时作业自动暂停并留下 `automation_runs` 记录。
### 7.9 顺带修
给 `POST /api/login/batch` 补审计记录(对齐 `cookie:relogin` 等已有动作),否则自动化与人工的登录来源无法追溯。
## 8. P2 · 资料收集链接
### 8.1 目标
客服把一条链接发给用户,用户填**订单号 + 游戏编号 + 游戏昵称 + 主页截图**,信息直接进系统;**皮肤由订单号自动对应**(决策 8)。
### 8.2 数据模型(迁移 1 个)
**`collect_links`**:`id` / `token`(唯一,32 字节随机 base64url) / `account_id`(可空) / `created_by` / `title` / `expires_at` / `max_submissions`(默认 1) / `submissions_count` / `status`(open/closed/expired) / `created_at` / `closed_at`
**`collect_submissions`**:`id` / `link_id` / `account_id` / `order_no`(归一化后) / `order_snapshot`(JSON,来自 §6) / **`fields`(JSON:游戏编号 / 游戏昵称)** / **`screenshots`(JSON:主页截图路径数组)** / `remark` / `status`(pending/processing/done/rejected) / `handled_by` / `handled_at` / `ip` / `user_agent` / `created_at`
> 用户只填这三样。**唯一命中的订单**皮肤由订单号自动带出,提交记录只存订单快照。
> **用户"选一件"的结果不放在这里**,而是放在工单的 `exchange_orders.chosen_commodity_id` 上(见 §10.3/§10.4)—— 提交记录是用户视角的原始凭证,不该被后续业务流程改写。
**截图存储**:**阿里云 OSS**(不落本地磁盘),DB 只存 `object_key`;读取走后端鉴权代理。详见 §8.3。
### 8.3 图片存储:OSS(不落本地磁盘)
**服务器磁盘有限,用户上传的图片一律进 OSS,本地只做内存处理(Pillow 从 bytes 读,不写临时文件)。**
参考 order_site 的成熟做法(实测):
| 环节 | order_site 的做法 | 我们照做 |
| --- | --- | --- |
| 桶 | 阿里云 OSS `order-site-assets`(**私有**,region `oss-cn-hangzhou`) | **`live-hub-assets`** —— ✅ **已建好**(私有 ACL + 版本控制已开,内网端点,实测 put/get/list/delete 全通) |
| 端点 | 上传走内网 `oss-cn-hangzhou-internal.aliyuncs.com` | 同(实测容器可达:`100.118.28.52`,无公网流量费) |
| 存什么 | DB 只存 `object_key`,**不存 URL** | 同(换域名/换桶不用改数据) |
| 怎么读 | 自己的后端代理 `/api/v1/files/object?key=...`,带 ETag / Last-Modified / 304 | `GET /api/collect/submissions/{id}/screenshots/{idx}`,鉴权 + 流式返回 + 304 |
| 变体 | `sharp` 生成 thumb(480) / medium(1280) | Pillow 生成缩略图(列表页用),原图保留 |
**落地要点**
1. **桶权限:私有**(不开公共读)—— ✅ 已实测:公共 URL 直取返回 **403**。既然读走后端代理,就不需要公网端点、公开 ACL 或签名 URL,攻击面最小;
- **凭据已就位**:独立 RAM 子账号 `live-hub-oss` + 策略 `live-hub-assets-rw`,实测跨桶访问被拒(最小授权);
- **实现注意**:`object_storage.py` 只用 `PutObject` / `GetObject` / `DeleteObject` / `ListObjectsV2`,**不要用 `HeadBucket`**(该策略下返回 403),健康检查改用 ListObjects 或 Put/Get 往返;
2. **object key 规范**:`collect/<yyyyMM>/<uuid>.<ext>`,缩略图 `collect/<yyyyMM>/<uuid>_thumb.<ext>`;
3. **上传前处理**:校验 magic bytes → 限制 ≤5MB / ≤3 张 → Pillow **重新编码**(剥 EXIF、防伪装)→ 生成缩略图 → 两张都传 OSS;
4. **鉴权读取**:读图接口挂登录依赖 + 数据范围过滤(决策 7:归属客服 + 运营/超管),不允许无鉴权直读;
5. **凭据**:建议新建 **RAM 子账号**,只授权这一个桶的 `PutObject` / `GetObject` / `DeleteObject`,不复用 order_site 的 AK;
6. **不落地**:`UploadFile` 读进内存即可,**不写 `./data`**,容器磁盘只用于日志;
7. **清理**:给桶配生命周期规则(如转低频/过期删除)由运维侧决定;本轮不做应用层删除。
> Python 侧用官方 `oss2`(需新增依赖,当前环境未安装);不用 boto3,省掉 S3 兼容层的坑。
### 8.4 订单号处理(决策 5 + P0 整合)
1. 归一化:去掉首尾空白 + 去掉所有空白字符(含全角空格 `\u3000`)→ 非空校验;
2. **查单**:走 P0 适配层拿 `sku_code / sku_name / order_status / route`,写入 `external_order_cache` 与提交记录的快照(准入规则见 §6.4);
3. **唯一性**:`order_no` 全库唯一(重复直接拒绝并提示「该订单号已提交过」)。不做格式校验。
> 假设:不同平台订单号不会撞号。若将来会撞,改为 `(order_no + platform)` 组合唯一。
### 8.5 接口
**公开(无鉴权,独立前缀 `/api/public/collect`)**
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/public/collect/{token}` | 链接状态、资料模板字段、已提交时的处理状态 |
| POST | `/api/public/collect/{token}` | multipart 提交(订单号 + 游戏编号 + 游戏昵称 + 主页截图) |
> **为什么单独开 `/api/public/` 前缀**:① 若公开接口用 `/api/collect/{token}`,会把同级的 `/api/collect/links` 当成 `token="links"` 吞掉(FastAPI 按声明顺序匹配参数化路由);② 无鉴权边界写在 URL 上,一眼能审出哪些路由不该挂登录依赖。
**内部(需登录,前缀 `/api/collect`)**
`POST /api/collect/links`(生成)、`GET /api/collect/links`、`POST /api/collect/links/{id}/close`、`GET /api/collect/submissions`(按范围过滤)、`GET /api/collect/submissions/{id}/screenshots/{idx}`(鉴权读图,见 §8.3)、`POST /api/collect/submissions/{id}/accept|reject`
> 现有代码**没有全局鉴权中间件**,鉴权是逐路由 `Depends(get_current_user)` —— 所以公开路由只要不挂依赖即可;新路由记得在 `web/backend/main.py` 里 `include_router`。
### 8.6 防护(公开入口必须做)
- token 32 字节随机、有效期默认 7 天、提交次数上限;
- 每 IP 限流(内存令牌桶,默认 10 次/分钟);
- 文件:仅 png/jpg/webp、单张 ≤ 5MB、最多 3 张、校验 magic bytes,并用 Pillow **重新编码**(剥离 EXIF、防伪装文件),随后直传 OSS(§8.3,不落本地盘);
- 公开接口只回显最少信息(不暴露账号密码、CK、内部备注);截图读取一律走后端鉴权接口,不给 OSS 公共读。
### 8.7 前端
- **公开页** `/collect/:token`:
- **P2 阶段**:订单号 → 显示**订单商品名**(来自订单快照,仅作核对,不做皮肤匹配——那时还没有别名表)→ 游戏编号 → 游戏昵称 → 主页截图 → 提交。
- **P4 阶段**:在 P2 基础上增加皮肤匹配显示 —— 唯一命中时展示「你买的是:套装-暗影哥特」让用户确认;`skin_choice`(多选)时展示候选列表让用户选一件(§10.4)。
已提交过则显示状态(**决策 3**:不做复杂回执,但兑换成功后用户打开同一链接能看到「已兑换 XX 皮肤 + 时间」)。
**链接在 `status=open` 期间可反复打开**(查看状态不算一次提交,不消耗 `max_submissions`)—— 用户没保存链接时,客服转发原链接即可;这是"用户能回来看到结果"的实现前提;
该路由必须放在 `App.tsx` 登录判断**之外**,SPA fallback 已支持;
- **内部页**:`/collect-links`(链接管理:生成 / 复制 / 关闭)、`/collect-submissions`(待处理队列 + 大图查看 + 受理 / 驳回)。
权限点:`collect:link`、`collect:handle`;可见范围按决策 7。
### 8.8 验收标准
1. 手机打开链接能完成填写与上传,提交后客服端队列立即出现;
2. 同一订单号二次提交被拒绝;
3. 超过有效期 / 超过提交次数 / 非图片文件 / 超大文件都被正确拒绝;
4. 上传的图片**只存在于 OSS**,容器与宿主机磁盘上找不到任何副本(可 `find` 验证);
5. 未登录访问读图接口返回 401/403;客服只能看到自己范围内账号的截图;
6. OSS 不可用时提交给出明确失败提示,不产生"半条记录";
7. 兑换成功后,用户用原链接能看到结果。
## 9. P3 · 账号皮肤台账
### 9.1 目标
知道「哪个账号、什么时间、兑换了什么皮肤」,并在兑换前拦住「365 天内重复兑换同一件皮肤」。
### 9.2 数据模型(迁移 1 个)
**别名归一(本阶段的核心):以斗鱼商品为锚点**
| 表 | 字段 | 说明 |
| --- | --- | --- |
| `skin_catalog` | `commodity_id`(PK,斗鱼商品 ID) / `manual_id` / `name` / `points` / `category`(suit/helmet/bag/weapon/pack/other/**non_skin**) / `active` / `updated_at` | **斗鱼商品目录是主表**。365 天规则的判定键就是它 |
| `skin_aliases` | (`source`, `raw_key`) 联合 PK / `commodity_id`(索引) / `confidence` / `updated_at`;`source ∈ {kuaishou_rel_sku_id, kuaishou_rel_item_id, kuaishou_sku_nick, manual}` | 快手 SKU / item / 规格名 → 斗鱼商品(**优先 SKU 精确,名称只兜底**,见 §3.6) |
| `skin_keywords` | `keyword`(PK) / `commodity_id`(索引) / `updated_at` | 归一层用的核心词(当前 30 个),用于在「小店自选 / 3选1 / 堆词标题」里识别候选商品 |
> 依据 §3.3 实测:`sku_code`(数字型,易变)、`sku_name`(中文,有 `套装-` 前后缀变体)、语义化 code(`suit_*`)都只是**别名**,必须归一到 `skin_key` 才能正确判「同一件」。
> **规模(已跑完分析,见 [皮肤准入映射分析](皮肤准入映射分析.md))**:不是梳理 923 或 615 个快手标准名,而是维护
> **60 个斗鱼商品 ← 76 个快手商品名**的映射 —— 工作量从「数百条」降到「几十条」,人工过一遍即可。
> 其中 62 个快手名唯一命中(订单可自动定皮肤)、14 个命中多个(需用户选)。
**与 `douyu_goods_snapshot` 的关系(同步策略)**
| 项 | 约定 |
| --- | --- |
| 同步方向 | `douyu_goods_snapshot` **→** `skin_catalog`(单向;snapshot 是抓取结果,catalog 是我们的可用目录) |
| 存多少 | **全部 78 行**都进 catalog(非皮肤 18 个标 `category=non_skin`、`active=false`);准入只看 `active=true` 的 |
| 何时同步 | **新增同步作业挂到 P1 的调度器上**(`sku_catalog_sync`,默认每天一次),不引入新机制;也可在 `/skins` 页手动触发 |
| 冲突处理 | `commodity_id` 已存在 → 只更新 `name`/`points`;**不覆盖人工维护的 `category`/`active`**;snapshot 里消失的商品 → 标 `active=false`,**不物理删除**(历史台账要能查到) |
**`account_exchange_records`(台账)**:`id` / `account_id`(索引) / `skin_key`(索引) / `goods_name` / `exchange_time` / `source`(our_task / douyu_api / manual / order) / `order_no`(订单号, 可空) / `sku_code` / `sku_name`(订单快照) / `task_id`(可空) / `role_name` / `remark` / `raw`(JSON) / `created_at`
唯一键:`(account_id, order_no)`(有订单号时)防重复导入。
### 9.3 四路数据源
1. **我方兑换任务成功时自动落账**(最准,带账号/时间/商品/任务/角色);斗鱼商品先经 `skin_douyu_items` 映射到 `skin_key`;
2. **斗鱼侧 `exchange_records` 回填**(复用 `query_exchange_records`,解析 `goods_name / buy_time / serial` 后 upsert);
3. **订单驱动落账**(P4 工单执行成功时,直接用订单快照里的 `sku_code/sku_name` 归一后落账);
4. **人工补录**(带截图,用于查不到的历史)。
### 9.4 规则引擎
`can_exchange(account_id, skin_key, within_days=365)`:
- 取该账号该 `skin_key` 最近一条 `exchange_time`;
- 若 `now - exchange_time < 365 天` → 阻止,并给出「上次兑换 2026-03-01,2027-03-01 后可再兑」;
- 超管可用 `force_reason` 强制通过(**决策 6**),写审计。
**归一流程**(订单号进来的皮肤):
`sku_code / sku_name` → 查 `skin_aliases` → 命中则用 `skin_key`;**未命中则挂起待人工归类**(工单进 `rule_unmatched`,见 §10.4;**不静默放行**,避免 365 天规则被绕过)。
接入点:
- 兑换**前**:工作台里选中账号 + 商品时校验并提示(前端禁止 + 后端接口二次校验);
- 兑换**后**:任务成功即写台账;
- 工单**受理时**:用订单快照里的 `skin_key` 再校验一次。
### 9.5 前端
新页 `/skins`:
- 皮肤视角:某皮肤被哪些账号兑换过、最近一笔时间、还剩多久可再兑;
- 账号视角:某账号的兑换时间线(含截图);
- 检索与导出;`skin:manage` 可维护别名映射。
权限点:`skin:view`、`skin:manage`。
## 10. P4 · 皮肤兑换工单(模板 + 匹配 + 状态机)
设计思路**整体对齐 order_site 接单平台**:同样是「订单进来 → 按规则模板匹配 → 命中才建单 → 用户补资料 → 执行 → 归档」,只是把「打手抢单」换成「客服执行」。
### 10.0 参考对象:接单平台的六层机制
| 层 | 组件 | 关键设计 |
| --- | --- | --- |
| ① 规则/模板 | `work_categories`、`work_product_rules`、`work_product_rule_mappings` | 规则带 `enabled` / `auto_create` / 来源约束 / **`requirement_json.fields`(资料模板)** / 报酬 / 超时策略 / 验收模式;映射分 `product_default` / `sku_exact` / `sku_series` 三种粒度 |
| ② 匹配 | `resolveMatchingProductRuleDecision()` | 打分 → 排序 → **只有唯一最高分才算命中**;并列判 `ambiguous` 绝不猜;匹配上下文取自订单快照;结果与候选写 `work_product_match_logs` |
| ③ 建单 | `syncWorkerOrdersForSourceOrder()` | 未命中 → 只写日志不建单;命中但 `auto_create=false` → 跳过;校验订单号跨订单唯一(防撞号);**初始状态由模板决定**:有必填 → `pending_material`,无必填 → `unassigned`;`material_json` 存来源订单快照;`requirement_json` 存模板快照 |
| ④ 状态机 | `WORK_ORDER_STATUS` | `pending_material` → `unassigned`/`open` → `in_progress` → `pending_acceptance` → `accepted`;旁路 `problem`/`cancelled`;配 `deadline_at` + `timeout_policy`;`acceptance_mode` 分 `standard`/`friend_gift` |
| ⑤ 补资料 | `/api/collect/lookup · submit · upload` | 凭订单号找 `pending_material` 工单;提交写 `material_json.collect = {fields, screenshots, submittedAt, complete}`;**状态回落**:`complete ? 下一态 : pending_material` |
| ⑥ 事件与实时 | `work_order_events` + realtime + 通知 | 每次状态变更写 `from_status → to_status + payload`;实时推送;建单时给管理端发通知 |
**模板的两种编辑方式**(直接抄):
```jsonc
// 结构化
"requirement_json": { "fields": [
{ "key": "gameId", "label": "游戏编号", "required": true, "type": "text", "options": [] },
{ "key": "system", "label": "系统", "required": true, "type": "select", "options": ["安卓","苹果"] }
]}
```
```
# 文本(一行一个字段:key:label#选项1,选项2)
gameId:游戏编号
system:系统#安卓,苹果
```
**我们的模板固定三个字段**(决策 8):
```
game_id:游戏编号
game_nickname:游戏昵称
homepage:主页截图#image
```
比接单平台**多一个 `image` 字段类型** —— 他们的类型只有 `text`/`select`,截图是单独一个 `screenshots` 数组;我们把图片也做成模板字段,这样「必填校验」统一走一套逻辑,不用给截图开特例。
### 10.1 规则/模板层(新增 2 张表)
**`exchange_skin_rules`(业务配置,一个斗鱼商品一条规则)**
| 字段 | 说明 |
| --- | --- |
| `id` / `rule_key`(唯一) | |
| `commodity_id`(唯一, 索引) | 指向 `skin_catalog.commodity_id` —— **规则直接挂在斗鱼商品上** |
| `enabled` / `auto_create` | 是否参与准入 / 命中后是否自动建单 |
| `required_points` | 预计消耗积分(用于前置校验) |
| `cooldown_days` | 冷却天数,默认 **365** |
| `requirement_json` | **资料模板**(默认 `游戏编号` / `游戏昵称` / `主页截图`,三项均必填) |
| `sort_order` / `remark` / `created_at` / `updated_at` | |
> **没有单独的映射表** —— 原先打算照抄接单平台的 `exchange_skin_rule_mappings`,但它与 §9.2 的 `skin_aliases` 职责重叠(两条链路都能把订单映射到同一个斗鱼商品),实现时会反复纠结「这条映射该写哪张表」。
> 接单平台要 rule + mapping 两层,是因为它要在**多个店铺/多个接单模板**之间路由;我们只有**一个数据源(快手)**,不需要。
>
> **最终分工**:商品身份识别统一交给 `skin_aliases` / `skin_keywords`(§9.2),规则表只挂业务配置 —— **一张表一件事**。
> 连带好处:**`rule_ambiguous` 状态也不需要了**(`skin_aliases` 主键 `(source, raw_key)` 天然一对一,不会出现多条规则并列),候选复数只剩「小店自选」一类,走 `skin_choice`。
**`exchange_match_logs`(匹配诊断)**:`order_no` / `rel_sku_id` / `sku_nick` / `item_title` / `match_status`(`matched` / `multi_match` / `unmatched`) / `commodity_id` / `candidates`(JSON) / `created_at`
### 10.2 匹配层
```python
resolve_skin(order_snapshot) -> { commodity_ids: list[str], reason: matched|multi_match|unmatched, source: alias|keyword, rule | None }
```
- 输入:订单快照的 **`(sellerId, relItemId, relSkuId, skuNick, itemTitle)`**(直接来自 `spec_json`,§3.6);
- **第一步:解析候选商品**(`skin_aliases` / `skin_keywords`)—— 匹配顺序从精确到宽泛:**`rel_sku_id` 精确 → `sku_nick` 精确 → `sku_nick` 系列前缀 → `rel_item_id` → `item_title` 包含**;
- **第二步:取业务配置**(按命中的 `commodity_id` 取 `exchange_skin_rules`:模板、积分、冷却、启用状态)—— 分工见 §10.1;
- **候选 1 个且规则 `enabled` → `matched`**(皮肤由订单自动确定);
- **候选多个 → `multi_match`**(「3选1 / 小店自选」类):工单进 `skin_choice`,让用户在链接里选一件(决策 8 例外,占 12.4%);
- **候选 0 个,或规则 `enabled=false` → `unmatched`**(斗鱼兑不了 / 我们不接,占 59.9%)→ 拒绝、不建单;
- **没有 `ambiguous`**:`skin_aliases` 主键 `(source, raw_key)` 天然一对一,不存在多条并列(§10.1);
- 匹配结果**每次都要落日志**(命中 / 多选 / 未命中都写),这是排障的唯一依据。
### 10.3 工单模型(新增 2 张表)
**`exchange_orders`(工单)**
| 字段 | 说明 |
| --- | --- |
| `order_no`(唯一) / `order_snapshot`(JSON) | 来源订单 + 快照 |
| `chosen_commodity_id`(可空) | **最终选定的斗鱼商品** —— 多选类由用户在链接里选、或客服代选(决策 8 例外的落点) |
| `submission_id`(可空, 索引) | 来源的 `collect_submissions.id` —— **P2 与 P4 的衔接点**(见 §10.5) |
| `sku_code` / `sku_name` / `skin_key` | 皮肤标识(`skin_key` 可空,未归类时为空) |
| `rule_id` / `requirement_json` | 命中的规则(按 `commodity_id` 取)+ **模板快照**(模板后续改动不影响历史工单) |
| `account_id` / `assignee_id` | 执行账号(客服选)+ 归属客服 |
| `status` | 见 §10.4 |
| `material_json` | 用户提交内容 + 截图路径 + 提交时间(对齐接单平台 `material_json.collect`) |
| `eligibility_check`(JSON) | 前置校验结果:CK 状态 / 365 天规则 / 角色绑定 / 积分 |
| `task_batch_id` / `result`(JSON) | 关联的斗鱼任务批次与执行结果 |
| `force_reason` | 超管强制兑换原因 |
| `deadline_at` / `finished_at` / `created_at` / `updated_at` | |
**`exchange_order_events`(事件流水)**:`order_id` / `actor_type`(`collect` / `support` / `system`) / `actor_id` / `event_type` / `from_status` / `to_status` / `payload`(JSON) / `created_at`
### 10.4 状态机
> **工单的创建时机**:① 用户通过链接提交后自动建单(主路径);② 客服手动建单(不走链接的特殊情况)。
> 「客服生成链接」本身**不建工单** —— 链接是 `collect_links`,用户没提交时没有工单行,所以状态机里**没有 `draft`**。
```
pending_material(待用户补资料) ← 模板有必填字段时的默认入口
→ pending_match(订单已提交,待匹配皮肤)
├─ rule_unmatched(皮肤映射不到斗鱼商品)→ 人工归类或拒绝 → 回 pending_match
├─ rule_unmatched(映射不到斗鱼商品 / 规则未启用)→ 人工归类或拒绝 → 回 pending_match
└─ skin_choice(命中多个斗鱼商品,等选一件)→ 选定 chosen_commodity_id → 回 pending_match
→ ck_repairing(账号 CK 无效 → 等重登;**P1b 已启用则自动重登,未启用则等人工点重登**)
→ ineligible(前置校验不过:365 天规则命中 / 积分不足 / 角色未绑定)
└─ 超管 force_reason 强制 → 回 ready;否则取消
→ ready(前置校验全过,可执行)
→ running(兑换执行中,关联斗鱼任务批次)
→ done(已完成,已写台账)
旁路:problem(执行异常待处置)/ cancelled(取消)
```
> **`ineligible` 与 `ck_repairing` 的区别**:`ck_repairing` 是**能自动修复**的(等 CK 重登好),`ineligible` 是**需要人决策**的(365 天命中就摆在那儿,只能超管强制或放弃)。两者都不能直接进 `running`。
补充规则(对齐接单平台):
- **初始状态由模板决定**:模板有必填 → `pending_material`;全可选 → 直接进 `pending_match`;
- **状态回落**:用户补资料不完整(缺必填)时状态**退回** `pending_material`,并写事件;
- **超时**:`deadline_at` + `timeout_policy`,我们只需要 `reopen`(回 `ready`)和 `cancel` 两种;
- **不做**接单平台的 `open`(抢单)/ `unassigned`(派单池)/ `pending_acceptance` 复杂分支 —— 我们是一对一客服,「执行完成即 done」,异常统一进 `problem`;
- **`skin_choice` 怎么选**(决策 8 例外的落地方式):工单进 `skin_choice` 后,客服把(同一个)链接再发给用户 —— 链接在 `status=open` 期间可反复打开,此时公开页展示候选列表让用户选一件;用户不方便时客服可在工单页**代选**。两种情况都写 `chosen_commodity_id` 并写事件。
### 10.5 补资料流程(对齐 `/api/public/collect/*`)
| 步骤 | 行为 |
| --- | --- |
| 用户打开链接 | 已有 `order_no` 时直接查;没有则输入订单号 |
| 查订单 | P0 适配层 `lookup_order()` → `route` 判定;`route ≠ unhandled` 直接拒绝(§6.4) |
| 匹配规则 | `resolve_skin_rule()` → 命中则带出皮肤与资料模板;未命中 / 歧义 → 进对应待处理状态 |
| 渲染表单 | 按 `requirement_json.fields` 动态渲染:`text`→输入框、`image`→上传(预览 + 删除,单张 ≤5MB,Pillow 重编码) |
| 提交 | 校验必填(`image` 字段看路径数组非空)→ 写 `material_json.collect = {fields, screenshots, submitted_at, complete}` → 状态流转 + 写事件;**不完整则退回 `pending_material`** |
| 存图 | **直传 OSS**(`collect/<yyyyMM>/<uuid>.<ext>`,§8.3),DB 只存 `object_key`;**不落本地磁盘** |
| 多件商品 | 我们 **1 单 = 1 件商品**(实测 271,024 单 1:1),不需要接单平台「多件要求指定 workOrderId」的分支 |
### 10.6 前端页面(对齐接单平台三处界面)
| 页面 | 参考实现 | 内容 |
| --- | --- | --- |
| `/exchange-rules` | `ProductRulesPanel.tsx` | 规则列表 + 模板编辑(**支持文本一行一字段**)+ 启用 / 自动建单开关 + 斗鱼商品绑定 |
| `/exchange-orders` | `WorkOrdersPanel.tsx` | 状态筛选、资料查看(含大图)、一键执行、备注、强制兑换(超管 + 原因) |
| `/collect/:token` | `CollectPage.tsx` | 动态字段表单 + 截图上传 + 提交后状态展示(与 §8.7 同一页面) |
权限点:`exchange:view`(查看)、`exchange:manage`(改规则 / 执行工单 / 强制兑换,见 §11.1)。
### 10.7 与接单平台的差异(不照搬的部分)
| 接单平台有 | 我们 | 原因 |
| --- | --- | --- |
| 抢单大厅 / 分享 / 多打手 / 派单池 | ❌ | 一对一客服执行 |
| 报酬、保证金、钱包、提现、等级、VIP | ❌ | 不涉及众包结算 |
| 验收草稿、申诉、售后、超时扣款 | ❌(简化) | 只保留 `problem` + 客服处置 |
| `friend_gift` 好友赠送冷却(默认 72h) | ❌ **不涉及**(决策 9) | 我们的兑换不需要加好友等待 |
| 资料是给打手看的 | ✅ 换成 | 资料是给客服操作游戏账号用(游戏编号 / 游戏昵称 / 主页截图) |
| —— | ➕ CK 健康检查(P1) | 接单平台没有账号池概念 |
| —— | ➕ 365 天规则(P3) | 接单平台没有跨订单冷却 |
| —— | ➕ 斗鱼商品映射与积分校验 | 接单平台直接对接发货平台 API |
### 10.8 端到端流程
```
客服选账号 → 生成收集链接 → 发给用户
↓
用户填订单号 → P0 适配层查 order_site(route 判定:是否「没人管」)→ 匹配规则(皮肤做不做)
↓
按规则模板渲染表单 → 用户补游戏编号 / 游戏昵称 / 主页截图 → 提交(写 material_json)
↓
系统自动前置校验(后台作业):
① skin_key 归一(归不到 → rule_unmatched,人工处理,不静默放行)
② CK 是否有效 → 无效则挂 P1 自动重登,工单停在 ck_repairing
③ 皮肤 365 天规则(P3)→ 命中则标记「不可兑换」并提示上次兑换时间
④ 角色绑定 / 积分是否足够(复用现有 query 任务)
汇总写 eligibility_check
↓
工单变 ready → 客服一键「执行」→ 现有任务链(绑定 → 开通 → 兑换)
↓
二维码 / 结果回填工单 → 成功自动写台账(P3)→ 用户用原链接看到结果
```
### 10.9 验收标准
1. 从「发链接」到「兑换完成」全程无需手工录数据;
2. 用户填的订单号能自动带出商品/皮肤并留快照;
3. 映射不到斗鱼商品(`unmatched`)时不建单;候选多个(`multi_match`)时必须先选定 `chosen_commodity_id` 才能继续;匹配日志能看出命中了哪些候选;
4. 用户资料不完整时状态退回 `pending_material`,补齐后继续;
5. CK 失效时工单自动进入 `ck_repairing`,修复后自动转 `ready`;
6. 命中 365 天规则的工单不能直接执行,超管可强制并留原因;
7. 每个状态变更都有 `exchange_order_events` 记录;
8. 完成后台账、用户链接、工单三处状态一致。
## 11. 横切关注点
### 11.1 权限点与默认角色
新增权限点,并给出建议的角色默认分配(沿用 `web/backend/permissions.py` 的 `ROLE_PERMISSIONS` 机制):
| 权限点 | 说明 | super_admin | operation | support |
| --- | --- | --- | --- | --- |
| `automation:view` | 查看自动化作业与运行历史 | ✅ | ✅ | ❌ |
| `automation:manage` | 改配置 / 手动触发作业 | ✅ | ❌ | ❌ |
| `collect:link` | 生成收集链接 | ✅ | ✅ | ✅ |
| `collect:handle` | 受理 / 驳回提交 | ✅ | ✅ | ✅(仅自己账号,见下) |
| `skin:view` | 查看台账 | ✅ | ✅ | ✅(仅自己账号) |
| `skin:manage` | 维护皮肤别名与目录 | ✅ | ✅ | ❌ |
| `exchange:view` | 查看兑换规则与工单 | ✅ | ✅ | ✅(仅自己账号相关) |
| `exchange:manage` | 维护兑换规则、执行工单、强制兑换 | ✅ | ✅ | ❌ |
> **数据范围靠过滤而不是权限点**:客服只能看到「分配给自己的账号」相关的提交与台账,实现方式与现有 `account:view_assigned` / `cookie:operate` 一致(查询时按 `accounts.assigned_to` 过滤)。
### 11.2 审计
| 动作 | 落点 |
| --- | --- |
| 自动化每轮执行 | `automation_runs` |
| 自动重登单个账号 | `audit_logs`(action=`cookie:auto_relogin`) |
| 人工发起登录批次 | `audit_logs`(**P1 顺带补**,action=`login:batch`) |
| 生成收集链接 / 用户提交 / 客服受理 | `audit_logs` + `collect_submissions` 状态 |
| 365 天规则**强制兑换** | `audit_logs`(含 `force_reason`、操作人) |
| C 桶订单**人工放行** | `audit_logs`(决策 10 要求的留痕) |
### 11.3 配置项清单
| 配置 | 阶段 | 默认 | 说明 |
| --- | --- | --- | --- |
| `ORDER_SITE_DATABASE_URL` | P0 | — | ✅ 已写入生产 `.env`(角色 `livehub_ro`),不入仓库 |
| `ORDER_SITE_LOOKUP_ENABLED` | P0 | `false` | 总开关;关掉即退回手工流程(✅ 已写入生产 `.env`,当前为 false) |
| `ORDER_SITE_LOOKUP_TIMEOUT_SECONDS` | P0 | `3` | 只读查询 `statement_timeout`(✅ 已写入;DB 侧角色也已设 3s,双保险) |
| `AUTOMATION_ENABLED` | P1 | `false` | 调度器总开关 |
| `COLLECT_LINK_TTL_DAYS` | P2 | `7` | 收集链接有效期 |
| `COLLECT_UPLOAD_MAX_MB` | P2 | `5` | 单张截图上限 |
| `COLLECT_UPLOAD_MAX_FILES` | P2 | `3` | 单次提交截图张数 |
| `COLLECT_RATE_LIMIT_PER_MIN` | P2 | `10` | 公开接口每 IP 限流 |
| `OSS_ENDPOINT` | P2 | 内网端点 | `https://oss-cn-hangzhou-internal.aliyuncs.com`(上传走内网,无流量费) |
| `OSS_BUCKET` | P2 | — | ✅ `live-hub-assets`(已建:私有 + 版本控制),已写入生产 `.env` |
| `OSS_ACCESS_KEY_ID` / `OSS_ACCESS_KEY_SECRET` | P2 | — | ✅ RAM 子账号 `live-hub-oss`,只授权该桶;已写入生产 `.env` |
| `OSS_PREFIX` | P2 | `collect` | object key 前缀 |
> 作业级参数(`interval_seconds` / `batch_size` / `concurrency` / 退避表 / 失败率阈值)走 `automation_jobs.config`,在 `/automation` 页面改,不写死在环境变量里。
### 11.4 数据保留与清理
| 数据 | 建议策略 |
| --- | --- |
| `automation_runs` | 保留 90 天,超期清理(**待确认**) |
| `exchange_match_logs` | 保留 180 天 |
| `external_order_cache` | 每条 24 小时后视为过期(过期重查,不主动删) |
| `collect_links` 过期记录 | 保留 90 天 |
| 用户上传截图(OSS) | **不自动删**(属业务凭证);生命周期与版本控制由 OSS 侧规则管理 |
### 11.5 备份
目前只备份数据库。**截图改为 OSS 后(§8.3),不再需要本地备份** —— 图片的持久性与版本控制由 OSS 承担:
- 建议给 `live-hub-assets` 开启**版本控制**并用生命周期规则管理(转低频/过期清理由运维定);
- DB 依旧只备份数据库,但要注意 **DB 里的 `object_key` 与 OSS 对象是配套的**,误删 OSS 对象会导致台账"有记录、没图"。
### 11.6 用户可见文案(统一)
| 场景 | 文案 |
| --- | --- |
| 订单号为空 | 请填写订单号 |
| 订单号重复提交 | 该订单号已提交过,请勿重复提交 |
| 订单不存在 | 订单号不存在,请核对后重试 |
| 已由发货平台处理(`auto_fulfilled`) | 该订单已由发货平台处理,无需在此提交 |
| 走接单平台(`worker_platform`) | 该订单由接单平台处理,无需在此提交 |
| 无人管但皮肤未命中(`unhandled` + 规则未命中) | 该订单暂不支持,请联系客服 |
| 皮肤待归类(内部) | 该商品未归类,请先在「兑换规则」里补充映射 |
| 365 天规则命中 | 该账号已于 2026-03-01 兑换过该皮肤,2027-03-01 后可再兑 |
| 资料不完整 | 还有必填项未填写,请补全后提交 |
### 11.7 测试计划
- 每个阶段补 pytest,沿用现有基线(当前 235 条通过);
- **只读与纯函数优先**:匹配打分、别名归一、365 天判定、订单号归一、模板必填校验都做成纯函数,重点覆盖;
- **写操作不纳入自动回归**:兑换、重登、支付类不跑真实请求,用 mock 客户端做契约测试(沿用 `tests/test_douyu_write_path_contracts.py` 的写法);
- 迁移脚本要走现有 `tests/test_migrations.py` 的链完整性 + SQLite 整链执行;
- 前端沿用 `tsc -b` + `npm run build` 门禁。
### 11.8 安全
- 公开接口最小回显 + 限流 + 图片重编码(见 §8.6 防护);
- 内部接口沿用现有鉴权与数据范围;新增接口一律挂 `Depends(get_current_user)` 或具体权限;
- order_site 只读账号权限最小化,只授 4 张表的 SELECT;
- 截图存**私有 OSS 桶**,只通过鉴权接口读取(不放进 `dist`,不给公共读、不发签名 URL);
- OSS 凭据走 `.env`,建议独立的 RAM 子账号 + 最小授权;不在日志里打印 AK/SK。
## 12. 里程碑与依赖
| 阶段 | 内容 | 依赖 | 粗估 |
| --- | --- | --- | --- |
| **P1a** | CK 巡检自动化(调度器骨架 + `ck_health` 只检测/只标记 + `/automation` 页 + 登录批次审计)**不改动任何 CK** | 无 | 2 人日 |
| **P0** | 订单读取适配层(只读 PG 账号 + `external_order_source.py` + `external_order_cache`) | 运维建只读账号 | 1~1.5 人日 |
| **P2** | 资料收集链接(公开页 + **OSS 图片直传** + 后端代理读图 + 队列 + 订单回显) | P0(订单回显)+ 运维建 OSS 桶;P0 未完成时先留手工填订单号 | 5~6 人日 |
| **P3** | 皮肤台账(`skin_catalog` 以斗鱼商品为锚 + `skin_aliases` 映射 + 四路来源 + 365 天规则 + `/skins` 页) | P0(订单皮肤);映射分析**已完成**(见分析文档),人工只需过 60 个商品 / 76 条别名 | 4~5 人日 |
| **P4** | 皮肤兑换工单(规则/模板 + 匹配层 + 工单/事件表 + 状态机 + 三处界面 + 前置校验作业) | P1a + P2 + P3(**不依赖 P1b**:CK 失效时先标记等人工重登,P1b 上线后自动接管) | 6~8 人日 |
| **P1b** | CK 自愈(`ck_relogin` 自动重登:**失败分型** + 退避 + 熔断 + 开关,**默认关闭**) | P1a(检测已验证,不必再等观察期) | 2.5~3 人日 |
**建议顺序:P1a → P1b → P0 → P2 → P3 → P4。**
- **P1a 已上线**(2026-10-02),只做「帮你把失效 CK 找出来」,零风险;
- **P1b 已上线**(2026-10-02):20 个失效账号实测 20/20 重登成功、零极验;当前 30 条/轮、约 97% 成功率,待重登积压约 3.6k 正在消化;
- **P0 已过半**(2026-10-02):适配层 + 迁移 0039 已完成并真机验证(三桶判定全对、3.3ms、540 个重复订单号正确拦下)。
`psycopg` 与迁移 0039 已随 2026-10-03 的部署上线(`external_order_cache` 表已在生产建好)。
**仍缺接口与页面**,且 `ORDER_SITE_LOOKUP_ENABLED=false` —— 运营还查不了订单;
接口/页面按 §6.4 的调用点(用户填订单号时)随 P2 一起上;
- **P1b 提到 P1a 之后**:原计划等一到两周观察期,但线上数据已把三个前提验证完了 —— 检测结果与人工/直连逐条一致、误判路径已封(三类判定 + 自动暂停)、失效性质查清(会话过期而非凭证问题)。不联动的话,巡检每天翻出失效清单却没人兑现修复,1,356 个账号只会越积越多;
- **P0 随时可做**:只动本系统,order_site 侧代码改动量为 0;
- **P1b 开关仍默认关闭**,按 §7.4.7 三段上线:小规模实测 → 限量开 → 全量;
- **P1a 上线按三段走**:
① 打开 `AUTOMATION_ENABLED=true` 并重启(此时作业开关仍关着,调度线程空转,不会有任何检测);
② 在 `/automation` 页点「立即跑一次」并先把「每轮条数」设 **50** —— 手动触发不受开关限制,正好用来观察斗鱼风控与失败率;
③ 确认无异常后把「每轮条数」调 **200** 并打开「启用」,调度器接管(5 分钟一轮,约 5.4 小时覆盖全库一遍),观察一到两周确认检测结果与人工判断一致。
依赖关系:
```
P1a ──> P1b (观察一到两周后再开,开关默认关闭)
│
└──────────────┐
├──> P4 (只依赖 P1a 的「检测出失效」;P1b 上线后自动接管重登)
P0 ──┬──> P2 ────┤
└──> P3 ────┘
```
## 13. 风险与回退
| 风险 | 影响 | 缓解 | 回退方式 |
| --- | --- | --- | --- |
| 只读查 order_site 打慢对方生产库 | 影响自动发货 | `statement_timeout=3s`、单行索引查询、2 条连接、本地缓存 | 关 `ORDER_SITE_LOOKUP_ENABLED` |
| order_site 改表结构 | 适配层报错 | 只读 4 列且 88 个迁移零变更;SQL 集中在一个文件 | 同上(降级为手工) |
| **巡检把「查不到」当成「失效」** | 接口抖动/被风控时批量误标失效,客服误判 | 已做三类判定:只有斗鱼明确回「未登录」才写 invalid,其余只记 `error`、保留原结论;`error` 占比 > 30% 自动暂停作业 | 恢复接口后重新打开作业开关 |
| 巡检本身触发斗鱼风控 | 检测接口被限流/封禁,误判大面积 invalid | 巡检走**只读接口**且不并发过高;失败率超阈值自动暂停;必要时给请求加随机抖动、降 `concurrency` | 关 `ck_health` 作业(回到人工检测) |
| 冷启动一次性全量扫 | 短时高并发被风控 | 首轮小批量(50)观察后再放量 | 调小 `batch_size` |
| 自动重登烧掉邮箱配额 | 该账号当天无法登录 | 单账号退避到次日 + 每日上限 + 失败率熔断 | 关 `ck_relogin` 作业 |
| 自动重登与人工操作撞车 | 状态互相覆盖 | `attempt_status` 在途判断 + 作业锁 | 关作业 |
| 公开收集页被刷 | 垃圾数据 / 磁盘占用 | token + 有效期 + 次数上限 + IP 限流 + 图片重编码 + 大小限制 | 关闭链接 / 全局停用公开接口 |
| 皮肤别名归类错误 | 365 天规则误判 | 未命中一律挂起待人工,不静默放行;强制兑换留审计 | `/skins` 页面修正别名 |
| OSS 桶/对象被误删 | 台账有记录、图片丢失 | 桶开版本控制 + 生命周期;DB 只存 `object_key` 便于核对 | 从 OSS 版本历史恢复 |
| OSS 不可用 | 用户传不了图 | 提交失败给明确提示,不产生半条记录;重试 | 临时降级:先收文字资料,客服后补图 |
| 规则配置错误导致误拒绝 | 用户无法提交 | 匹配日志 + 人工放行通道 | 修正规则 / 人工放行 |
## 14. 明确不做(避免范围膨胀)
- 不做用户注册 / 登录体系:收集链接就是一次性的、带 token 的表单;
- 不做用户端的复杂进度回执与消息推送(决策 3);
- 不做订单号格式校验(决策 5),但**会校验订单是否真实存在**(依赖 P0);
- **不改 order_site 的代码、鉴权与部署**(见 §5.2);
- **不在 order_site 里重建一套兑换流程**:只借用它的订单/商品数据;
- **不回写 order_site 的订单状态**(本轮只读);
- 不做接单平台的众包能力(抢单 / 报酬 / 保证金 / 钱包 / 验收申诉);
- 不做分布式调度 / 多副本部署适配(当前单容器);
- 不做自动付款:所有涉及花钱的环节仍需人工扫码确认。
## 15. 待定事项
### 15.1 需要运维配合(非代码改动,P0/P2 前置)
| # | 事项 | 状态 |
| --- | --- | --- |
| 1 | order_site PostgreSQL 建只读账号并授权 4 张表 | ✅ **已完成**(`livehub_ro`,SELECT-only,详见 §6.5) |
| 2 | 连接串写入 live-hub-py 的 `.env` 并确认可达 | ✅ **已完成**(`/root/live-hub-py/.env`,容器实测可达 `172.19.0.2:5432`) |
| 3 | 只读保护参数 | ✅ **已完成**(`statement_timeout=3s`、`CONNECTION LIMIT 5`、`default_transaction_read_only=on`) |
| 4 | 新建 OSS 桶 `live-hub-assets`(私有 + 版本控制) | ✅ **已完成**(private ACL、版本控制开启、put/get/list/delete 实测通过) |
| 5 | 新建 RAM 子账号(只授权该桶 Put/Get/Delete)并写入 AK/SK | ✅ **已完成**(`live-hub-oss` + 自定义策略 `live-hub-assets-rw`,实测最小授权生效) |
**第 5 项的做法(已完成,留档)**
子账号 `live-hub-oss` 只挂了自定义策略 `live-hub-assets-rw`(仅 `live-hub-assets` 桶的 Put/Get/Delete/List),实测:
| 验证 | 结果 |
| --- | --- |
| 本桶 Put / Get / Delete / List | ✅ 全通(含真实 PNG 上传 → 后端读回,字节与 ContentType 完全一致) |
| **跨桶越权**(读 `order-site-assets`) | ✅ `AccessDenied` —— 最小授权生效 |
| **公共 URL 直取** | ✅ **403** —— 证实桶是私有的,必须走后端代理 |
| `HeadBucket` | ❌ 403(OSS 对 HEAD Bucket 要的权限不在这套策略里)→ **`object_storage.py` 不要用 HeadBucket 做健康检查**,用 `ListObjectsV2` 或 Put/Get 往返 |
原控制台步骤(供以后给新环境建号时参考):
1. 阿里云控制台 → **RAM 访问控制** → 身份管理 → 用户 → **创建用户**
- 登录名称:`live-hub-oss`
- 访问方式:**只勾「OpenAPI 调用访问」**(不要开控制台登录)
2. 创建后**立即保存 AccessKey ID / Secret**(只显示这一次)
3. 权限管理 → **新增授权** → 自定义策略 → 新建策略(脚本编辑),粘贴:
```json
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": ["oss:PutObject", "oss:GetObject", "oss:DeleteObject", "oss:AbortMultipartUpload", "oss:ListParts"],
"Resource": ["acs:oss:*:*:live-hub-assets/*"]
},
{
"Effect": "Allow",
"Action": ["oss:ListObjects", "oss:GetBucketInfo", "oss:GetBucketAcl"],
"Resource": ["acs:oss:*:*:live-hub-assets"]
}
]
}
```
4. 把 AK/SK 写入 `/root/live-hub-py/.env`:
```bash
OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com
OSS_BUCKET=live-hub-assets
OSS_ACCESS_KEY_ID=<第 2 步的 ID>
OSS_ACCESS_KEY_SECRET=<第 2 步的 Secret>
OSS_PREFIX=collect
```
> 若嫌麻烦,也可先用现有 AK 跑通 P2,之后再收窄权限 —— 但最小授权更稳(`.env` 泄漏时影响面只有一个桶)。
### 15.2 自有待确认
1. `automation_runs` 保留 90 天是否合适(§11.4);
2. 收集链接默认有效期 7 天是否合适;
3. `skin_catalog` / `skin_aliases` 初版由谁确认(分析已完成,只需过一遍 **60 个斗鱼商品 + 76 条快手别名**);
4. **C 桶的②卡券/道具类(17,948 件)与③纯数字商品名(4,491 件)是否纳入**?初步判断:不纳入(疑似走电子凭证自动发码 / 需人工识别);
5. 是否需要在兑换成功后主动通知用户(目前靠用户自己打开链接看)。
### 15.3 已定事项(留档)
| 事项 | 结论 |
| --- | --- |
| **准入范围** | **只接 C 桶(没人管)**;A(已自动发货)、B(接单平台)都不要(§3.4) |
| C 桶内①服装类(黑色ROCK/运动服,17,704 件) | **后面单独处理**,本轮不做 |
| 归一的锚点 | **斗鱼商品目录**(78 个),不是快手商品名(§3.5) |
| 皮肤由谁定 | 唯一命中 → 订单自动定;命中多个 → **用户在链接里选一件**(决策 8 + 例外) |
| 斗鱼兑不了的 59.9% | **直接拒绝、不建单**(负责人已定),后续慢慢优化 |
| 匹配键 | **优先 SKU**(`relSkuId`/`skuNick`),文本兜底(决策 12) |
| 规则与映射的分工 | **取消映射层**:识别归 `skin_aliases`/`skin_keywords`,配置归 `exchange_skin_rules`(§10.1);`rule_ambiguous` 一并去掉 |
| 皮肤是否由用户选 | **不用**,由订单号自动对应(决策 8) |
| 是否涉及加好友赠送 | **不涉及**(决策 9) |
| 整合方案 | 选 **B:只读直连 PG**,order_site 零改动(§5.2) |
---
## 附录 A · 迁移清单(均为加表/加字段,不动存量数据;编号按落地顺序取)
| 阶段 | 迁移内容 |
| --- | --- |
| P0 | `external_order_cache` |
| P1a | `automation_jobs`、`automation_runs`;`login_tasks` + `relogin_attempts`/`relogin_next_at`/`relogin_last_error`;`accounts` + `auto_managed`(revision `20261002_0037`) |
| P1b | `login_tasks` + `relogin_failure_kind`(枚举;按落地顺序取号) |
| P2 | `collect_links`、`collect_submissions`(截图只存 `object_key`,不存 URL/本地路径) |
| P3 | `skin_catalog`、`skin_aliases`、`skin_douyu_items`、`account_exchange_records` |
| P4 | `exchange_skin_rules`、`exchange_match_logs`、`exchange_orders`、`exchange_order_events`(**4 张**,映射层已取消) |
## 附录 B · 文件清单(预估)
**后端**
| 文件 | 阶段 | 说明 |
| --- | --- | --- |
| `web/backend/models.py` | 全部 | 新增表与字段 |
| `web/backend/permissions.py` | 全部 | 新增权限点与角色默认 |
| `web/backend/migrations/versions/*.py` | 全部 | 5 个迁移 |
| `web/backend/services/automation/scheduler.py` | P1 | 进程内调度线程 |
| `web/backend/services/automation/jobs/ck_health.py` | P1a | 巡检作业 |
| `web/backend/services/automation/jobs/ck_relogin.py` | P1b | 自愈作业 |
| `web/backend/services/external_order_source.py` | P0 | **唯一**允许写跨库 SQL 的文件 |
| `web/backend/services/collect_service.py` | P2 | token / 限流 / 图片校验与重编码 |
| `web/backend/services/object_storage.py` | P2 | **OSS 封装(`oss2`)**:上传/读取/删除,唯一允许出现 OSS SDK 的文件 |
| `pyproject.toml` | P0/P2 | 新增依赖:PG 驱动(`psycopg[binary]` 等,P0)+ `oss2`(P2) |
| `web/backend/services/skin_service.py` | P3 | 别名归一 + 365 天规则 + 落账 |
| `web/backend/services/exchange_order_service.py` | P4 | 匹配 + 状态机 + 事件 |
| `web/backend/routers/automation.py` | P1 | 作业管理接口 |
| `web/backend/routers/collect.py` | P2 | 公开 + 内部接口 |
| `web/backend/routers/skins.py` | P3 | 台账与别名维护 |
| `web/backend/routers/exchange_orders.py` | P4 | 工单接口 |
| `web/backend/main.py` | P1 | 注册路由 + 启停调度器 |
| `.env.example` | P0/P1/P2 | 新增配置项说明 |
**前端**
| 文件 | 阶段 | 说明 |
| --- | --- | --- |
| `web/frontend/src/pages/AutomationPage.tsx` | P1 | 作业与运行历史 |
| `web/frontend/src/pages/CollectPage.tsx` | P2 | 公开收集页(**放在登录判断之外**) |
| `web/frontend/src/pages/CollectLinksPage.tsx` | P2 | 链接管理 |
| `web/frontend/src/pages/CollectSubmissionsPage.tsx` | P2 | 待处理队列 |
| `web/frontend/src/pages/SkinsPage.tsx` | P3 | 台账与别名维护 |
| `web/frontend/src/pages/ExchangeRulesPage.tsx` | P4 | 规则与资料模板 |
| `web/frontend/src/pages/ExchangeOrdersPage.tsx` | P4 | 工单管理 |
| `web/frontend/src/App.tsx` | P2 | 新增路由(公开页需放在 `isLoggedIn` 分支之外) |
| `web/frontend/src/layouts/MainLayout.tsx` | P1~P4 | 菜单项 |
| `web/frontend/src/api/{automation,collect,skins,exchangeOrders}.ts` + `types.ts` | 全部 | 接口封装与类型 |
**文档**
| 文件 | 说明 |
| --- | --- |
| `docs/自动化与资料收集规划.md` | 本文档 |
| `docs/README.md` | 索引 |
## 附录 C · 施工单(实现顺序与验证)
战略与数据模型在正文已定;这里只写**按什么顺序动手、每步怎么验证**。
### C.1 迁移编号(按落地顺序取,**不预留**)
现状 head = `20261003_0041`(账号事件,在 `dev`;`main` 上是 `20261003_0040`)。**谁先落地谁取下一个号**,不要按阶段预分配。
| 阶段 | revision | 说明 |
| --- | --- | --- |
| P1a(已落地) | `20261002_0037` | `automation_jobs` / `automation_runs` / `login_tasks` +3 / `accounts.auto_managed` |
| P1b(已落地) | `20261002_0038` | `login_tasks` + `relogin_failure_kind` |
| P0(已落地) | `20261002_0039` | `external_order_cache` |
| 生产热修(已落地) | `20261003_0040` | `douyu_tasks.account_assigned_to` + 复合索引(消除任务列表 filesort) |
| 账号事件(已落地,在 `dev`) | `20261003_0041` | `account_events` + `audit_logs.account_id`([设计](设计-账号详情页与事件历史.md)) |
| P2 / P3 / P4 | 各自落地时依次取号(**下一个是 `20261003_0042`**) | 见附录 A 的表清单 |
> **为什么不能预留编号**:Alembic 的 `down_revision` 必须形成单链。若先给 P1 占 0038、
> 把 0037 留给 P0,等 P0 真落地时它的 `down_revision` 只能指向 0038(否则与 0038 平级,
> 出现**两个 head**,`tests/test_migrations.py` 的单头断言会直接失败),编号比父提交还小。
> 所以按落地顺序递增,才是唯一不别扭的做法。
约定:每个迁移的 `down_revision` 指向前一个;**写成幂等的**(照 `20261001_0036` 的写法,先 `sa.inspect` 查存在再改);每加一个迁移,`tests/test_migrations.py` 的 `HEAD_REVISION` 要同步改。
> **⚠️ 迁移编号一旦被任何库应用过就不能再改名**。P1a 犯过一次:迁移先叫 `20261002_0038`,本地 dev 库跑过一次,随后为了链条连续改名成 `0037`,dev 库里的
> `alembic_version` 还记着 `0038`,再启动就报
> `CommandError: Can't locate revision identified by '20261002_0038'`。
> 改名要在**还没被任何库应用**之前做;已经应用过又必须改名时,除了改文件,还要把每个应用过的库
> 手动对齐:`UPDATE alembic_version SET version_num='<新编号>';`(前提是迁移内容没变,
> 否则要按实际差异补脚本)。
### C.2 通用约定
- 测试文件按功能命名:`tests/test_<域>_<主题>.py`(现有 38 个文件、235 条);
- **纯函数优先**:匹配打分、别名归一、365 天判定、订单号归一、模板必填校验都做成纯函数重点覆盖;
- **写操作不进自动回归**:兑换/重登/支付用 mock 客户端做契约测试(沿用 `tests/test_douyu_write_path_contracts.py`);
- 提交门禁:pre-commit 跑 `ruff format --check`(仅暂存文件)+ `tsc -b`;pre-push 跑全量 pytest;
- **新增环境变量必须同步改 `docker-compose.yml`**:本项目的 compose 用的是
`environment:` **显式列表**(不是 `env_file`),只写进 `.env` **不会传进容器**。
P1a 踩过一次:`.env` 加了 `AUTOMATION_ENABLED=true`,容器里查不到,调度器照旧不启动;
早先 §15.1 写入的 `ORDER_SITE_*` 与 `OSS_*` 同样没生效。新增变量时三处都要动:
`.env`(服务器实值)+ `docker-compose.yml`(透传)+ `.env.example`(留档);
- **测试现已跑在真实 MySQL 上**(2026-10-02 改造完成,不再用 SQLite):SQLite 与 MySQL 的方言差异
曾让 `NULLS FIRST` 这类写法本地全绿、生产 1064,而 P1b 又踩到 SQLite 测不出的
REPEATABLE READ 快照问题(见下条)。现在 `tests/conftest.py` 指向独立的 `douyu_login_test`、
每用例前后清表,库名与开发库相同时直接拒绝运行(防误清);`./dev.sh test` 需要 MySQL 已启动。
**代价**:跑测试前要先 `./dev.sh` 起库,耗时从约 3.6 秒增至约 13 秒;
- **注意方言差异:生产是 MySQL 8**。SQLite 能吃下的写法 MySQL 未必认,行为测试也发现不了。
P1a 踩过一次:`ORDER BY ck_checked_at ASC NULLS FIRST` 在 SQLite 正常,MySQL 直接报
`1064 ... near 'NULLS FIRST'`。跨方言的排序用可移植写法(如 `col IS NULL DESC, col ASC`),
并在测试里按 MySQL 方言 `compile()` 一次做回归。**P0 更要注意:它连的是 order_site 的 PostgreSQL**,
三套方言(MySQL / SQLite / PG)都出现过,写 SQL 前先想清楚方言;
- **DB 读出来的时间是 naive UTC**:`datetime` 列取回后不带时区,直接和 `datetime.now(UTC)` 比较会
`TypeError: can't compare offset-naive and offset-aware`(P1a 踩过一次)。比较前一律用
`_as_utc()`(`services/yyb_service.py` 与 `services/automation/scheduler.py` 各有一份)补时区。
SQL 里用绑定参数比较不受影响,只有 Python 侧比较需要注意 —— P0 判缓存过期时会再遇到;
- **每个阶段独立可发布**,新作业/新功能一律**开关默认关闭**;
- **迁移是应用启动时自动跑的**(`database.init_db()` → `run_migrations()` → `alembic upgrade head`)。
所以「部署」就等于「跑迁移」,**不要**再手动执行一遍;同时也意味着**迁移写坏了会挡住整个应用启动**,
必须保持幂等(先 `sa.inspect` 查存在再改)。想先看效果可在本地 dev 库跑;
- **⚠️ 删除登录任务 = 删除该账号的 CK**(2026-10-03 出过事故,丢过 5019 个 CK,详见
[事故复盘](事故复盘-CK误删与恢复.md)):斗鱼 CK **只存在 `login_tasks.cookie`**,
`accounts` 表没有 cookie 列。列表显示的「状态」是 `attempt_status`(最近一次尝试),
与「是否持有 CK」(`status`/`cookie`)**语义不同** —— 当时有 752 行「显示失败但持有
有效 CK」。任何按条件筛选/删除的入口,判定必须基于 `cookie` 字段;
- **⚠️ 动手前先核实外部库的真实表结构,别信文档和早期分析**(P0 踩过一次,差点白干):
规划 §6.2 写的 `orders.order_no`(PK) 在真实库里**根本不存在**,实际是
`platform_order_id`(且**非唯一**,541 组重复)。同一订单号下还有「发货方内部行 +
真实店铺行」的成对结构,**按单行判 route 会放行 540 个已发货订单**。
做法:先用只读账号 `SELECT column_name FROM information_schema.columns` 核对一遍,
再把结论写进文档(§6.5.1 就是这么留档的)。**跨系统的字段假设一律当作待验证**;
- **加依赖后必须重锁 `uv.lock`**:Dockerfile 用的是 `uv sync --frozen`(见 §C.3),
只改 `pyproject.toml` 不重锁,生产构建会直接失败。P0 加 `psycopg[binary]` 时走过一遍:
本地 `uv lock` + `uv sync`,提交时带上 `uv.lock`;
- **验证容器里的代码要 `docker cp`,挂载只有 logs/data**:compose 只 bind 了
`/root/live-hub-py/logs` 与 `/data`,**代码在镜像里**。改完宿主机文件不会自动生效;
临时验证用 `docker cp` 进 `/app`,之后**记得删掉**,别污染生产工作区(会让 `git pull` 起冲突);
- **容器里装包要用 `uv pip` 且指定 `VIRTUAL_ENV`**:容器的 `/app/.venv` 里**没有 pip**,
系统 `pip` 装的东西 venv 里的 python 看不见(P0 验证时白装过一次)。正确姿势:
`docker exec -w /app <容器> env VIRTUAL_ENV=/app/.venv uv pip install <包>`;
另:`docker exec -e` 覆盖不了 compose 已注入的同名变量,验证时改用容器自带的 env
或在脚本里 `os.environ[...] = ...`。
- **⚠️ `create_all` 不会修改已存在的表**:测试库靠 `Base.metadata.create_all()` 建表,
它**只建缺失的表**。给**已有表**加列/改列后,测试库里的旧结构不会更新,症状是一堆
**不相关**的用例同时挂(例如加 `audit_logs.account_id` 后 22 个用例报
`Unknown column 'account_id'`)。处理:改已有表的模型后,对测试库手动 `ALTER TABLE`,
或 `DROP TABLE` 让它按新模型重建。**新增表**不受影响(会直接建出来);
- **灌测试数据要带 `--default-character-set=utf8mb4`**:`docker exec -i mysql ... < file`
不带这个参数时中文会被双重编码(`套装` 存成 `套装`)。排查这类问题的快捷判断:
对可疑列取 `HEX()`,若是 `C3A5C2A5...` 这种「UTF-8 再编码一次」的形态,
说明是**写坏了**而不是读坏了。
### C.3 已核实的环境事实
| 事实 | 值 | 对实现的影响 |
| --- | --- | --- |
| uvicorn worker 数 | **`--workers 1`**(单容器单进程) | 进程内调度线程安全,**不需要分布式锁**;调度器在 lifespan 里启停 |
| 生产路径 | `/root/live-hub-py`,容器 `live-hub-py-douyu-login-1` | 部署 = `git pull` + `./deploy.sh` |
| 鉴权 | **无全局中间件**,逐路由 `Depends(get_current_user)` | 公开路由只要不挂依赖;新路由要在 `main.py` 注册 |
| PG 驱动 | `psycopg[binary]` 已进 `pyproject.toml` + `uv.lock`,**已随镜像上线**(2026-10-03,容器实测 3.3.6) | 部署时随镜像装上 |
| 只读 PG | `livehub_ro` @ `order_site-postgres-1:5432`,`.env` 已配(容器内 3 个 `ORDER_SITE_*` 均可见) | 适配层直接用 `ORDER_SITE_DATABASE_URL`;**`LOOKUP_ENABLED` 仍是 `false`** |
| OSS | 桶 `live-hub-assets`(私有+版本控制)、RAM `live-hub-oss`、`.env` 已配 | P2 用 `oss2`;**不要用 HeadBucket** |
| 迁移 head / 测试基线 | `20261002_0039` / 338 条(2026-10-03) | 见 C.1;**应用启动会自动跑迁移**,0039 已在生产应用 |
| 时区 | 库/`NOW()` 都是 UTC(`--default-time-zone=+00:00`),接口返回 `+00:00`,前端转北京时间 | **别把 `SELECT NOW()` 当真实时间**,详见 README「时区口径」 |
| 备份 | **没有定期逻辑备份**;依赖 MySQL ROW 格式 binlog(`log_bin=ON`,保留 30 天) | 误删只能靠 binlog 恢复;**不要关 binlog 或缩短保留期**;镜像里没有 `mysqlbinlog`,用 `pymysqlreplication` |
### C.4 P1a 施工单(✅ 已按此完成,8 步)
| # | 动作 | 文件 | 验证 |
| --- | --- | --- | --- |
| 1 | 迁移 0037:`automation_jobs`、`automation_runs`;`login_tasks` +3 字段;`accounts.auto_managed` | `migrations/versions/20261002_0037_automation_jobs.py`、`models.py` | `pytest tests/test_migrations.py`;本地建表通过 |
| 2 | 调度器骨架:作业注册表、60s tick、进程内锁、开关、`automation_runs` 记录、优雅停止 | `services/automation/scheduler.py` | 单测:假时钟驱动 tick,断言「作业 enabled=false 时不执行」「不重叠执行」 |
| 3 | `ck_health` 作业:选行(`cookie_holding_filter` + 超期 + 排除在途/`auto_managed=false`)、并发 5、调 `check_douyu_cookie()`、写回 `ck_check_*` | `services/automation/jobs/ck_health.py` | 单测:选行条件正确;**断言全程不写 `cookie` / `status`**(P1a 的核心约束) |
| 4 | 接口 + 权限 + 注册:4 个 `/api/automation/*`;`automation:view` / `automation:manage`;lifespan 启停 | `routers/automation.py`、`permissions.py`、`main.py` | `tests/test_automation_api.py`:权限校验、开关生效、手动触发 |
| 5 | 补 `login:batch` 审计 | `routers/login.py` | 单测:发起批次后 `audit_logs` 有记录 |
| 6 | 前端:作业卡片 + 运行历史 + 手动触发;路由/菜单/接口封装 | `pages/AutomationPage.tsx`、`App.tsx`、`MainLayout.tsx`、`api/automation.ts` | `tsc -b` + `npm run build` |
| 7 | 本地端到端:对 3~5 个账号跑一轮,与人工点「检测」的结果逐条比对 | —— | 结果一致才进入下一步 |
| 8 | 上线:`git pull` + `./deploy.sh` → 先 `batch_size=50` 跑一轮看风控 → 无异常再调到 200 | —— | `/automation` 页面能看到每轮明细;CK 未被改动 |
**P1a 完成定义(DoD)**:接口能开关与手动触发;巡检结果与人工检测一致;`login_tasks.status` / `cookie` 全程无变化;关掉开关后调度立即停止。
### C.4-bis P0 施工单(✅ 已完成,2026-10-02;**迁移 0039**)
| # | 动作 | 文件 | 验证 |
| --- | --- | --- | --- |
| 1 | 加 PG 驱动 `psycopg[binary]` 并重锁 `uv.lock`(Dockerfile 用 `uv sync --frozen`,不重锁构建会失败) | `pyproject.toml`、`uv.lock` | 容器内 `import psycopg` 通过 |
| 2 | 迁移 **0039**:`external_order_cache`,主键 `platform_order_id + source_order_id` | `migrations/versions/20261002_0039_external_order_cache.py`、`models.py` | `pytest tests/test_migrations.py`(`HEAD_REVISION` 已同步);本地建表通过 |
| 3 | 适配层(唯一跨库 SQL):URL 归一、`statement_timeout=3s`、2 条连接池、**订单号维度聚合 route**、回显取真实店铺行 | `services/external_order_source.py` | `tests/test_external_order_source.py`(24 条) |
| 4 | 真机验证(只读账号 `livehub_ro`) | `tests/test_external_order_source_live.py`(手工跑,未配 URL 时 skip) | 见下 |
| 5 | 降级开关验证 | —— | 未配 URL / `ENABLED=false` / 库不可用时均抛 `OrderLookupUnavailable`,主流程照常 |
**真机验证结果(生产容器,`ORDER_SITE_LOOKUP_ENABLED` 临时打开)**
| 用例 | route | 回显 | 结论 |
| --- | --- | --- | --- |
| C 桶 `2627501602915345` | `unhandled` | 激萌罗小黑 | ✅ 进候选 |
| B 桶 `2627501932004488` | `worker_platform` | 指挥官密钥4个 | ✅ 拒绝 |
| A 桶 `2627501850053080` | `auto_fulfilled` | 套装-浪漫天命 | ✅ 拒绝 |
| **重复组** `2624100167260659` | **`auto_fulfilled`** | **狂沙舞者** | ✅ **拦下,且回显真实店铺行** |
| 重复组 `2624100563293477` | `auto_fulfilled` | 100011 | ✅ 拦下 |
| 不存在 `0000000000000000` | —— | —— | ✅ 明确「订单号不存在」 |
| 单次查询耗时 | —— | —— | ✅ **3.3 ms**(要求 < 50ms) |
**⚠️ 上线前还需做(P0 未完的部分)**
1. `ORDER_SITE_LOOKUP_ENABLED` 仍为 `false`(默认关闭)—— 要正式启用需改生产 `.env` 并重启;
2. ~~生产库还没跑迁移 0039~~ ✅ **已于 2026-10-03 部署时自动应用**(`external_order_cache` 表已存在,psycopg 3.3.6 随镜像装上);
3. **P0 目前只有适配层,没有接口和页面** —— 运营还查不了。按 §12 排期,接口/页面可随 P2 收集链接一起上(用户填订单号时调用)。
### C.5 其余阶段的施工顺序(粗粒度)
| 阶段 | 顺序 |
| --- | --- |
| **P0** | ① `pyproject.toml` 加 `psycopg[binary]` → ② 迁移(取下一个号)+ `models.py` → ③ `external_order_source.py`(唯一跨库 SQL;URL 归一成 `postgresql+psycopg://`;`statement_timeout`;2 条连接池) → ④ `tests/test_external_order_source.py`(用真实只读账号跑只读查询,覆盖 A/B/C 三桶与不存在订单) → ⑤ 降级开关验证 |
| **P2** | ① 迁移(取下一个号)→ ② `object_storage.py`(`oss2`,Put/Get/Delete/List,不用 HeadBucket) → ③ `collect_service.py`(token/限流/图片校验+Pillow 重编码+缩略图) → ④ 公开路由 `/api/public/collect/*` + 内部 `/api/collect/*` → ⑤ 前端公开页(放 `App.tsx` 的 `/login` 同级)+ 内部两页 → ⑥ 测试(含"宿主机找不到图片副本"的验收) |
| **P3** | ① 迁移(取下一个号)→ ② `skin_catalog` 从 `douyu_goods_snapshot` 同步 + `sku_catalog_sync` 作业(挂 P1 调度器) → ③ `skin_service.py`(别名归一 + `can_exchange` 365 天 + 落账) → ④ `/api/skins/*` → ⑤ `/skins` 页 → ⑥ 把 62 条唯一映射 + 14 条多选映射导入 |
| **P4** | ① 迁移(取下一个号)→ ② `exchange_order_service.py`(`resolve_skin` 两步匹配 + 状态机 + 事件) → ③ 前置校验(CK/365 天/绑定/积分) → ④ 工单接口 + 与 `collect_submissions` 衔接 → ⑤ `/exchange-rules` + `/exchange-orders` 两页 → ⑥ 端到端联调(发链接 → 提交 → 校验 → 执行 → 台账) |