feat(pool): 账号自动分配与回收(租约 + 补给链 + 独立工作台)
在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条 「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。 旧路径只做并集与可选参数扩展,行为不变(有用例守着)。 - 归属与使用权分离:新增 account_leases(生成列 + 唯一索引,并发抢号由数据库裁决), assigned_to 收窄为管理员/运营的维护责任;客服可见范围扩为「名下 ∪ 租用中」 - account_pool 服务:候选筛选、两梯队排序(先花快过期的 / 先选充值少的)、 供给与成本预估、租约领取/释放/回收 - 补给链:查余额 → 充鱼翅(单次 ≤800 元,超限拆单)→ 赠精英令 → 兑换; 运行期两道闸门:_skip_when(余额已够就别再花钱)、_depends_on(前置未成功就跳过) - 转人工:鱼翅支付等 10 分钟未到账转 manual_required,租约先不还; account_lease_reaper 兜 TTL 到期 / 漏释放 / 孤儿批次 - 运营流水:业务事件补 actor_id = task.created_by;账号详情页展示操作人、渠道 与租赁记录 - 新工作台:/douyu/pool + 菜单「账号分配台」(新建,不改造旧工作台) - 修复 D1:官方补录的兑换事件漏写 subject_key(10,612 条主键为空), 会让 365 天同皮肤规则漏判 87% 的历史;补代码 + 回填脚本 - 迁移 20261007_0052;权限 account:pool_use / account:pool_admin 测试:766 passed, 6 skipped(改动前基线 727);ruff 与 tsc -b 干净。 设计稿与实施记录:docs/设计-账号自动分配与回收.md
This commit is contained in:
@@ -0,0 +1,960 @@
|
||||
# 设计:账号自动分配与回收(斗鱼 · 精英宝典优先)
|
||||
|
||||
> 状态:**已实现并通过测试**(本地 dev,未上生产)|见 §8 实施记录|2026-10-07
|
||||
> 定位:在现有「管理员分配 → 客服导入工作台 → 手动选号执行」之外**增加**一条
|
||||
> 「客服选皮肤 + 数量 → 系统分配账号 + 自动补积分 → 用完自动收回」的路径。
|
||||
> **旧代码路径一行不改**(见 §3.11)。
|
||||
>
|
||||
> 数据来源:刷新到 2026-10-07 的**生产镜像库 `douyu_login_prod`**(旧镜像 10-04 已按用户要求删除重建)。
|
||||
> 本文所有数字都是新库实测,SQL 见 [附录 A](#附录-a实测-sql)。
|
||||
>
|
||||
> 相关:[账号全生命周期](设计-账号全生命周期.md)(`account_events` 台账)、
|
||||
> [自动化与资料收集规划](自动化与资料收集规划.md) §9/§10、
|
||||
> [皮肤准入映射分析](皮肤准入映射分析.md)(判定键必须是斗鱼 `commodity_id`)
|
||||
|
||||
---
|
||||
|
||||
## 0. 本版修订点(按用户三轮答复,共 12 条)
|
||||
|
||||
**第一轮**
|
||||
|
||||
| # | 用户答复 | 对本设计的影响 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **跨归属使用不允许**;账号由**管理员/运营管理维护**,客服按需使用 | 池 = 「未分配」∪「管理员/运营名下」;**客服名下账号不进池**。因为归属本来就该是管理员/运营的,所以不存在"跨归属"。需一次归属移交(§3.1) |
|
||||
| 2 | 客服选皮肤 + **需要几个账号** | 一期**一账号一件**(`num=1`),"数量"= 账号个数 |
|
||||
| 3 | 换绑冷却要**考虑到**,但不同活动换绑时间不同,**先记录到数据库** | 新增按作用域的「可换绑时刻」列,**只记录、不做分配硬过滤**,在结果里提示(§4) |
|
||||
| 4 | **积分可以通过积分充值 API 自动充值**,可根据皮肤按需充;精英宝典仍需客服**手动开通** | 候选不再要求"积分够";分配器内建**补给链**;`elite_opened=1` 成为硬门槛(§3.3 / §3.4) |
|
||||
|
||||
**第二轮**
|
||||
|
||||
| # | 用户答复 | 对本设计的影响 |
|
||||
| --- | --- | --- |
|
||||
| 5 | 鱼翅渠道由**管理员手动切换,不能随便改**;**没开就转人工** | 池路径对 `gold_recharge_channel` **只读**,不提供切换入口、绝不回写;`supplier_api` 才走自动直充,否则生成支付码**转人工扫码**(§3.4.1) |
|
||||
| 6 | API 充值加**单次上限 800**(元),一次不得超过 | `create_gold_qr` 服务端校验 `amount <= 800`;池路径缺口金额超限时**拆单**(§3.4.2)。实测 **1 元 = 1 鱼翅**,800 元 = 8000 积分 = 6 件 1280 分套装 |
|
||||
| 7 | 客服租赁期间的**所有操作**(充值多少、API 还是微信、积分变化、兑换、开通)都要记进**账号信息** | 补 `actor_id`(= 发起任务的客服)+ `lease_id/batch_id`;实测当前 **24,502/34,462 条事件(71%)没有操作人**(§2.7、§3.14) |
|
||||
| 8 | **新建一个工作台,不是改造旧的** | 新增页面 `/douyu/pool` + 新菜单 + 新接口,按**租约批次**聚合;**不改** `handbook_scope` 取值域、不动旧工作台与 `DouyuWorkbenchAccount`(§3.13) |
|
||||
|
||||
**第三轮**
|
||||
|
||||
| # | 用户答复 | 对本设计的影响 |
|
||||
| --- | --- | --- |
|
||||
| 9 | **暂不设置**(每日累计鱼翅上限) | 该未决项关闭;只保留单次 800 元硬校验(§3.4.2),风险在 §7.3 如实登记 |
|
||||
| 10 | 移交**先在本地 dev 测试,不动生产**;本地 dev 也有测试数据 | P0 的生产移交**暂缓**;新增 §5.1 本地 dev 验证方案(实测:dev 12 个账号**全部未分配,天然就是池成员,不需要移交**) |
|
||||
| 11 | `wechat_qr` **10 分钟超时**,之后转人工 | 复用现有常量 `DOUYU_PAYMENT_POLL_SECONDS = 600`(**正好 10 分钟,不用新增配置**);超时后从"自动等待"转入"人工处理"(§3.4.1) |
|
||||
| 12 | **D1 同步修复** | D1 从"前置条件"升级为**本期必做**,纳入同一批施工(§5.2) |
|
||||
|
||||
### 第 4 条带来的数字反转
|
||||
|
||||
同一个商品(`20260120QYOOB_bag10` 套装-兔兔白日梦,1280 分),候选池实测
|
||||
(两个数同口径:CK 有效 + **精英宝典已开通** + 365 天内未兑过):
|
||||
|
||||
| 口径 | 可分配账号 |
|
||||
| --- | --- |
|
||||
| 上一版(要求"积分已够") | **13** |
|
||||
| 本版(允许自动充值) | **4,618** |
|
||||
|
||||
**瓶颈从「账号 + 积分」转移到了「鱼翅采购」**(§2.2)。
|
||||
上一版基于"积分池枯竭、候选只有十几个"得出的"这事做不起来"的结论,**作废**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
**目标**
|
||||
|
||||
1. 客服选皮肤 + 数量 → 系统分配账号、按需补积分、执行兑换、用完自动收回;
|
||||
2. 系统能回答"哪些账号兑换过什么皮肤"(台账已有,见 §2.3)。
|
||||
|
||||
**非目标**:不做抢单大厅/派单池/报酬结算(沿用
|
||||
[自动化与资料收集规划](自动化与资料收集规划.md) §10.7);不改任务执行链本身;
|
||||
不做电竞手册(§2.5)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现状核实(2026-10-07 实测)
|
||||
|
||||
### 2.1 账号与归属:**全部挂在客服名下,管理员/运营名下 0 个**
|
||||
|
||||
| 指标 | 实测值 |
|
||||
| --- | --- |
|
||||
| `accounts` 总数 | 30,518 |
|
||||
| 生命周期 | `imported` 14,832 / `active` 10,726 / `invalid` 4,792 / `manual` 168 |
|
||||
| CK 有效 | 10,683 |
|
||||
| 已分配 / 未分配 | 15,693 / 14,825 |
|
||||
| **CK 有效且未分配** | **0** |
|
||||
| 归属角色分布 | **`support` 15,693 个 / `super_admin` 0 个 / `operation` 0 个** |
|
||||
|
||||
**结论**:按你的第 1 条,现在**一个池成员都没有** —— 15,693 个账号全挂在 13 个
|
||||
`support` 角色用户(`kf_1`~`kf_11`、`yunying1`、`kefu1`)名下,单人名下 19~2,000 个。
|
||||
所以落地前必须先做一次**归属移交**(用现有的批量分配功能即可,**不需要新代码**,见 §3.1)。
|
||||
|
||||
### 2.2 补给链:**1 元 = 1 鱼翅 = 1 精英令 = 10 精英积分**(实测)
|
||||
|
||||
积分不是"账号资产",而是**按需买来的消耗品**。整条链已经全部任务化:
|
||||
|
||||
```
|
||||
人民币 ──create_gold_qr(鱼翅充值)──→ 账号鱼翅余额
|
||||
──donate_elite_gift(赠送精英令)──→ 精英积分
|
||||
──exchange_goods──→ 皮肤
|
||||
```
|
||||
|
||||
实测依据:
|
||||
|
||||
| 证据 | 值 |
|
||||
| --- | --- |
|
||||
| 充值面值 ↔ 鱼翅(近 8 笔充值事件) | `amount=48元 → balance_after=48 鱼翅`、`69→69`、`18→18` …(账号从 0 开始)→ **1 元 = 1 鱼翅** |
|
||||
| 单笔赠令样本(`douyu_tasks.result`) | `gift_count=18` → `+180 积分`,`gold_spent = −18 鱼翅` → **1 鱼翅 = 1 令 = 10 积分** |
|
||||
| 近 3 天 `gold_spent` | 1,592 笔,合计 **−192,586 鱼翅** |
|
||||
| 近 3 天 `points_credit` | 1,595 笔,合计 **+1,927,110 积分** |
|
||||
| 比值 | 1,927,110 ÷ 192,586 = **10.0 积分/鱼翅** ✓ |
|
||||
| 近 3 天 `donate_elite_gift` | 2,016 次,单次平均预期 **+1,478 积分** |
|
||||
| 近 3 天 `create_gold_qr` | 1,989 次(鱼翅补充),平均面值 **122 元** |
|
||||
|
||||
**推论**:一件 1280 分的套装 = 128 令 = 128 鱼翅 = **128 元**。
|
||||
**要钱的东西只有鱼翅,钱和积分之间是一条固定汇率的直链。**
|
||||
|
||||
⚠️ **渠道现状(对应第 5 条)**:`douyu_config.gold_recharge_channel` 生产当前是
|
||||
**`wechat_qr`(微信扫码)**,不是 `supplier_api`。实测 1,574 条充值事件的
|
||||
`detail.channel` **全部是 `wechat_qr`** —— 这正是"没开 API 就转人工"的现实状态。
|
||||
|
||||
### 2.3 兑换台账:已经可用,但有一个缺陷 D1
|
||||
|
||||
| 指标 | 10-04 镜像 | 10-07 镜像 |
|
||||
| --- | --- | --- |
|
||||
| `account_events` 总行数 | 36 | **34,462** |
|
||||
| `exchange` 事件 | 9 | **12,194** |
|
||||
| 覆盖账号 | 9 | **9,171** |
|
||||
| `source=official_sync` | 2 | **10,612**(全部 `scope=elite`) |
|
||||
|
||||
即「哪些账号兑换过什么皮肤」**现在真的能从数据库查到**(一键查询把斗鱼官方记录补成了事件)。
|
||||
|
||||
**缺陷 D1:官方同步事件没写 `subject_key`。**
|
||||
[`record_official_exchange`](../web/backend/services/business_events.py#L338) 设了
|
||||
`subject_type="goods"`,却**没传 `subject_key`**,这批事件的主键列全是空串:
|
||||
|
||||
| source | 非空 `subject_key` | 事件数 |
|
||||
| --- | --- | --- |
|
||||
| `official_sync` | **0** | 10,612 |
|
||||
| `douyu_task` | 1,582 | 1,582 |
|
||||
|
||||
而 365 天规则和索引 `ix_account_events_ledger (account_id, event_type, subject_key, occurred_at)`
|
||||
([models.py:879](../web/backend/models.py#L879))正是按 `subject_key = commodity_id` 设计的
|
||||
([models.py:857](../web/backend/models.py#L857))。**不修,365 天规则会漏掉 87% 的历史**。
|
||||
实测差异(为排除"手册是否开通"的干扰,下面两个数都不加 `elite_opened` 条件):
|
||||
按 `detail.commodity_id` 算候选 **14 个**,按 `subject_key` 算 **16 个**(多放行 2 个已兑过的账号)。
|
||||
|
||||
修复成本极低:代码补一行 + 一条回填 UPDATE(`detail.commodity_id` 是全的)。
|
||||
且 `subject_type`/`subject_key` **目前没有任何 WHERE 使用**(只在时间线接口被原样 SELECT,
|
||||
[accounts.py:1469](../web/backend/routers/accounts.py#L1469)),统一口径不破坏现有功能。
|
||||
|
||||
### 2.4 换绑冷却:现在存的是**相对秒数**,落库即过期
|
||||
|
||||
| 字段 | 语义 | 问题 |
|
||||
| --- | --- | --- |
|
||||
| `accounts.change_role_wait_time` | 精英宝典换绑**剩余秒数** | 是查询那一刻的相对值,落库后无法判断"什么时候能换绑" |
|
||||
| `esports_change_role_wait_time` | 电竞手册剩余秒数 | 同上 |
|
||||
| `accounts.esports_can_change_time` | 电竞手册**可换绑时刻**(unix 秒) | ✅ 已经是绝对值,可直接用 |
|
||||
|
||||
写入点:[douyu_runner_bind.py:250](../web/backend/services/douyu_runner_bind.py#L250)(精英)与
|
||||
[:266](../web/backend/services/douyu_runner_bind.py#L266)(电竞)。
|
||||
好在 `query_change_bind_time`(一键查询换绑时间)近 6 天跑了 6,724 次,数据本身是新鲜的。
|
||||
|
||||
实测(CK 有效账号):`change_role_wait_time > 0` **8,939** 个 / `= 0` 843 个 / `null` 901 个。
|
||||
|
||||
### 2.5 其他作用域
|
||||
|
||||
| 作用域 | 现状 |
|
||||
| --- | --- |
|
||||
| **电竞手册** | CK 有效账号里 `esports_opened=1` **0 个**、`esports_points` 已知的 10,683 个**全为 0** → 一期不做 |
|
||||
| 和平小店 | `xpd_balance` 已知 192 个,>0 的 178 个 → 二期 |
|
||||
| 商品目录 | 已在库:精英 78 个 / 电竞 88 个 / 小店 288 个斗鱼商品 |
|
||||
|
||||
### 2.6 竞争环境
|
||||
|
||||
- 近 3 天很忙:`query_limited_goods` 19,049、`query_account_overview` 11,402、
|
||||
`get_bind_qr` 7,854、`donate_elite_gift` 5,062、`create_gold_qr` 5,053、
|
||||
`create_elite_qr` 4,732、`exchange_goods` 3,756。
|
||||
- `exchange*` 失败/错误共 **769 条**:
|
||||
|
||||
| 失败原因 | 条数 | 能否本地预筛 |
|
||||
| --- | --- | --- |
|
||||
| 已达兑换上限 / 已达限量 | 279 | 修完 D1 后可近似 |
|
||||
| 点击太快(限流) | 198 | 不能(降并发) |
|
||||
| 积分不足 | 149 | ✅ **本版可自动补** |
|
||||
| 活动火爆 | 41 | 不能(重试) |
|
||||
| 需要开通精英手册 | 30 | ✅(`elite_opened`) |
|
||||
| 无库存 / 活动未开启 / 鱼翅不足 | 28 | 部分可 |
|
||||
|
||||
### 2.7 运营流水的现状:**71% 的事件没有"谁做的"**
|
||||
|
||||
第 7 条要求"客服租赁期间的所有操作都记进账号信息"。现有台账的基础是好的,但缺一个关键字段:
|
||||
|
||||
| 项 | 实测 |
|
||||
| --- | --- |
|
||||
| `account_events` 总行数 | 34,462 |
|
||||
| **`actor_type` 为空** | **24,502(71%)** |
|
||||
| `actor_type='system'` | 7,153 |
|
||||
| `actor_type='user'` | 2,807 |
|
||||
| 充值事件的 `detail.channel` | **已记**(1,574 条,全部 `wechat_qr`) |
|
||||
| 充值事件的 `amount` / `balance_after` | **已记** |
|
||||
| 积分变化(`points_credit` / `gold_spent`) | **已记**(含金额与余额) |
|
||||
| 兑换(`exchange`)/ 开通(`elite_opened`) | **已记** |
|
||||
|
||||
**结论**:金额、渠道、积分变化、商品这些**都在**;缺的是 **"哪个客服"** ——
|
||||
业务事件由后台 runner 线程发出,而 `record_recharge` / `record_exchange` 等
|
||||
**都没有 `actor_id` 参数**(已 grep 确认),所以 71% 的流水查不出责任人。
|
||||
这正是第 7 条要补的东西(方案见 §3.14)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 3.1 归属模型:管理员/运营维护,客服租用
|
||||
|
||||
```
|
||||
账号 ──assigned_to──→ 管理员 / 运营 (维护责任:CK 重登、开通、进货)
|
||||
←──lease─────── 客服(临时使用权)
|
||||
```
|
||||
|
||||
**池成员定义**(严格满足"不跨归属"):
|
||||
|
||||
```
|
||||
池 = accounts.assigned_to IS NULL
|
||||
∪ accounts.assigned_to IN (role ∈ {super_admin, operation} 的用户)
|
||||
```
|
||||
|
||||
客服名下的账号**不进池**,也不会被别的客服领用。
|
||||
|
||||
**生产落地前的一次归属移交**(**本期暂缓**,见 §5.1;dev 不需要,因为 dev 账号本来就全部未分配):
|
||||
|
||||
- 用现有 `POST /api/accounts/batch-assign`([accounts.py:790](../web/backend/routers/accounts.py#L790))
|
||||
把账号从 `kf_*` 名下移交给运营/管理员,**不需要新代码**;
|
||||
- 建议**先移交一小批**(如 500 个 CK 有效账号)试运行池,验证后再全量移交 10,683 个;
|
||||
- 移交会写 `assigned` 事件(`emit_bulk`),时间线留痕,符合现有审计约定;
|
||||
- ⚠️ **副作用要提前说清**:移交后客服在旧路径(工作台导入 / 按账号选择)里**看不到任何账号**
|
||||
—— 这正是"客服不持有账号"的应有结果,但意味着**池路径是客服唯一可用的入口**。
|
||||
|
||||
### 3.2 表结构:`account_leases`
|
||||
|
||||
```sql
|
||||
CREATE TABLE account_leases (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
account_id INT NOT NULL,
|
||||
holder_user_id INT NOT NULL, -- 租用的客服
|
||||
scope VARCHAR(16) NOT NULL, -- elite / esports / peace
|
||||
purpose VARCHAR(24) NOT NULL DEFAULT 'exchange',
|
||||
commodity_id VARCHAR(64) NOT NULL DEFAULT '', -- 本次要兑的斗鱼商品
|
||||
batch_id VARCHAR(64) NOT NULL DEFAULT '', -- 关联 douyu_tasks.batch_id
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active / released / expired
|
||||
leased_at DATETIME NOT NULL,
|
||||
expires_at DATETIME NOT NULL, -- TTL 兜底
|
||||
released_at DATETIME NULL,
|
||||
release_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||
-- 「一个账号同时只能有一个有效租约」的数据库级保证:
|
||||
-- 非 active 时为 NULL,MySQL 唯一索引允许多个 NULL → 历史记录不受限。
|
||||
active_account_id INT GENERATED ALWAYS AS
|
||||
(CASE WHEN status = 'active' THEN account_id ELSE NULL END) STORED,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_lease_active_account (active_account_id),
|
||||
KEY ix_leases_holder (holder_user_id, status),
|
||||
KEY ix_leases_batch (batch_id),
|
||||
KEY ix_leases_reap (status, expires_at),
|
||||
CONSTRAINT fk_lease_account FOREIGN KEY (account_id) REFERENCES accounts(id),
|
||||
CONSTRAINT fk_lease_holder FOREIGN KEY (holder_user_id) REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
用「生成列 + 唯一索引」而不是应用层"先查后插",是为了**让并发抢号由数据库裁决**:
|
||||
两个事务抢同一账号必有一个撞唯一键,捕获后换下一个候选。
|
||||
|
||||
> SQLAlchemy:`mapped_column(Integer, Computed("CASE WHEN status='active' THEN account_id END", persisted=True))`。
|
||||
> 迁移编号按落地顺序取(当前最大 `20261004_0051`),**不预留**。
|
||||
|
||||
### 3.3 候选筛选与两梯队排序
|
||||
|
||||
**筛选**(一期 `scope='elite'`):
|
||||
|
||||
| 条件 | 字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 池成员 | `assigned_to IS NULL OR assigned_to ∈ 管理员/运营` | §3.1 |
|
||||
| 生命周期可用 | `status='active' AND ck_check_status='valid'` | 7,091 个已开通的账号满足 |
|
||||
| 持有 CK | `cookie <> ''` | 执行硬前提 |
|
||||
| **精英宝典已开通** | **`elite_opened = 1`** | **硬门槛**:开通要人工扫码(`create_elite_qr` 生成支付码),系统不能自动开通 |
|
||||
| 365 天未兑过 | `account_events` 无同 `commodity_id` 事件 | **依赖 D1 修复** |
|
||||
| 无有效租约 | `account_leases.active_account_id` | 并发互斥 |
|
||||
| 未被排除 | `auto_managed = 1` | 已有的"人工维护账号"开关([models.py:154](../web/backend/models.py#L154)) |
|
||||
|
||||
换绑冷却**不作为过滤条件**(按你的第 3 条),只在返回里提示。
|
||||
|
||||
**两梯队排序**(`points` 是存量积分,`score` 是商品价):
|
||||
|
||||
| 梯队 | 定义 | 排序 | 目的 |
|
||||
| --- | --- | --- | --- |
|
||||
| **A · 免充值** | `points >= score` | ① `points_expire_at` 升序(NULL 最后)② `points` 升序 | 先把**快到期的存量**花掉;再用小余额,把大余额留给贵商品 |
|
||||
| **B · 需充值** | `points < score` | ① 需补的精英令数升序 ② `points` 降序 | **少买鱼翅**;先用存量多的 |
|
||||
|
||||
纯函数实现,便于反向验证(参照 [自动化与资料收集规划](自动化与资料收集规划.md) §11.7)。
|
||||
|
||||
### 3.4 补给链:缺多少充多少
|
||||
|
||||
对每个 B 梯队账号:
|
||||
|
||||
```
|
||||
need_points = score - points
|
||||
gift_count = ceil(need_points / 10) // 1 精英令 = 10 积分
|
||||
gold_needed = gift_count // 1 精英令 = 1 鱼翅
|
||||
```
|
||||
|
||||
执行顺序(全部是现有任务类型,**runner 不改**):
|
||||
|
||||
```
|
||||
① 若 gold_balance < gold_needed → create_gold_qr(鱼翅充值) ← 见 §3.4.1 / §3.4.2
|
||||
② donate_elite_gift(gift_count) → 短轮询确认积分到账(最多若干次)
|
||||
⚠️ 斗鱼积分同步有延迟,未确认时要如实标注,不能假装到账
|
||||
③ exchange_goods(commodity_id, num=1)
|
||||
```
|
||||
|
||||
#### 3.4.1 渠道:只读配置,没开 API 就转人工(第 5 条)
|
||||
|
||||
**硬规则:程序绝不修改 `gold_recharge_channel`。** 该配置由管理员在
|
||||
`PUT /api/douyu/config`(`douyu:config` 权限)手动切换,池路径**只读不写**,
|
||||
也不提供任何切换入口(池页面/池接口都不暴露它)。
|
||||
|
||||
| 当前渠道 | 池路径行为 |
|
||||
| --- | --- |
|
||||
| `supplier_api` | 自动直充(`create_gold_qr` 走供应商下单 + 轮询回调),全自动 |
|
||||
| `wechat_qr`(**生产当前值**) | 生成支付码 → 任务进入 `running` 并提示"等待扫码支付" → **转人工**:通知发起该批次的客服去扫 → 到账后自动继续 ②③ |
|
||||
| 其他/异常 | 一律当作"转人工"处理,**不猜、不降级、不自动切渠道** |
|
||||
|
||||
转人工的两个边界要定清楚:
|
||||
|
||||
1. **等待时限 = 10 分钟**(第 11 条)。现有常量
|
||||
`DOUYU_PAYMENT_POLL_SECONDS = 600`
|
||||
([douyu_runner_core.py:38](../web/backend/services/douyu_runner_core.py#L38))
|
||||
**正好是 600 秒**,且 `create_gold_qr` 与开通链路都已经在用它
|
||||
([douyu_runner_gold.py:67](../web/backend/services/douyu_runner_gold.py#L67))——
|
||||
所以**不需要新增配置项,直接复用**。语义拆开写清楚:
|
||||
- 0~10 分钟:系统轮询等待到账(`DOUYU_PAYMENT_POLL_INTERVAL = 5` 秒一次);
|
||||
- 超过 10 分钟未到账:任务从"自动等待"转入**人工处理**状态(`manual_required`),
|
||||
**不再自动轮询**,池工作台的「待人工区」醒目标注;
|
||||
- 转入人工后租约**不自动释放**(账号已挂在这单上),由客服三选一:
|
||||
继续等待 / 重新生成支付码 / 放弃并释放(`released(manual_giveup)`)。**不自动放弃**,
|
||||
避免误杀正在扫码的单子。
|
||||
2. **通知方式**:一期只在池工作台内显示醒目状态 + 任务日志;不引入外部通知渠道
|
||||
(沿用"务实、克制"的原则)。
|
||||
|
||||
#### 3.4.2 单次充值上限 800 元(第 6 条)
|
||||
|
||||
实测 **1 元 = 1 鱼翅**,所以上限的换算关系很直观:
|
||||
|
||||
```
|
||||
800 元 = 800 鱼翅 = 8000 精英积分 = 6 件 1280 分的套装
|
||||
```
|
||||
|
||||
**校验点**:`create_gold_qr` 读 `payload.amount` 的地方
|
||||
([douyu_runner_gold.py:250](../web/backend/services/douyu_runner_gold.py#L250),
|
||||
`amount` 单位是**元**,`pay_amount = Decimal(amount)`,[同文件:340](../web/backend/services/douyu_runner_gold.py#L340))。
|
||||
|
||||
| 层 | 动作 |
|
||||
| --- | --- |
|
||||
| 服务端(**必须**) | `if amount > 800: 拒绝并回报`,写进任务 `message`(如"单次充值不得超过 800 元,本次请求 1200 元") |
|
||||
| 池路径分配时 | 算出的 `gold_needed > 800` 时**拆单**:`ceil(gold_needed / 800)` 笔,每笔 ≤ 800,按序执行 |
|
||||
| 前端 | 输入框限 1~800 并提示;**但这只是体验,服务端校验才是准的** |
|
||||
|
||||
同一批次内多笔充值要**串行**(前一笔到账再发下一笔),避免同账号并发下单。
|
||||
单账号一次兑换需要的鱼翅通常是 128 这个量级,**800 的额度基本不会触发拆单**,
|
||||
只有批量场景(一次给多账号补)才会;那种情况应该**按账号分别建单**,而不是一个账号充 8000。
|
||||
|
||||
> 注意:上限是**单次**的。池路径不允许用"多笔小额"绕过人工审批的意图 ——
|
||||
> 一期不加"单日累计上限",但如果踩到风险,加一个每日累计上限是自然的下一步(§7.1)。
|
||||
|
||||
**"缺多少充多少"的边界**:`gift_count` 按精确缺口算,不做多余的整额充值;
|
||||
补进来的积分不浪费。同时**优先用 A 梯队**(存量积分)—— 存量积分是已经花过钱的沉没成本,
|
||||
尤其是有 `points_expire_at` 的(全库 2,538 个账号有积分到期时间;
|
||||
CK 有效且有积分的账号里,7 天内到期 23 个)。
|
||||
|
||||
实测规模(兔兔白日梦,把 4,618 个可用账号全部兑完):
|
||||
|
||||
| 指标 | 值 |
|
||||
| --- | --- |
|
||||
| 可用账号 | 4,618 |
|
||||
| 总缺口 | 5,869,775 积分 |
|
||||
| 需要精英令 = 需要鱼翅 = **元** | **586,978** |
|
||||
| 当前有效 CK 账号的鱼翅总额 | 4,274(够兑约 33 件) |
|
||||
| 当前鱼翅 ≥128 的账号 | 8 个 |
|
||||
|
||||
**结论**:鱼翅靠"现有余额"远远不够,必须**持续采购**(近 3 天实际补了 192,586 鱼翅 ≈ 1,504 件)。
|
||||
所以池接口必须**预估鱼翅成本(元)**并让客服/运营看到(§3.8),否则会出现"分了号但充不起分"。
|
||||
|
||||
### 3.5 原子领取
|
||||
|
||||
```
|
||||
候选列表(已排序,取 N × 1.3 作为备选)
|
||||
for 候选 account:
|
||||
INSERT INTO account_leases (account_id, holder_user_id, status='active', ...)
|
||||
撞 uq_lease_active_account → 跳过,继续下一个
|
||||
成功 → 计入本次分配
|
||||
直到 分配数 == N 或 候选耗尽
|
||||
```
|
||||
|
||||
单事务提交;单个候选失败不回滚已领到的租约。
|
||||
|
||||
### 3.6 任务编排与"客服能看到自己的任务"
|
||||
|
||||
分配成功后复用 `create_douyu_planned_tasks`([douyu_service.py:268](../web/backend/services/douyu_service.py#L268))
|
||||
按上面 ①②③ 建**一个批次**(同一 `batch_id`,按序执行)。
|
||||
|
||||
⚠️ **一个必须一并处理的集成问题**:任务列表对客服的过滤是
|
||||
`DouyuTask.account_assigned_to == current.id`([douyu.py:236](../web/backend/routers/douyu.py#L236)),
|
||||
而该字段在创建时写的是**账号归属**([douyu_service.py:299](../web/backend/services/douyu_service.py#L299))。
|
||||
池化后账号归属是运营/管理员 → **客服提交的任务自己在列表里看不到**。
|
||||
|
||||
**方案**:池路径下把 `account_assigned_to` 写成**租用人(`holder_user_id`)**。
|
||||
理由:这列本来就是"账号归属客服冗余字段,配合复合索引消除任务列表全表 filesort",
|
||||
其实际作用是"谁能在列表里看到这条任务";改写成租用人**不新增列、不改查询、不动索引**
|
||||
(`ix_douyu_tasks_account_assigned_to_id` 照常命中)。
|
||||
实现上给 `create_douyu_planned_tasks` 加一个**可选参数** `account_assigned_to_override`,
|
||||
默认仍取 `account.assigned_to` → **旧路径行为完全不变**。
|
||||
|
||||
> 遗留注意:管理员后续改归属时会批量重写 `account_assigned_to`
|
||||
> ([accounts.py:845](../web/backend/routers/accounts.py#L845)),会连带改掉历史池任务的可见人。
|
||||
> 一期接受这个偏差(任务已执行完,历史可见性影响很小),若需严格隔离再加一个布尔列区分。
|
||||
|
||||
### 3.7 失败补位
|
||||
|
||||
预筛挡不住"已达兑换上限/无库存/活动未开启"(服务端最终闸门)。
|
||||
任务终态为失败且原因属于**可补位类**(已达上限 / 未开通手册 / 无库存 / 鱼翅不足)时:
|
||||
释放该账号租约 → 从备选再补一个 → 重跑 ①②③。
|
||||
补位次数上限 = 本批次需求量,**不做无限重试**;补不满时**如实报告**(§3.8)。
|
||||
|
||||
本版特例:**"积分不足"不再需要补位** —— 因为已经在 ② 里按缺口充过了;
|
||||
若仍然报积分不足,说明充值未确认或接口延迟,应作为异常上报而不是静默跳过。
|
||||
|
||||
### 3.8 接口
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/douyu/pool/supply?scope=elite&commodity_id=...` | **不锁号**,预览供给与成本 |
|
||||
| `POST` | `/api/douyu/pool/exchange` | 提交 `{scope, commodity_id, quantity, allow_topup, allow_fallback}` |
|
||||
| `GET` | `/api/douyu/pool/leases?mine=1` | 我当前持有的租约 |
|
||||
| `POST` | `/api/douyu/pool/leases/{id}/release` | 手动释放 |
|
||||
| `POST` | `/api/douyu/pool/leases/release-batch` | 超管:强制释放 |
|
||||
|
||||
`GET /supply` 返回(数据是实测真值,可直接当验收基准):
|
||||
|
||||
```json
|
||||
{
|
||||
"commodity": {"commodity_id": "20260120QYOOB_bag10", "name": "套装-兔兔白日梦", "score": 1280},
|
||||
"zero_topup": 13,
|
||||
"after_topup": 4618,
|
||||
"blocked": {
|
||||
"elite_not_opened": 3592,
|
||||
"redeemed_within_365d": 2473,
|
||||
"no_valid_ck": 19835
|
||||
},
|
||||
"cost": {"est_gifts": 586978, "est_yuan": 586978},
|
||||
"cooling": {"role_change_in_cooldown": 8939, "changeable_now": 843},
|
||||
"recharge_channel": "wechat_qr",
|
||||
"recharge_note": "当前为微信扫码渠道,充值需人工支付"
|
||||
}
|
||||
```
|
||||
|
||||
> `cost.est_yuan` 单位是**元**(1 元 = 1 鱼翅)。`recharge_channel` 是**只读**回显,
|
||||
> 让客服在提交前就知道"这单要不要我去扫码"。
|
||||
|
||||
`POST /exchange` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"batch_id": "a1b2c3d4",
|
||||
"requested": 5, "allocated": 3, "shortfall": 2,
|
||||
"accounts": [
|
||||
{"account_id": 123, "tier": "A", "points": 1500, "topup_gifts": 0, "topup_yuan": 0,
|
||||
"recharge_orders": [],
|
||||
"role_change_available_at": "2026-10-10T08:00:00Z"},
|
||||
{"account_id": 456, "tier": "B", "points": 300, "topup_gifts": 98, "topup_yuan": 98,
|
||||
"recharge_orders": [{"amount": 98, "channel": "wechat_qr", "state": "awaiting_payment"}]}
|
||||
],
|
||||
"reasons": ["可用账号不足:满足条件仅 3 个"]
|
||||
}
|
||||
```
|
||||
|
||||
> `topup_yuan > 800` 时 `recharge_orders` 会是**多笔**(每笔 ≤800,串行执行,§3.4.2)。
|
||||
|
||||
### 3.9 自动收回
|
||||
|
||||
两个触发点,缺一不可:
|
||||
|
||||
1. **即时释放**:`_mark_task` 写终态时调 `release_by_task(db, task.id)`
|
||||
([douyu_runner_core.py:208](../web/backend/services/douyu_runner_core.py#L208))—— 覆盖 99%;
|
||||
2. **兜底作业** `account_lease_reaper`:在调度器注册
|
||||
([scheduler.py:92](../web/backend/services/automation/scheduler.py#L92)),处理
|
||||
`expires_at < now()`、批次已不在内存注册表(进程重启)、任务已终态但没释放成功。
|
||||
建议 `run_interval_seconds = 60`、`batch_size = 500`、`enabled = true`。
|
||||
|
||||
**注意**:收回是"账面收回",**不代表账号马上能给下一单用** —— 换绑冷却仍然存在(§4)。
|
||||
一期只记录 + 提示,不拦截。
|
||||
|
||||
### 3.10 状态机
|
||||
|
||||
```
|
||||
客服提交(commodity_id, 数量 N) ← 只读渠道:supplier_api / wechat_qr
|
||||
│
|
||||
├─ A 梯队够 N 个 → 直接建任务
|
||||
├─ A 不够、B 凑 → 建任务并在批次内插入充值链
|
||||
├─ 总数不够 → 返回 shortfall,不建单
|
||||
│
|
||||
├─ 领取租约(active, TTL 60min) → 建任务(batch_id)
|
||||
│
|
||||
├─ ① 充值鱼翅
|
||||
│ ├─ supplier_api ──→ 自动直充,轮询到账
|
||||
│ └─ wechat_qr ──→ 生成支付码 → 【待人工扫码】
|
||||
│ ├─ 10 分钟内到账 → 继续
|
||||
│ └─ 超 10 分钟未到账 → manual_required(转人工,停止轮询)
|
||||
│ ├─ 继续等待 / 重新生成码
|
||||
│ └─ 客服点"放弃" → released(manual_giveup)
|
||||
├─ ② donate_elite_gift(gift_count)
|
||||
├─ ③ exchange_goods(commodity_id, num=1)
|
||||
│
|
||||
├─ 成功 ──→ released(task_success)
|
||||
├─ 失败(可补位) ──→ released(ineligible) → 补位 → 回到"领取"
|
||||
├─ 失败(不可补位) ──→ released(task_failed)
|
||||
└─ 超时/重启 ──→ reaper 置 expired
|
||||
```
|
||||
|
||||
### 3.11 与旧路径的关系(增加,不替换)
|
||||
|
||||
| 项 | 处理 |
|
||||
| --- | --- |
|
||||
| `assigned_to` / `account:view_assigned` | **保留**,语义收窄为维护责任 |
|
||||
| 工作台导入、手动选号兑换 | **代码一行不动**(客服名下无号时自然用不了,见 §3.1 的副作用说明) |
|
||||
| 旧工作台页面 / 路由 / 菜单 | **一行不动**;新工作台是新增页面与新增接口(§3.13) |
|
||||
| `handbook_scope` 取值域 | **不扩**(仍 `elite`/`esports`/`peace`),新工作台靠租约批次聚合 |
|
||||
| `DouyuWorkbenchAccount`(导入账号机制) | **不动**,新工作台不用它 |
|
||||
| `create_douyu_planned_tasks` | 只**加**可选参数,默认行为不变 |
|
||||
| `_visible_tasks_query` | **不改**(靠写 `account_assigned_to` 解决,见 §3.6) |
|
||||
| `gold_recharge_channel` | **只读**;不新增任何切换入口,程序不回写(§3.4.1) |
|
||||
| `account_events` / `audit_logs` | 复用,不新增台账;只补 `actor_id` 与 `detail` 字段(§3.14) |
|
||||
|
||||
### 3.12 权限
|
||||
|
||||
| 权限点 | 说明 | super_admin | operation | support |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `account:pool_use` | 使用自动分配 | ✅ | ✅ | ✅ |
|
||||
| `account:pool_admin` | 查看全局池 / 强制释放 | ✅ | ✅ | ❌ |
|
||||
|
||||
### 3.13 新工作台(**新建**,不改旧的 —— 第 8 条)
|
||||
|
||||
新增一个独立页面,**不动**旧的精英宝典/电竞手册工作台:
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 路由 | `/douyu/pool`([App.tsx](../web/frontend/src/App.tsx) 新增 `<Route>`,旧路由一行不动) |
|
||||
| 菜单 | [MainLayout.tsx](../web/frontend/src/layouts/MainLayout.tsx) 斗鱼分组下新增「账号分配台」,按 `account:pool_use` 显示 |
|
||||
| 页面 | **新文件** `web/frontend/src/pages/DouyuPoolPage.tsx`(不复用、不改造 `DouyuTasksPage.tsx`) |
|
||||
| 接口 | **新增** `/api/douyu/pool/*`(§3.8),**不复用也不修改** `GET /api/douyu/tasks` |
|
||||
|
||||
**关键取舍:新工作台不碰 `handbook_scope`。**
|
||||
|
||||
任务的业务归属仍然是精英宝典,所以 `handbook_scope` 继续写 `'elite'` ——
|
||||
现有代码里这个字段的取值域被硬编码在多处正则与映射表里
|
||||
([douyu.py:474](../web/backend/routers/douyu.py#L474)、
|
||||
[douyu.py:741](../web/backend/routers/douyu.py#L741)、
|
||||
`DOUYU_HANDBOOK_TASK_TYPES`),加一个新 scope 要连带动校验、映射、旧任务兼容,
|
||||
收益却只是"换个标签"。改为:
|
||||
|
||||
- 新工作台按 **租约批次** 聚合视图:`account_leases.holder_user_id = 我` → `batch_id` → 任务与结果;
|
||||
- 任务仍属于 `elite` scope,因此在旧的精英宝典页面里**也能看到**(这没问题,反而方便对账);
|
||||
- 新工作台**不使用** `DouyuWorkbenchAccount`(那是"导入账号"的旧机制),账号集合完全由租约决定 ——
|
||||
两套机制互不干扰。
|
||||
|
||||
新工作台页面内容:
|
||||
|
||||
1. **发起区**:皮肤下拉(`douyu_goods_snapshot`,接口已有 [douyu.py:602](../web/backend/routers/douyu.py#L602))
|
||||
\+ 数量 + 实时显示「可直接兑 N 个 / 充值后可兑 M 个 / 预估 X 元」;
|
||||
2. **我的批次区**:本次租赁的账号、每个账号的执行阶段(充值中/待扫码/赠令中/兑换中/成功/失败)、
|
||||
失败原因、`shortfall`;
|
||||
3. **待人工区**:`wechat_qr` 渠道下等待扫码支付的账号 + 支付码入口(转人工的唯一入口);
|
||||
4. **我的租约区**:租约的 `expires_at`、`role_change_available_at`("X 时间后可换绑")、手动释放按钮。
|
||||
|
||||
### 3.14 运营流水进账号信息(第 7 条)
|
||||
|
||||
目标:**在账号详情里能完整看到"某个客服租用这个账号期间做了什么"** ——
|
||||
充值多少、走的 API 还是微信、积分变化多少、兑换了什么、开通了什么。
|
||||
|
||||
**要补的(实测缺口见 §2.7)**:
|
||||
|
||||
| 动作 | 内容 |
|
||||
| --- | --- |
|
||||
| 事件补操作人 | 给 `record_recharge` / `record_exchange` / `record_points_credit` / `record_points_expire` / `record_bind`(含 `elite_opened`)加 `actor_type='user'` / `actor_id` 参数;**取值 = `task.created_by`**(发起该任务的用户)。池路径下 `created_by` 就是租用的客服,天然正确 |
|
||||
| 事件挂租约 | `detail` 里补 `lease_id` / `batch_id`,把"一次租赁内的所有动作"串起来 |
|
||||
| 渠道 | ✅ 已在 `detail.channel`(实测 1,574 条充值全部有值),无需改 |
|
||||
| 新增列 | 无需 —— `actor_type` / `actor_id` 列已存在([models.py:868](../web/backend/models.py#L868)),只是没人写 |
|
||||
|
||||
**账号详情页要能看到的**(都属于"账号信息"):
|
||||
|
||||
| 展示 | 数据来源 |
|
||||
| --- | --- |
|
||||
| 时间线每条带「操作人」 | `account_events.actor_id` → `users.username` |
|
||||
| 充值条目标注「API 直充 / 微信扫码」+ 金额 | `detail.channel` + `amount` |
|
||||
| 积分变化 | `points_credit`(+)/ `exchange`、`points_expire`(−)+ `balance_after` |
|
||||
| 兑换了什么 | `exchange` 事件的 `detail.commodity_name` / `commodity_id` |
|
||||
| 开通了什么 | `elite_opened` 事件 |
|
||||
| **租赁记录区块**(新增) | `account_leases` 按账号列出:谁、何时租、何时释放、何因释放、关联批次 |
|
||||
|
||||
**接口改动**:`GET /api/accounts/{id}/events` 增加 `actor_id` 筛选参数,
|
||||
并在返回里带上 `actor_name`(联表 `users`);不改现有字段与分页行为。
|
||||
|
||||
**索引**:`account_events` 加 `(actor_id, occurred_at)` ——
|
||||
"某客服这几天都做了什么"是管理刚需,且现在没有可用索引。
|
||||
(`actor_id` 是已有列,只加索引,不动表结构。)
|
||||
|
||||
**审计三处一致**:`account_leases`(谁何时领了哪个号、何因释放)、
|
||||
`account_events`(业务事实,本条补齐操作人)、`audit_logs`(池操作动作)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 换绑冷却的记录方案(按第 3 条:记录,不拦截)
|
||||
|
||||
**问题**:`change_role_wait_time` 是"剩余秒数",落库即过期,无法回答"什么时候能换绑"。
|
||||
**不同活动换绑时间不同** → 必须**按作用域分别记录**。
|
||||
|
||||
| 作用域 | 现状 | 本设计 |
|
||||
| --- | --- | --- |
|
||||
| 精英宝典 | `change_role_wait_time`(相对秒) | **新增 `role_change_available_at` DATETIME** = 写入时刻 + `wait_time`;原列**保留不动**(接口/schema 原文照旧) |
|
||||
| 电竞手册 | 已有 `esports_can_change_time`(unix 秒,绝对值) | **复用,不新增**(避免开副本) |
|
||||
| 和平小店 | 无 | 一期不做 |
|
||||
|
||||
**写入点**:`_apply_bind_info_to_account`([douyu_runner_bind.py:250](../web/backend/services/douyu_runner_bind.py#L250))
|
||||
在写 `change_role_wait_time` 的同一处,同时写 `role_change_available_at`。
|
||||
`query_change_bind_time`(一键查询换绑时间,近 6 天 6,724 次)会持续刷新,数据自然保持新鲜。
|
||||
|
||||
**使用方式**:`GET /supply` 与 `POST /exchange` 的返回里带上该时刻,
|
||||
前端提示"该账号 X 时间后可换绑";**不参与候选过滤**(§7.2 未决)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 施工顺序
|
||||
|
||||
### 5.1 本地 dev 验证(第 10 条:本期就做,不动生产)
|
||||
|
||||
**环境实测**(dev 库 `douyu_login`,只读查询):
|
||||
|
||||
| 项 | 实测 |
|
||||
| --- | --- |
|
||||
| 迁移版本 | `20261004_0051`(与代码同头,**无需先升版本**) |
|
||||
| 账号 | 12 个 |
|
||||
| **已分配账号** | **0 个(`assigned_to` 全为 NULL)** |
|
||||
| CK 有效 | 4 个 |
|
||||
| 已开通精英宝典 | 3 个 |
|
||||
| 商品快照 | 43 个(最低 **10 分** 精英星光 / **20 分** 荣耀勋章礼包) |
|
||||
| 用户 | `admin`、`live_yml`(super_admin)、`kf1_test`(support) |
|
||||
| 工作台账号(旧机制) | 5 行 |
|
||||
| `account_leases` 表 | **不存在**(预期,迁移还没做) |
|
||||
|
||||
**关键结论:dev 上根本不需要做"归属移交"。**
|
||||
按 §3.1 的池定义(未分配 ∪ 管理员/运营名下),这 12 个账号**本来就全是池成员**。
|
||||
移交是**生产独有**的动作(生产是 15,693 个账号全挂在 `support` 名下)。
|
||||
所以"先本地 dev 测试"这条路是通的,而且省掉了最麻烦的一步。
|
||||
|
||||
**可用于验证的候选(3 个)**:
|
||||
|
||||
| 账号 id | 昵称 | points | elite_opened | role_bind | 能覆盖的分支 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 61 | 用户6966677706 | 300 | 1 | bound | **A 梯队**(用 20 分商品免充值) |
|
||||
| 60 | 冰焰燃情 | 2 | 1 | bound | **B 梯队**(补 2 令兑 20 分商品) |
|
||||
| 62 | 用户80180471 | 0 | 1 | bound | B 梯队(补 2 令) |
|
||||
|
||||
用 **20 分商品**就能在本地同时验证 A/B 两梯队;账号 61 还有 bag10 的兑换历史,
|
||||
可用于验证 **365 天规则**。
|
||||
|
||||
⚠️ **两个必须先知道的本地差异**:
|
||||
|
||||
1. **`gold_recharge_channel` 本地是 `supplier_api`,生产是 `wechat_qr`**。
|
||||
也就是本地默认走**自动直充**,**测不到"转人工"分支**。
|
||||
要验证 §3.4.1 的 10 分钟超时→转人工,得先把 **dev 库**的该配置改成 `wechat_qr`
|
||||
(本地库随便改;**生产绝不动**)。这是本地与生产最大的环境差异。
|
||||
2. **dev 里有 2 组重复的官方兑换事件**(账号 61 的 `bag10`、`bag353` 各两条)。
|
||||
根因是幂等键格式变更过:
|
||||
- 旧:`official_exchange:{账号}:{商品}:{时间}`(dev 里 id 14/15)
|
||||
- 新:`official_exchange:{scope}:{账号}:{商品}:{时间}`(dev 里 id 23/24)
|
||||
|
||||
**生产没有这个问题**(已核实:10,612 条全部是新格式,`affected_accounts = 0`)。
|
||||
但本地断言"兑换**条数**"时要么先清掉这 2 条,要么只断言"是否兑换过"的布尔值。
|
||||
|
||||
**测试库 `douyu_login_test` 是空的(0 个账号)** —— 按 [AGENTS.md](../AGENTS.md),
|
||||
测试套件用这个库并由用例自己造数据,**不要**拿 dev 或生产镜像跑测试套件。
|
||||
|
||||
**本地验证顺序**:
|
||||
|
||||
1. 跑迁移 → 确认 `account_leases` 建出、`accounts.role_change_available_at` 在、索引在;
|
||||
2. **D1 修复 + 回填** → 验证 dev 的 `official_sync` 事件 `subject_key` 由空变为商品 ID,
|
||||
且两种口径的候选数一致;
|
||||
3. 用 `kf1_test` 对 **20 分商品**发起分配 → 应领到账号 61(A 梯队,`topup_gifts=0`)
|
||||
\+ 60/62(B 梯队,`topup_gifts=2`);
|
||||
4. 并发:两个会话同时领取,只有一个成功;
|
||||
5. 把 dev 配置改成 `wechat_qr` → 跑一条 B 梯队 → 观察支付码生成、
|
||||
10 分钟(`DOUYU_PAYMENT_POLL_SECONDS=600`)后是否进入 `manual_required`;
|
||||
6. 回收:任务终态后租约释放;关掉终态钩子 → reaper 兜底是否生效;
|
||||
7. 流水:`account_events.actor_id` 是否等于 `kf1_test` 的 id,`detail.lease_id` 是否可回查。
|
||||
|
||||
**生产移交:暂缓**(第 10 条)。等本地验证通过后,再定"先移交 500 个还是全量"。
|
||||
在此之前,生产**只上线 P0 的 D1 修复**(纯数据修复,不改行为)。
|
||||
|
||||
### 5.2 P0 · 修 D1(本期必做,第 12 条)
|
||||
|
||||
| 动作 | 内容 |
|
||||
| --- | --- |
|
||||
| 代码 | `record_official_exchange` 补 `subject_key=commodity_id`;`subject_type` 统一为 `commodity`(无 WHERE 使用,安全) |
|
||||
| 回填 | `UPDATE account_events SET subject_key = JSON_UNQUOTE(JSON_EXTRACT(detail,'$.commodity_id')) WHERE source='official_sync' AND (subject_key IS NULL OR subject_key='')` |
|
||||
| 验证 | 重跑 §2.3 两种口径,候选数应一致(14 = 14);本地先在 dev 跑一遍 |
|
||||
| 归属移交 | **暂缓**(生产不做,dev 不需要) |
|
||||
|
||||
### 5.3 P1 · 租约 + 分配服务(核心,1.5~2 人日)
|
||||
|
||||
- 迁移:`account_leases`;`accounts.role_change_available_at`;`account_events(actor_id, occurred_at)` 索引
|
||||
- 新增 `web/backend/services/account_pool.py`:纯函数(筛选/两梯队排序/供给预估/成本估算)+ 领取/释放
|
||||
- 接口 `GET /supply`、`POST /exchange`
|
||||
- `_require_task_account_access` 增加"本人有效租约"分支(**只加不删**)
|
||||
- `create_douyu_planned_tasks` 加可选参数 `account_assigned_to_override`(§3.6)
|
||||
- `_mark_task` 终态释放钩子
|
||||
- `role_change_available_at` 写入点(§4)
|
||||
- **单次充值 800 元上限**(§3.4.2):服务端校验 + 池路径拆单
|
||||
- **运营流水补操作人**(§3.14):业务事件补 `actor_id = task.created_by` + `detail.lease_id/batch_id`
|
||||
|
||||
### 5.4 P2 · 新工作台 + 兜底作业(1.5 人日)
|
||||
|
||||
- **新建** `src/pages/DouyuPoolPage.tsx` + 路由 `/douyu/pool` + 菜单项(§3.13,旧页面不动)
|
||||
- `account_lease_reaper` 作业
|
||||
- `GET /leases` / 手动释放
|
||||
- 账号详情页:事件接口加 `actor_id` 筛选 + 操作人/渠道展示 + 「租赁记录」区块(§3.14)
|
||||
|
||||
### 5.5 P3 · 补给链与补位(1 人日)
|
||||
|
||||
- `donate_elite_gift` 按缺口编排进批次(§3.4);`wechat_qr` 的"待人工扫码 → 继续/放弃"闭环(§3.4.1)
|
||||
- 失败补位(§3.7)
|
||||
- 可选:由**管理员**把 `gold_recharge_channel` 切到 `supplier_api`(**只能人工决策,代码不得代办**)
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证计划
|
||||
|
||||
测试库用 `douyu_login_test`(**不许**拿生产镜像跑测试套件)。
|
||||
下面的自动化用例在这里跑;**端到端的手工验证按 §5.1 在 dev 库 `douyu_login` 做**。
|
||||
|
||||
| # | 用例 | 反向验证(把 bug 注入回去看是否变红) |
|
||||
| --- | --- | --- |
|
||||
| 1 | 并发领取:两会话抢同一账号只成功一个 | 去掉 `uq_lease_active_account` → 应出现双租约 |
|
||||
| 2 | 365 天规则:已兑过 bag10 的不进候选 | 不做 D1 回填 → 候选从 14 变 16 |
|
||||
| 3 | `elite_opened != 1` 不进候选 | 去掉该条件 → 候选从 4,618 变 7,091 |
|
||||
| 4 | 充值量计算:`points=300, score=1280` → 98 令 | 改成向上取整到 100 → 用例应红 |
|
||||
| 5 | A 梯队优先:有存量积分的先被选走 | 去掉梯队排序 → 应出现"先选了需要充值的账号" |
|
||||
| 6 | 失败补位 | 关掉补位 → `shortfall` 应为 1 而不是 0 |
|
||||
| 7 | TTL 回收 | 关掉 reaper → 租约永远 active |
|
||||
| 8 | 越权:租约不属于我 → 403 | 去掉租约校验 → 应能提交别人的号 |
|
||||
| 9 | **池任务的客服可见性**(§3.6) | 不写 `account_assigned_to_override` → 客服任务列表应为空 |
|
||||
| 10 | **单次充值上限**:`amount=1200` 被拒且 message 有明确文案 | 去掉 `amount > 800` 校验 → 用例应变红 |
|
||||
| 11 | **超限拆单**:缺口 1700 元 → 拆成 800+800+100 三笔且串行 | 去掉拆单 → 单笔 1700 应被拒 |
|
||||
| 12 | **渠道只读**:`gold_recharge_channel=wechat_qr` 时池路径生成支付码并停在待人工,**配置未被改写** | 让池路径顺手写 config → 断言配置值不变的用例应变红 |
|
||||
| 13 | **操作人落库**:池路径兑换后 `account_events.actor_id` == 租用客服的 id | 不传 `actor_id` → 事件 actor 为空 |
|
||||
| 14 | **租赁可追溯**:`detail.lease_id` 能关联回 `account_leases` | 不写 lease_id → 关联查询为空 |
|
||||
| 15 | 旧路径回归:手动导入 + 手动选号全流程不变;`/douyu/elite` 页面在新工作台上线后行为不变 | —— |
|
||||
| 16 | **超时转人工**:把 `DOUYU_PAYMENT_POLL_SECONDS` 打小 → 未到账应进 `manual_required`,且**租约仍为 active**(不自动释放) | 让超时路径释放租约 → 用例应变红 |
|
||||
|
||||
断言必须看 `model_dump()`(Pydantic 静默丢字段在本仓库踩过三次,见 [AGENTS.md](../AGENTS.md))。
|
||||
|
||||
---
|
||||
|
||||
## 7. 未决与风险
|
||||
|
||||
### 7.1 后续可考虑(本期不做)
|
||||
|
||||
1. **每日累计鱼翅上限** —— 按第 9 条**暂不设置**。单次 800 元挡不住"分多笔把预算吃穿",
|
||||
风险如实登记在 §7.3;等需求出现再加。
|
||||
2. **生产归属移交的节奏** —— 按第 10 条**暂缓**,先做本地 dev 验证(§5.1),
|
||||
通过后再定"先移交 500 个还是全量 10,683 个"。
|
||||
|
||||
> 鱼翅渠道**不是未决项**:按第 5 条,切换 `gold_recharge_channel` 是管理员的手动动作,
|
||||
> 系统只读、不代办;没开 API 就走"转人工扫码"。
|
||||
|
||||
### 7.2 已按用户答复确定(留档)
|
||||
|
||||
| 项 | 决定 |
|
||||
| --- | --- |
|
||||
| 跨归属使用 | **不允许**;账号归属 = 管理员/运营的维护责任 |
|
||||
| 数量语义 | **账号个数**,一账号一件(`num=1`) |
|
||||
| 换绑冷却 | **记录到数据库 + 提示,不做分配硬过滤**(不同活动冷却不同) |
|
||||
| 鱼翅渠道 | **管理员手动切换,系统只读不改**;未开 API 一律转人工 |
|
||||
| 充值上限 | **单次 ≤ 800 元**(服务端校验;池路径超限拆单);**不设每日累计**(第 9 条) |
|
||||
| 支付超时 | **10 分钟**(复用 `DOUYU_PAYMENT_POLL_SECONDS = 600`),超时转人工(第 11 条) |
|
||||
| 运营流水 | 租赁期间的充值/积分/兑换/开通**全部写进账号信息**,含操作人、渠道、金额 |
|
||||
| 工作台 | **新建**,不改造旧的精英宝典/电竞手册工作台 |
|
||||
| 落地范围 | **先在本地 dev 验证,不动生产**;生产只上 D1 修复(第 10、12 条) |
|
||||
| D1 | **本期必做**,与主体改动同一批(第 12 条) |
|
||||
|
||||
### 7.3 已知风险
|
||||
|
||||
| 风险 | 说明 | 缓解 |
|
||||
| --- | --- | --- |
|
||||
| 积分到账延迟 | `donate_elite_gift` 后短轮询未必确认(已有 `gift_points_confirmed` 标记) | 未确认如实标注,**不当成到账**;必要时补位重试 |
|
||||
| 服务端最终闸门 | "已达兑换上限/无库存/活动未开启"只有真跑才知道(279+41+17 条) | §3.7 补位 + 如实报 shortfall |
|
||||
| 限流 | 池化会抬高并发,实测已有 198 条"点击太快" | 租约天然串行;批次 `concurrency` 保持默认 |
|
||||
| D1 回填 | 一次性 UPDATE 10,612 行 | 先备份 `account_events`,按主键范围分批提交 |
|
||||
| 归属移交副作用 | 客服旧路径看不到账号 | 先小批试点并明确告知;旧代码保留 |
|
||||
| **转人工卡单** | `wechat_qr` 下客服不扫码,租约被占住不释放 | 池工作台醒目提示 + 客服可手动"放弃并释放";**不自动释放**(避免误杀正在支付的单) |
|
||||
| **拆单放大** | 一笔 5800 元被拆成 8 笔 800,可能绕过审批预期 | 单次 800 是硬校验;**每日累计上限本期不设**(§7.1 第 1 条,已按用户决定),一期靠审计可见 |
|
||||
| **渠道被误改** | 有人把 `gold_recharge_channel` 改动导致行为突变 | 池路径只读;切换只走 `PUT /api/douyu/config`(需 `douyu:config`,即超管/运营),且 `gold_*` 变更**已经写审计**(已核实) |
|
||||
| **本地与生产环境不同** | dev 是 `supplier_api`、生产是 `wechat_qr`;dev 还有 2 组重复的历史事件 | 本地验证前先把 dev 配置改成 `wechat_qr`;断言条数前先清重复(§5.1) |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:实测 SQL
|
||||
|
||||
```sql
|
||||
-- 归属角色分布(关键:池成员为 0)
|
||||
SELECT u.role, COUNT(*) accounts, SUM(a.ck_check_status='valid') valid
|
||||
FROM accounts a JOIN users u ON u.id=a.assigned_to GROUP BY u.role;
|
||||
|
||||
-- 供给(兔兔白日梦):0 充值 / 充值后可用
|
||||
SELECT
|
||||
SUM(elite_opened=1 AND points>=1280 AND NOT EXISTS(
|
||||
SELECT 1 FROM account_events e WHERE e.account_id=a.id AND e.event_type='exchange'
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(e.detail,'$.commodity_id'))='20260120QYOOB_bag10')) zero_topup,
|
||||
SUM(elite_opened=1 AND NOT EXISTS(
|
||||
SELECT 1 FROM account_events e WHERE e.account_id=a.id AND e.event_type='exchange'
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(e.detail,'$.commodity_id'))='20260120QYOOB_bag10')) after_topup
|
||||
FROM accounts a WHERE a.ck_check_status='valid'; -- 13 / 4618
|
||||
|
||||
-- 补给链换算(1 元 = 1 鱼翅 = 1 精英令 = 10 积分)
|
||||
SELECT SUM(-amount) gold_spent FROM account_events
|
||||
WHERE event_type='gold_spent' AND occurred_at >= CURDATE() - INTERVAL 3 DAY; -- 192586
|
||||
SELECT SUM(amount) points_credit FROM account_events
|
||||
WHERE event_type='points_credit' AND occurred_at >= CURDATE() - INTERVAL 3 DAY; -- 1927110
|
||||
|
||||
-- 1 元 = 1 鱼翅:充值事件里 amount(元)与 balance_after(鱼翅)相等
|
||||
SELECT account_id, amount, balance_after FROM account_events
|
||||
WHERE event_type='recharge' ORDER BY id DESC LIMIT 8; -- 48→48、69→69、18→18 …
|
||||
|
||||
-- 渠道现状(全人工)
|
||||
SELECT JSON_UNQUOTE(JSON_EXTRACT(detail,'$.channel')) ch, COUNT(*)
|
||||
FROM account_events WHERE event_type='recharge' GROUP BY ch; -- wechat_qr 1574
|
||||
|
||||
-- 操作人缺口(第 7 条要补的)
|
||||
SELECT actor_type, COUNT(*) FROM account_events GROUP BY actor_type; -- '' 24502 / system 7153 / user 2807
|
||||
|
||||
-- 本地 dev 环境(库 douyu_login,§5.1)
|
||||
SELECT COUNT(*), SUM(assigned_to IS NULL), SUM(ck_check_status='valid'), SUM(elite_opened=1)
|
||||
FROM accounts; -- 12 / 12(全未分配)/ 4 / 3
|
||||
SELECT id, points, elite_opened, role_bind_status FROM accounts
|
||||
WHERE ck_check_status='valid' ORDER BY id; -- 60:2分 / 61:300分 / 62:0分
|
||||
SELECT gold_recharge_channel FROM douyu_config; -- 本地 supplier_api(生产 wechat_qr)⚠️
|
||||
SELECT MIN(score) FROM douyu_goods_snapshot; -- 10(20 分商品可覆盖 A/B 两梯队)
|
||||
|
||||
-- dev 里的重复历史事件(幂等键格式变更导致,生产无此问题)
|
||||
SELECT id, account_id, source_ref FROM account_events
|
||||
WHERE source='official_sync' ORDER BY id; -- id14/15 无 scope,id23/24 带 scope → 同两笔重复
|
||||
|
||||
-- D1:两种口径的候选池(应相等)
|
||||
SELECT COUNT(*) FROM accounts a WHERE a.ck_check_status='valid' AND a.points>=1280
|
||||
AND NOT EXISTS (SELECT 1 FROM account_events e WHERE e.account_id=a.id AND e.event_type='exchange'
|
||||
AND e.subject_key='20260120QYOOB_bag10'); -- 16(错,回填后应变 14)
|
||||
SELECT COUNT(*) FROM accounts a WHERE a.ck_check_status='valid' AND a.points>=1280
|
||||
AND NOT EXISTS (SELECT 1 FROM account_events e WHERE e.account_id=a.id AND e.event_type='exchange'
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(e.detail,'$.commodity_id'))='20260120QYOOB_bag10'); -- 14(对)
|
||||
|
||||
-- 换绑冷却(相对值,落库即过期 → 需要绝对时刻)
|
||||
SELECT SUM(change_role_wait_time>0), SUM(change_role_wait_time=0)
|
||||
FROM accounts WHERE ck_check_status='valid'; -- 8939 / 843
|
||||
|
||||
-- 补给成本:把 4,618 个可用账号全部兑完(必须与 §3.4 同口径:含 365 天过滤)
|
||||
SELECT CEIL(SUM(GREATEST(0,1280-points))/10) gifts
|
||||
FROM accounts a
|
||||
WHERE a.ck_check_status='valid' AND a.elite_opened=1
|
||||
AND NOT EXISTS (SELECT 1 FROM account_events e WHERE e.account_id=a.id AND e.event_type='exchange'
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(e.detail,'$.commodity_id'))='20260120QYOOB_bag10'); -- 586978
|
||||
```
|
||||
|
||||
## 附录 B:涉及文件
|
||||
|
||||
| 文件 | 动作 |
|
||||
| --- | --- |
|
||||
| `web/backend/services/business_events.py` | P0:`record_official_exchange` 补 `subject_key`;P1:业务事件补 `actor_id` + `detail.lease_id/batch_id`(§3.14) |
|
||||
| `web/backend/migrations/versions/20261xxx_005x_account_leases.py` | P1:**新建** `account_leases` |
|
||||
| `web/backend/migrations/versions/20261xxx_005x_role_change_available_at.py` | P1:`accounts` 加列 + `account_events(actor_id, occurred_at)` 索引 |
|
||||
| `web/backend/models.py` | P1:`AccountLease` 模型 + `Account.role_change_available_at` |
|
||||
| `web/backend/services/account_pool.py` | P1:**新增**,候选/排序/供给预估/成本估算/领取/释放 |
|
||||
| `web/backend/services/douyu_service.py` | P1:`create_douyu_planned_tasks` 加可选参数(默认不变) |
|
||||
| `web/backend/routers/douyu.py` | P1:池接口;`_require_task_account_access` 加租约分支 |
|
||||
| `web/backend/services/douyu_runner_gold.py` | P1:**单次充值 800 元上限** + 超限拆单(§3.4.2) |
|
||||
| `web/backend/services/douyu_runner_core.py` | P1:`_mark_task` 终态释放钩子 |
|
||||
| `web/backend/services/douyu_runner_bind.py` | P1:补写 `role_change_available_at` |
|
||||
| `web/backend/services/automation/jobs/account_lease_reaper.py` | P2:**新增** |
|
||||
| `web/backend/permissions.py` | P1:`account:pool_use` / `account:pool_admin` |
|
||||
| `web/backend/routers/accounts.py` | P2:事件接口加 `actor_id` 筛选 + `actor_name`(**只加**,其余不动) |
|
||||
| `web/frontend/src/pages/DouyuPoolPage.tsx` | P2:**新建**(§3.13) |
|
||||
| `web/frontend/src/App.tsx` / `layouts/MainLayout.tsx` | P2:加路由与菜单项(旧条目不动) |
|
||||
| `web/frontend/src/pages/douyu-tasks/*`、`pages/DouyuTasksPage.tsx` | **不改**(旧工作台) |
|
||||
| `web/frontend/src/pages/AccountDetailPage.tsx` | P2:时间线展示操作人/渠道 + 「租赁记录」区块 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施记录(2026-10-07)
|
||||
|
||||
> 状态:**已按本设计落地,测试通过;只在本地验证,未上生产**(第 10 条)。
|
||||
> 全量:`766 passed, 6 skipped`(改动前基线 727);新增 39 个用例;
|
||||
> `ruff check` 与前端 `tsc -b` 均干净。
|
||||
|
||||
### 8.1 落地清单
|
||||
|
||||
| 阶段 | 产出 |
|
||||
| --- | --- |
|
||||
| P0 · D1 | `business_events.record_official_exchange` 补 `subject_key=commodity_id`(`subject_type` 统一为 `commodity`);回填脚本 `scripts/backfill_official_exchange_subject_key.py`(`--dry-run/--apply`、分批、只填空值、幂等) |
|
||||
| P1 · 迁移 | `20261007_0052_account_leases`:`account_leases`(生成列 `active_account_id` + 唯一索引)、`accounts.role_change_available_at`、`ix_account_events_actor_occurred` |
|
||||
| P1 · 服务 | `services/account_pool.py`:候选筛选、两梯队排序、供给与成本预估、租约领取/释放/回收、充值拆单与校验、补给链编排 |
|
||||
| P1 · 接口 | `routers/douyu_pool.py`:`GET /supply`、`POST /exchange`、`GET /leases`、`POST /leases/{id}/release`、`POST /leases/release-batch/{batch_id}` |
|
||||
| P1 · 权限 | `account:pool_use`(含 support)、`account:pool_admin`(超管/运营) |
|
||||
| P1 · 流水 | 业务事件补 `actor_id = task.created_by`(7 个构造函数 + 13 个调用点);`GET /accounts/{id}/events` 支持 `actor_id` 筛选并返回 `actor_name` |
|
||||
| P1 · 上限 | `create_gold_qr` 服务端校验单次 ≤ 800 元;池路径超限自动拆单 |
|
||||
| P1 · 回收 | `_mark_task` 终态钩子即时释放租约 |
|
||||
| P2 · 作业 | `automation/jobs/account_lease_reaper.py`(TTL 到期 / 漏释放 / 孤儿批次),`run_interval_seconds=60` |
|
||||
| P2 · 前端 | **新建** `pages/DouyuPoolPage.tsx` + 路由 `/douyu/pool` + 菜单「账号分配台」;账号详情页加「操作人 / 渠道」列与「租赁记录」卡片 |
|
||||
| P3 · 补给链 | `build_supply_chain` 纯函数 + 运行期两道闸门 |
|
||||
|
||||
### 8.2 相对设计稿的**实现补充**(设计没写到、但落地必须有的)
|
||||
|
||||
| # | 补充 | 为什么必须有 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 新增任务状态 **`manual_required`(转人工)** | 设计只说"超时转人工",但没定状态。原来超时被标成 `failed`,与真正的失败混在一起,客服分不清"要我去扫码"和"这单废了" |
|
||||
| 2 | 转人工时**不释放租约**,且同批次只要还有 `manual_required` 就不释放 | 支付码已生成、账号已挂在这单上,放出去会被第二个人用 |
|
||||
| 3 | 运行期闸门 **`_skip_when`**(按账号当前字段比较) | 补给链必须能表达「余额已经够了就别再充钱」。**没有它就会为同一个账号重复充值** —— 这是花钱的事,不能靠计划期的旧值判断 |
|
||||
| 4 | 运行期闸门 **`_depends_on`**(前置没成功就跳过) | 充鱼翅转人工后,后面的赠令/兑换必然失败;没有它每条链会连报 2~3 个假失败,把真问题淹掉 |
|
||||
| 5 | 补给链**链首固定加 `query_gold_balance` / `query_points`** | `_skip_when` 读的是账号字段,不先刷新就是旧值 → **旧值偏低会多充真金白银** |
|
||||
| 6 | A 梯队(积分已够)**不生成任何补给步骤** | 给积分够的账号送礼/充值纯粹是浪费 |
|
||||
| 7 | 新增 `douyu_service.create_douyu_task`(单账号、独立载荷) | `create_douyu_planned_tasks` 是"一批共用一个 payload",表达不了每个账号不同的充值额/赠令数 |
|
||||
| 8 | `_visible_accounts_query` 扩为「我名下 ∪ 我租用中」 | 不然客服做完一单就看不了那个账号(连时间线都 404) |
|
||||
| 9 | `create_douyu_planned_tasks` 加**可选**参数 `account_assigned_to_override` | 设计 §3.6 的落地方式;默认不传 = 旧行为不变(有用例守着) |
|
||||
| 10 | `start_douyu_batch` 从旧路由抽出成函数 | 两条路径共用同一套启动方式,避免将来"老路径好了、池路径没好" |
|
||||
|
||||
### 8.3 本地 dev 验证结果(§5.1 的实测)
|
||||
|
||||
| 步骤 | 结果 |
|
||||
| --- | --- |
|
||||
| `ruff check` / `pytest` / `tsc -b` | 全部通过 |
|
||||
| dev 库是否需要归属移交 | **不需要**:12 个账号 `assigned_to` 全为 NULL,本来就都是池成员 |
|
||||
| 反向验证 ① D1 | 撤掉 `subject_key` 的实现 → 2 个用例变红;恢复 → 变绿 |
|
||||
| 反向验证 ② 唯一租约 | 去掉 `uq_lease_active_account` → 并发/重复领取 2 个用例变红;恢复 → 变绿 |
|
||||
| 反向验证 ③ 回填脚本 | 让 `--dry-run` 真的写库 → 对应用例变红;恢复 → 变绿 |
|
||||
| 反向验证 ④ 运行期闸门 | `_skip_when` / `_depends_on` 各有"不该跳却跳 / 该跳没跳"的双向用例 |
|
||||
|
||||
### 8.4 未做的事(如实登记)
|
||||
|
||||
1. **没有部署到生产**,也没在生产跑 D1 回填(第 10、12 条:先本地)。生产上线前必须:
|
||||
先跑 `--dry-run` 看影响行数 → 备份 `account_events` → `--apply` → 复核两种口径一致。
|
||||
2. **没有改 `handbook_scope`**,所以池任务在旧的精英宝典页面里也能看到(设计 §3.13 的取舍)。
|
||||
3. **电竞手册与小店**仍未接入池(实测电竞 `esports_opened=0`,无供给)。
|
||||
4. **补位(fallback)只做了参数与返回口径**,链内的"失败后自动换一个账号"需要跨任务重建链,
|
||||
目前由"missing 补齐"的下一轮调用完成 —— 也就是 `allow_fallback` 现在**只影响返回语义**,
|
||||
不会自动再领一个号。这是与设计 §3.7 的**已知差距**,下一步补。
|
||||
5. 转人工的"继续等待 / 重新生成码"目前靠客服在「我的租约」里手动处理(释放后重提),
|
||||
池工作台里还没有一键"继续等"按钮。
|
||||
Reference in New Issue
Block a user