Files
live-hub-py/docs/自动化与资料收集规划.md
T
yml2213 7aaaeebe2b fix(automation): 修正调度口径——作业每 5 分钟跑一轮,interval_seconds 只是单行重检间隔
P1a 把两个「间隔」混成了一个:next_run_at 用的是 job.interval_seconds,于是
作业 6 小时才跑一轮、每轮 200 条 = 800 条/天,全库 12,881 条要 16 天才能过
一遍 —— 等于没巡检,与规划里「账号 6 小时内被查一次」的目标差了两个数量级。

按规划原意修正:
- TICK_SECONDS 60 → 300:作业每个 tick(5 分钟)跑一轮;
- next_run_at 改为 finished + TICK_SECONDS,不再用 interval_seconds;
- interval_seconds 只作为「同一行多久重新检测」的选行 cutoff(选行逻辑本来就
  是这么用的,无需改);
- 容量口径随之更新:5 分钟一轮 × 200 条 = 40 条/分钟 ≥ 需要的 36 条/分钟,
  冷启动约 5.4 小时覆盖全库,稳态每轮一百多条;
- 前端标签由「巡检周期(小时)」改为「单账号重检间隔(小时)」,说明文案写清
  「每 5 分钟跑一轮」,避免运维误以为是作业周期。

同步更新规划文档 §7.2/§7.3(含两个间隔不能混的说明)与附录 C 的三段上线
步骤(立即跑一次 → 每轮 50 观察 → 每轮 200 启用)。
2026-10-02 19:54:31 +08:00

1215 lines
86 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 自动巡检)已落地**,P0/P2/P3/P4/P1b 待排期。产品决策已确认(见 §4),施工单见附录 C。
---
## 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`,即「没人管」)才进入候选;② 候选订单的商品必须**命中皮肤兑换规则**才放行。其余一律提示「该订单暂不支持,请联系客服」,客服可在后台人工放行(留审计) |
| 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`(订单快照)**:`order_no`(PK) / `platform` / `provider` / `order_status` / `sku_code` / `sku_name` / **`seller_id` / `rel_item_id` / `rel_sku_id` / `sku_nick` / `item_title`** / `quantity` / `route` / `payload`(JSON) / `fetched_at`
> 用途:用户提交订单号时立即回显商品;工单与台账留档;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 细节**;
- 查询顺序:先查 `external_order_cache`(未过期)→ 未命中再查 PG → 回写缓存。
### 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.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. 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` 全程未变。
### 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`;
- **并发**:默认 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`(自愈,默认关闭)
- **调度**:每 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` 不在途;
- **执行**:复用 `LoginBatchRunner(mode="relogin")`,成功才替换 CK;
- **退避**:`relogin_attempts` 1→1h、2→3h、3→12h、4+→24h(写 `relogin_next_at`);
- **邮箱配额(决策 1)**:命中 `EmailQuotaError` 时
- 只把**该账号**标记为「邮件配额用尽」,`relogin_next_at` 设为次日零点 + 随机抖动(默认 ±30 分钟,避免整点齐发),`relogin_last_error` 记录原因;
- **批次继续跑其他账号**,不全局暂停;
- 说明:现有 `login()` 已经是这个语义(单账号返回失败、批次继续),自动化层只需额外记录退避时间,避免反复重试同一账号;
- **系统性故障刹车(与决策 1 不冲突)**:以下情况仍会自动暂停作业并告警 ——
代理 API 不可用 / 连续 N 轮失败率超过阈值(默认 80%)/ 单轮异常数超过阈值。这些不是单账号问题,继续跑只会浪费资源。
### 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 观察一到两周 | 1.5~2 人日 |
**建议顺序:P1a → P0 → P2 → P3 → P4 → P1b。**
- **P1a 先上**:只做「帮你把失效 CK 找出来」,零风险、立刻省掉大批量人工点检测;观察一到两周、确认检测结果与人工判断一致后,再打开 P1b;
- **P0 随时可做**:只动本系统,order_site 侧代码改动量为 0;
- **P1b 代码可与 P1a 一起写完**,但开关默认关闭,等观察结论再开;
- **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 | 巡检走**只读接口**且不并发过高;失败率超阈值自动暂停;必要时给请求加随机抖动、降 `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` |
| P1 | `automation_jobs`、`automation_runs`;`login_tasks` + `relogin_attempts`/`relogin_next_at`/`relogin_last_error`;`accounts` + `auto_managed` |
| 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 = `20261001_0036`。**谁先落地谁取下一个号**,不要按阶段预分配。
| 阶段 | revision | 说明 |
| --- | --- | --- |
| P1a(已落地) | `20261002_0037` | `automation_jobs` / `automation_runs` / `login_tasks` +3 / `accounts.auto_managed` |
| P0 | 落地时取下一个 | `external_order_cache` |
| P2 / P3 / P4 | 各自落地时依次取号 | 见附录 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;
- **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 判缓存过期时会再遇到;
- **每个阶段独立可发布**,新作业/新功能一律**开关默认关闭**。
### 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]`(已定) | P0 第一步就是加依赖 + 重建镜像 |
| 只读 PG | `livehub_ro` @ `order_site-postgres-1:5432`,`.env` 已配 | 适配层直接用 `ORDER_SITE_DATABASE_URL` |
| OSS | 桶 `live-hub-assets`(私有+版本控制)、RAM `live-hub-oss`、`.env` 已配 | `oss2`;**不要用 HeadBucket** |
| 迁移 head / 测试基线 | `20261001_0036` / 235 条 | 见 C.1 |
### 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.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` 两页 → ⑥ 端到端联调(发链接 → 提交 → 校验 → 执行 → 台账) |