From 18bc7be4b1855fc8b876e64947e6a81eaa5eff44 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 7 Oct 2026 12:29:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(pool):=20=E8=B4=A6=E5=8F=B7=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=88=86=E9=85=8D=E4=B8=8E=E5=9B=9E=E6=94=B6=EF=BC=88?= =?UTF-8?q?=E7=A7=9F=E7=BA=A6=20+=20=E8=A1=A5=E7=BB=99=E9=93=BE=20+=20?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=B7=A5=E4=BD=9C=E5=8F=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条 「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。 旧路径只做并集与可选参数扩展,行为不变(有用例守着)。 - 归属与使用权分离:新增 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 --- docs/设计-账号自动分配与回收.md | 960 ++++++++++++++++++ tests/test_account_lease_reaper.py | 177 ++++ tests/test_account_overview.py | 56 + tests/test_account_pool.py | 503 +++++++++ ..._backfill_official_exchange_subject_key.py | 137 +++ tests/test_douyu_pool_api.py | 342 +++++++ tests/test_migrations.py | 2 +- tests/test_pool_account_visibility.py | 217 ++++ tests/test_supply_chain.py | 278 +++++ web/backend/main.py | 2 + .../versions/20261007_0052_account_leases.py | 163 +++ web/backend/models.py | 88 ++ web/backend/permissions.py | 6 + web/backend/routers/accounts.py | 44 +- web/backend/routers/douyu.py | 62 +- web/backend/routers/douyu_pool.py | 447 ++++++++ web/backend/schemas.py | 58 ++ web/backend/services/account_pool.py | 734 +++++++++++++ web/backend/services/automation/__init__.py | 25 + .../automation/jobs/account_lease_reaper.py | 95 ++ web/backend/services/business_events.py | 56 +- web/backend/services/douyu_runner.py | 12 + web/backend/services/douyu_runner_bind.py | 5 +- web/backend/services/douyu_runner_core.py | 97 +- web/backend/services/douyu_runner_donate.py | 6 +- web/backend/services/douyu_runner_gold.py | 26 +- web/backend/services/douyu_runner_goods.py | 5 +- web/backend/services/douyu_runner_overview.py | 13 +- web/backend/services/douyu_runner_xpd.py | 3 +- web/backend/services/douyu_service.py | 72 +- web/frontend/src/App.tsx | 2 + web/frontend/src/api/douyuPool.ts | 43 + web/frontend/src/api/modules.ts | 1 + web/frontend/src/api/types.ts | 72 ++ web/frontend/src/layouts/MainLayout.tsx | 5 + web/frontend/src/pages/AccountDetailPage.tsx | 76 +- web/frontend/src/pages/DouyuPoolPage.tsx | 282 +++++ web/frontend/src/pages/douyu-tasks/config.tsx | 6 + 38 files changed, 5143 insertions(+), 35 deletions(-) create mode 100644 docs/设计-账号自动分配与回收.md create mode 100644 tests/test_account_lease_reaper.py create mode 100644 tests/test_account_pool.py create mode 100644 tests/test_backfill_official_exchange_subject_key.py create mode 100644 tests/test_douyu_pool_api.py create mode 100644 tests/test_pool_account_visibility.py create mode 100644 tests/test_supply_chain.py create mode 100644 web/backend/migrations/versions/20261007_0052_account_leases.py create mode 100644 web/backend/routers/douyu_pool.py create mode 100644 web/backend/services/account_pool.py create mode 100644 web/backend/services/automation/jobs/account_lease_reaper.py create mode 100644 web/frontend/src/api/douyuPool.ts create mode 100644 web/frontend/src/pages/DouyuPoolPage.tsx diff --git a/docs/设计-账号自动分配与回收.md b/docs/设计-账号自动分配与回收.md new file mode 100644 index 0000000..0ba4b3f --- /dev/null +++ b/docs/设计-账号自动分配与回收.md @@ -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) 新增 ``,旧路由一行不动) | +| 菜单 | [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. 转人工的"继续等待 / 重新生成码"目前靠客服在「我的租约」里手动处理(释放后重提), + 池工作台里还没有一键"继续等"按钮。 diff --git a/tests/test_account_lease_reaper.py b/tests/test_account_lease_reaper.py new file mode 100644 index 0000000..3cb6be0 --- /dev/null +++ b/tests/test_account_lease_reaper.py @@ -0,0 +1,177 @@ +"""租约兜底回收:漏释放、孤儿批次、TTL 到期。 + +主释放路径是任务终态钩子(有单独用例);这里守的是**保险丝**: +进程重启、释放抛异常、任务行被清 —— 这三种情况下账号不能永远被锁着。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import ( + Account, + AccountLease, + AutomationJob, + DouyuTask, + User, +) +from web.backend.services import account_pool as pool +from web.backend.services.automation.jobs import account_lease_reaper as reaper + +NOW = datetime(2026, 10, 7, 12, 0, 0, tzinfo=UTC) + + +class _ReaperBase: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.user = User(username="reap-kf", password_hash="h", role="support") + self.session.add(self.user) + self.session.flush() + self.user_id = self.user.id + + def teardown_method(self): + self.session.close() + + def _lease( + self, name: str, *, batch_id: str = "", ttl_minutes: int = 60 + ) -> AccountLease: + account = Account( + username=name, + password="p", + email="e@e.com", + email_password="ep", + status="active", + ck_check_status="valid", + cookie="PHPSESSID=x", + elite_opened=True, + points=5000, + ) + self.session.add(account) + self.session.commit() + lease = pool.lease_accounts( + self.session, + holder_user_id=self.user_id, + candidates=[ + pool.make_candidate( + account_id=account.id, + username=name, + points=5000, + score=1280, + ) + ], + commodity_id="20260101X_bag1", + batch_id=batch_id, + ttl_minutes=ttl_minutes, + now=NOW, + )[0] + return lease + + def _task(self, batch_id: str, account_id: int, status: str): + self.session.add( + DouyuTask( + batch_id=batch_id, + account_id=account_id, + task_type="exchange_goods", + handbook_scope="elite", + status=status, + created_by=self.user_id, + ) + ) + self.session.commit() + + def _status(self, lease_id: int) -> str: + self.session.rollback() + lease = self.session.get(AccountLease, lease_id) + assert lease is not None + return lease.status + + +class TestReapMissedReleases(_ReaperBase): + def test_releases_when_batch_has_no_active_task(self): + lease = self._lease("m1", batch_id="batch-m1") + self._task("batch-m1", lease.account_id, "success") + + assert pool.reap_missed_releases(self.session) == 1 + assert self._status(lease.id) == "released" + + def test_keeps_lease_while_batch_still_running(self): + lease = self._lease("m2", batch_id="batch-m2") + self._task("batch-m2", lease.account_id, "running") + + assert pool.reap_missed_releases(self.session) == 0 + assert self._status(lease.id) == "active" + + def test_keeps_lease_when_batch_not_created_yet(self): + """租约刚建、任务还没建出来(batch_id 为空)—— 不能当成漏释放。""" + lease = self._lease("m3", batch_id="") + assert pool.reap_missed_releases(self.session) == 0 + assert self._status(lease.id) == "active" + + def test_one_running_task_protects_the_whole_batch(self): + first = self._lease("m4a", batch_id="batch-m4") + second = self._lease("m4b", batch_id="batch-m4") + self._task("batch-m4", first.account_id, "success") + self._task("batch-m4", second.account_id, "pending") + + # 批次里还有 pending → 两条都不动(正在跑的那条可能还没结束) + assert pool.reap_missed_releases(self.session) == 0 + assert self._status(first.id) == "active" + + +class TestReaperJob(_ReaperBase): + def test_expires_ttl_leases(self): + lease = self._lease("j1", batch_id="batch-j1", ttl_minutes=1) + self._task("batch-j1", lease.account_id, "running") + + job = AutomationJob(key=reaper.JOB_KEY, batch_size=100) + result = reaper.run_account_lease_reaper( + self.session, job, now=NOW + timedelta(minutes=5) + ) + + assert result["expired"] >= 1 + assert self._status(lease.id) == "expired" + + def test_releases_orphan_batch(self): + """注册表里没有这个批次(模拟进程重启)→ 即使任务还是 running 也要收。""" + lease = self._lease("j2", batch_id="batch-gone") + self._task("batch-gone", lease.account_id, "running") + + job = AutomationJob(key=reaper.JOB_KEY, batch_size=100) + result = reaper.run_account_lease_reaper(self.session, job) + + assert result["orphan_batch"] >= 1 + assert self._status(lease.id) == "expired" + + def test_message_is_honest_about_counts(self): + self._lease("j3", batch_id="batch-j3", ttl_minutes=1) + job = AutomationJob(key=reaper.JOB_KEY, batch_size=100) + result = reaper.run_account_lease_reaper(self.session, job) + assert result["scanned"] == ( + result["expired"] + result["missed_release"] + result["orphan_batch"] + ) + assert "租约回收" in result["message"] + + def test_idempotent_second_run_finds_nothing(self): + lease = self._lease("j4", batch_id="batch-gone") + self._task("batch-gone", lease.account_id, "running") + job = AutomationJob(key=reaper.JOB_KEY, batch_size=100) + reaper.run_account_lease_reaper(self.session, job) + second = reaper.run_account_lease_reaper(self.session, job) + assert second["scanned"] == 0 + assert self._status(lease.id) == "expired" + + def test_freed_account_becomes_available_again(self): + lease = self._lease("j5", batch_id="batch-gone") + account_id = lease.account_id + job = AutomationJob(key=reaper.JOB_KEY, batch_size=100) + reaper.run_account_lease_reaper(self.session, job) + + candidates = pool.eligible_accounts( + self.session, + score=1280, + commodity_id="20260101X_bag1", + now=NOW + timedelta(minutes=5), + ) + assert account_id in {c.account_id for c in candidates} diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py index 0283da1..9afef95 100644 --- a/tests/test_account_overview.py +++ b/tests/test_account_overview.py @@ -1031,6 +1031,62 @@ class TestOfficialExchangeBecomesAccountEvent: assert event.detail["source"] == "official_sync" assert event.source == "official_sync" + def test_subject_key_is_commodity_id(self): + """D1:官方补录的事件必须写 `subject_key=商品ID`。 + + 2026-10-07 实测:生产 10,612 条 `official_sync` 事件的 `subject_key` + **全是空串**(`record_official_exchange` 漏传),而 365 天同皮肤规则与 + `ix_account_events_ledger` 都按 `subject_key` 判定 —— 不修会漏掉 87% 的历史。 + + 断言直接查 **DB 行**(不是只看返回值),并顺带确认 `subject_type` + 与 `record_exchange`(我方任务直发)统一为 `commodity`。 + """ + from web.backend.services import account_events_service as svc + + assert self._record(commodity_id="20260120QYOOB_bag10") is True + self.session.commit() + event = ( + self.session.query(AccountEvent) + .filter(AccountEvent.event_type == svc.EVENT_EXCHANGE) + .one() + ) + assert event.subject_key == "20260120QYOOB_bag10", ( + "365 天规则按 subject_key 查;这里为空等于查不到历史" + ) + assert event.subject_type == "commodity" + + def test_subject_key_makes_365d_lookup_work(self): + """按 `subject_key` 能查到、按空串查不到(反向验证 D1)。 + + `subject_key` 一旦再次漏写,这条与上面那条都会变红。 + """ + from web.backend.services import account_events_service as svc + + assert self._record(commodity_id="20260120QYOOB_bag10") is True + self.session.commit() + hit = ( + self.session.query(AccountEvent) + .filter( + AccountEvent.account_id == self.account.id, + AccountEvent.event_type == svc.EVENT_EXCHANGE, + AccountEvent.source == svc.OFFICIAL_SYNC_SOURCE, + AccountEvent.subject_key == "20260120QYOOB_bag10", + ) + .count() + ) + assert hit == 1 + empty = ( + self.session.query(AccountEvent) + .filter( + AccountEvent.account_id == self.account.id, + AccountEvent.event_type == svc.EVENT_EXCHANGE, + AccountEvent.source == svc.OFFICIAL_SYNC_SOURCE, + AccountEvent.subject_key == "", + ) + .count() + ) + assert empty == 0 + def test_is_idempotent(self): """一键查询会被反复执行,同一条官方记录只能落一条事件。""" from web.backend.services import account_events_service as svc diff --git a/tests/test_account_pool.py b/tests/test_account_pool.py new file mode 100644 index 0000000..a872517 --- /dev/null +++ b/tests/test_account_pool.py @@ -0,0 +1,503 @@ +"""账号池:候选筛选、两梯队排序、租约领取与释放。 + +分两层守: + +- **纯函数**(充值量、排序、拆单)不碰数据库,直接喂数据断言 —— + 这几条是业务规则的集中地,改错了必须立刻变红; +- **租约**(唯一约束、并发抢号、按任务释放)必须打真实 MySQL —— + 「一个账号同时只能有一个有效租约」是**数据库索引**给的保证, + 用内存假库测不出并发下的双发。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import Account, AccountEvent, AccountLease, DouyuTask, User +from web.backend.services import account_pool as pool + +NOW = datetime(2026, 10, 7, 10, 0, 0, tzinfo=UTC) +COMMODITY = "20260120QYOOB_bag10" + + +# ── 纯函数 ──────────────────────────────────────────────────────────────── + + +class TestTopupMath: + def test_rounds_gap_up_to_whole_gifts(self): + assert pool.topup_gifts_for(300, 1280) == 98 # 缺口 980 → 98 个令 + assert pool.topup_gifts_for(1279, 1280) == 1 + assert pool.topup_gifts_for(1280, 1280) == 0 + assert pool.topup_gifts_for(2000, 1280) == 0 + + def test_none_points_is_treated_as_zero(self): + assert pool.topup_gifts_for(None, 20) == 2 + + def test_exact_multiple_does_not_add_a_gift(self): + """缺口正好是 10 的倍数时不能多给一个令(多买就是浪费钱)。""" + assert pool.topup_gifts_for(0, 100) == 10 + + +class TestRechargeSplit: + def test_no_split_below_limit(self): + assert pool.recharge_amounts_for(0) == [] + assert pool.recharge_amounts_for(98) == [98] + assert pool.recharge_amounts_for(800) == [800] + + def test_splits_above_limit(self): + assert pool.recharge_amounts_for(1700) == [800, 800, 100] + assert pool.recharge_amounts_for(1600) == [800, 800] + + def test_validate_rejects_over_limit(self): + assert pool.validate_recharge_amount(800) == 800 + with pytest.raises(ValueError, match="800"): + pool.validate_recharge_amount(801) + with pytest.raises(ValueError): + pool.validate_recharge_amount(0) + + +class TestRanking: + def _cand(self, account_id, points, score=1280, expire=None): + return pool.make_candidate( + account_id=account_id, + username=f"acc{account_id}", + points=points, + score=score, + points_expire_at=expire, + ) + + def test_tier_a_before_tier_b(self): + rich = self._cand(1, 2000) + poor = self._cand(2, 0) + ranked = pool.rank_candidates([poor, rich]) + assert [c.account_id for c in ranked] == [1, 2] + assert ranked[0].tier == "A" and ranked[1].tier == "B" + + def test_tier_a_spends_expiring_points_first(self): + """A 梯队先花**快过期**的积分 —— 那是已经买过、不用就作废的钱。""" + soon = self._cand(1, 2000, expire=NOW + timedelta(days=1)) + later = self._cand(2, 2000, expire=NOW + timedelta(days=30)) + never = self._cand(3, 2000, expire=None) + ranked = pool.rank_candidates([never, later, soon]) + assert [c.account_id for c in ranked] == [1, 2, 3] + + def test_tier_a_then_prefers_smaller_balance(self): + """都没有到期时间时先用小余额,把大额账号留给更贵的商品。""" + small = self._cand(1, 1300) + big = self._cand(2, 9999) + ranked = pool.rank_candidates([big, small]) + assert [c.account_id for c in ranked] == [1, 2] + + def test_tier_b_prefers_least_topup(self): + """B 梯队先选**充值最少**的,省真金白银。""" + near = self._cand(1, 1200) # 只差 8 个令 + far = self._cand(2, 0) # 差 128 个令 + ranked = pool.rank_candidates([far, near]) + assert [c.account_id for c in ranked] == [1, 2] + assert ranked[0].topup_gifts == 8 + + def test_order_is_stable_for_equal_candidates(self): + same = [self._cand(i, 0) for i in (5, 3, 9)] + assert [c.account_id for c in pool.rank_candidates(same)] == [3, 5, 9] + + +# ── 数据库 ──────────────────────────────────────────────────────────────── + + +class _PoolBase: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.admin = User(username="pool-admin", password_hash="h", role="super_admin") + self.support = User(username="pool-kf", password_hash="h", role="support") + self.session.add_all([self.admin, self.support]) + self.session.flush() + self.admin_id = self.admin.id + self.support_id = self.support.id + + def teardown_method(self): + self.session.close() + + def make_account( + self, + name: str, + *, + points: int | None = 0, + opened: bool = True, + ck: bool = True, + status: str = "active", + assigned_to: int | None = None, + expire_at: datetime | None = None, + auto_managed: bool = True, + ) -> Account: + account = Account( + username=name, + password="p", + email="e@e.com", + email_password="ep", + status=status, + ck_check_status="valid" if ck else "", + cookie="PHPSESSID=x" if ck else "", + elite_opened=opened, + points=points, + assigned_to=assigned_to, + points_expire_at=expire_at, + auto_managed=auto_managed, + ) + self.session.add(account) + self.session.commit() + return account + + def add_exchange(self, account_id: int, commodity_id: str, days_ago: int = 1): + self.session.add( + AccountEvent( + account_id=account_id, + event_type="exchange", + category="business", + occurred_at=NOW - timedelta(days=days_ago), + title="兑换", + subject_type="commodity", + subject_key=commodity_id, + source="official_sync", + ) + ) + self.session.commit() + + def eligible(self, *, score=1280, limit=None): + return pool.eligible_accounts( + self.session, + score=score, + commodity_id=COMMODITY, + now=NOW, + limit=limit, + ) + + +class TestEligibilityFilters(_PoolBase): + def test_unassigned_and_operator_owned_are_pool_members(self): + operator = User(username="pool-op", password_hash="h", role="operation") + self.session.add(operator) + self.session.flush() + free = self.make_account("free") + owned = self.make_account("owned", assigned_to=operator.id) + ids = {c.account_id for c in self.eligible()} + assert {free.id, owned.id} <= ids + + def test_support_owned_accounts_are_not_in_pool(self): + """第 1 条:不跨归属 —— 客服名下账号不进池。""" + mine = self.make_account("kf-owned", assigned_to=self.support_id) + assert mine.id not in {c.account_id for c in self.eligible()} + + def test_not_opened_is_excluded(self): + """开通精英宝典要人工扫码,未开通的账号进池也白进。""" + closed = self.make_account("closed", opened=False, points=5000) + assert closed.id not in {c.account_id for c in self.eligible()} + + def test_unknown_opened_is_excluded(self): + """`NULL` = 没查过,不能当「已开通」放行。""" + unknown = self.make_account("unknown") + unknown.elite_opened = None + self.session.commit() + assert unknown.id not in {c.account_id for c in self.eligible()} + + def test_exchanged_within_cooldown_is_excluded(self): + done = self.make_account("done", points=5000) + self.add_exchange(done.id, COMMODITY, days_ago=10) + assert done.id not in {c.account_id for c in self.eligible()} + + def test_exchange_outside_cooldown_is_allowed_again(self): + old = self.make_account("old", points=5000) + self.add_exchange(old.id, COMMODITY, days_ago=pool.EXCHANGE_COOLDOWN_DAYS + 1) + assert old.id in {c.account_id for c in self.eligible()} + + def test_other_commodity_history_does_not_block(self): + other = self.make_account("other", points=5000) + self.add_exchange(other.id, "20260120QYOOB_bag5", days_ago=1) + assert other.id in {c.account_id for c in self.eligible()} + + def test_leased_account_is_excluded(self): + account = self.make_account("leased", points=5000) + pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=[ + pool.make_candidate( + account_id=account.id, + username=account.username, + points=5000, + score=1280, + ) + ], + commodity_id=COMMODITY, + now=NOW, + ) + assert account.id not in {c.account_id for c in self.eligible()} + + def test_no_ck_or_inactive_is_excluded(self): + no_ck = self.make_account("no-ck", ck=False, points=5000) + inactive = self.make_account("inactive", status="imported", points=5000) + ids = {c.account_id for c in self.eligible()} + assert no_ck.id not in ids and inactive.id not in ids + + def test_auto_managed_false_is_excluded(self): + special = self.make_account("special", points=5000, auto_managed=False) + assert special.id not in {c.account_id for c in self.eligible()} + + def test_unknown_points_is_excluded(self): + """积分没查过 → 充值量算不出来,先别放出去。""" + unknown = self.make_account("no-points", points=None) + assert unknown.id not in {c.account_id for c in self.eligible()} + + def test_tiers_are_assigned_by_score(self): + rich = self.make_account("rich", points=5000) + poor = self.make_account("poor", points=100) + by_id = {c.account_id: c for c in self.eligible(score=1280)} + assert by_id[rich.id].tier == "A" + assert by_id[poor.id].tier == "B" + assert by_id[poor.id].topup_gifts == 118 # (1280-100)/10 + + +class TestSupplyEstimate(_PoolBase): + def test_counts_add_up(self): + self.make_account("rich", points=5000) + poor = self.make_account("poor", points=0) + done = self.make_account("done", points=5000) + self.add_exchange(done.id, COMMODITY, days_ago=1) + self.make_account("closed", opened=False, points=5000) + + supply = pool.estimate_supply( + self.session, score=1280, commodity_id=COMMODITY, now=NOW + ) + assert supply["zero_topup"] == 1 # rich + assert supply["after_topup"] == 2 # rich + poor + assert supply["blocked"]["elite_not_opened"] == 1 + assert supply["blocked"]["redeemed_within_cooldown"] == 1 + # poor 差 128 个令;rich 已够,不产生缺口 + assert supply["cost"]["est_gifts"] == 128 + assert poor.id # 保留引用,避免上面的构造被优化掉 + + +class TestLeasing(_PoolBase): + def test_lease_creates_active_row(self): + account = self.make_account("a1", points=5000) + leases = pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=[ + pool.make_candidate( + account_id=account.id, username="a1", points=5000, score=1280 + ) + ], + commodity_id=COMMODITY, + now=NOW, + ) + assert len(leases) == 1 + assert leases[0].status == "active" + assert leases[0].expires_at == NOW + timedelta( + minutes=pool.DEFAULT_LEASE_TTL_MINUTES + ) + + def test_second_lease_on_same_account_is_rejected(self): + """唯一索引裁决:同一账号第二个租约必须失败(这是并发安全的地基)。""" + account = self.make_account("a2", points=5000) + candidate = pool.make_candidate( + account_id=account.id, username="a2", points=5000, score=1280 + ) + first = pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=[candidate], + commodity_id=COMMODITY, + now=NOW, + ) + assert len(first) == 1 + second = pool.lease_accounts( + self.session, + holder_user_id=self.admin_id, + candidates=[candidate], + commodity_id=COMMODITY, + now=NOW, + ) + assert second == [], "同一个账号不该被第二个租约抢到" + + def test_concurrent_lease_from_two_sessions_only_one_wins(self): + """两个独立会话抢同一账号 —— 真实并发形态。""" + account = self.make_account("a3", points=5000) + candidate = pool.make_candidate( + account_id=account.id, username="a3", points=5000, score=1280 + ) + other = make_test_session(self.engine) + try: + first = pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=[candidate], + commodity_id=COMMODITY, + now=NOW, + ) + second = pool.lease_accounts( + other, + holder_user_id=self.admin_id, + candidates=[candidate], + commodity_id=COMMODITY, + now=NOW, + ) + finally: + other.close() + assert len(first) == 1 + assert second == [] + assert ( + self.session.query(AccountLease) + .filter(AccountLease.account_id == account.id) + .count() + == 1 + ) + + def test_quantity_stops_early(self): + accounts = [self.make_account(f"q{i}", points=5000) for i in range(3)] + candidates = [ + pool.make_candidate( + account_id=a.id, username=a.username, points=5000, score=1280 + ) + for a in accounts + ] + leases = pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=candidates, + commodity_id=COMMODITY, + quantity=2, + now=NOW, + ) + assert len(leases) == 2 + + def test_release_for_task_only_frees_its_own_account(self): + """一个批次里每个账号各有一条租约,不能因为一条任务结束就整批释放。""" + accounts = [self.make_account(f"t{i}", points=5000) for i in range(2)] + candidates = [ + pool.make_candidate( + account_id=a.id, username=a.username, points=5000, score=1280 + ) + for a in accounts + ] + leases = pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=candidates, + commodity_id=COMMODITY, + batch_id="batch-1", + now=NOW, + ) + pool.bind_leases_to_batch(self.session, leases, "batch-1") + task = DouyuTask( + batch_id="batch-1", + account_id=accounts[0].id, + task_type="exchange_goods", + handbook_scope="elite", + status="success", + created_by=self.support_id, + ) + self.session.add(task) + self.session.commit() + + assert pool.release_for_task(self.session, task, reason="task_success") is True + self.session.expire_all() + states = { + lease.account_id: lease.status + for lease in self.session.query(AccountLease) + .filter(AccountLease.batch_id == "batch-1") + .all() + } + assert states[accounts[0].id] == "released" + assert states[accounts[1].id] == "active", "同批次另一个账号的租约不该被释放" + + def test_release_is_idempotent(self): + account = self.make_account("i1", points=5000) + lease = pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=[ + pool.make_candidate( + account_id=account.id, username="i1", points=5000, score=1280 + ) + ], + commodity_id=COMMODITY, + now=NOW, + )[0] + assert pool.release_lease(self.session, lease, reason="done") is True + assert pool.release_lease(self.session, lease, reason="done") is False + + def test_released_account_becomes_available_again(self): + account = self.make_account("r1", points=5000) + lease = pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=[ + pool.make_candidate( + account_id=account.id, username="r1", points=5000, score=1280 + ) + ], + commodity_id=COMMODITY, + now=NOW, + )[0] + assert account.id not in {c.account_id for c in self.eligible()} + pool.release_lease(self.session, lease, reason="done") + assert account.id in {c.account_id for c in self.eligible()} + + def test_reap_expired_only_touches_past_due(self): + due = self.make_account("due", points=5000) + live = self.make_account("live", points=5000) + candidates = [ + pool.make_candidate( + account_id=a.id, username=a.username, points=5000, score=1280 + ) + for a in (due, live) + ] + pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=[candidates[0]], + commodity_id=COMMODITY, + ttl_minutes=1, + now=NOW, + ) + pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=[candidates[1]], + commodity_id=COMMODITY, + ttl_minutes=600, + now=NOW, + ) + reaped = pool.reap_expired(self.session, now=NOW + timedelta(minutes=5)) + assert reaped == 1 + self.session.expire_all() + rows = { + lease.account_id: lease.status + for lease in self.session.query(AccountLease).all() + } + assert rows[due.id] == "expired" + assert rows[live.id] == "active" + + def test_leased_account_ids_lists_holder_only(self): + account = self.make_account("h1", points=5000) + pool.lease_accounts( + self.session, + holder_user_id=self.support_id, + candidates=[ + pool.make_candidate( + account_id=account.id, username="h1", points=5000, score=1280 + ) + ], + commodity_id=COMMODITY, + now=NOW, + ) + assert pool.leased_account_ids( + self.session, holder_user_id=self.support_id + ) == [account.id] or account.id in pool.leased_account_ids( + self.session, holder_user_id=self.support_id + ) + assert pool.leased_account_ids(self.session, holder_user_id=self.admin_id) == [] diff --git a/tests/test_backfill_official_exchange_subject_key.py b/tests/test_backfill_official_exchange_subject_key.py new file mode 100644 index 0000000..c594ad6 --- /dev/null +++ b/tests/test_backfill_official_exchange_subject_key.py @@ -0,0 +1,137 @@ +"""D1 回填脚本:把官方补录事件的 `subject_key` 补上商品 ID。 + +脚本是一次性数据修复,但它是**生产数据**的修复 —— 写错了会污染台账, +所以守住三件事:幂等、只填空值、dry-run 不写库。 +""" + +from __future__ import annotations + +import importlib.util +import sys +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import Account, AccountEvent + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[1] + / "scripts" + / "backfill_official_exchange_subject_key.py" +) + +NOW = datetime(2026, 10, 7, 3, 0, 0, tzinfo=UTC) + + +def _load_script(): + spec = importlib.util.spec_from_file_location( + "backfill_official_exchange_subject_key", SCRIPT_PATH + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def script(): + return _load_script() + + +def _run(script, monkeypatch, *argv: str) -> int: + monkeypatch.setattr(sys, "argv", [str(SCRIPT_PATH), *argv]) + return script.main() + + +class TestBackfillOfficialExchangeSubjectKey: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.account = Account( + username="bf-acc", password="p", email="b@e.com", email_password="ep" + ) + self.session.add(self.account) + self.session.flush() + self.account_id = self.account.id + + def teardown_method(self): + self.session.close() + + def _add_event(self, *, commodity_id: str | None, subject_key: str = "") -> int: + event = AccountEvent( + account_id=self.account_id, + event_type="exchange", + category="business", + occurred_at=NOW, + title="兑换 x", + amount=-1280, + subject_type="goods", + subject_key=subject_key, + detail=( + {"source": "official_sync", "commodity_id": commodity_id} + if commodity_id is not None + else {"source": "official_sync"} + ), + source="official_sync", + ) + self.session.add(event) + self.session.commit() + return event.id + + def _reload(self, event_id: int) -> AccountEvent: + """重新读一行 —— **必须先 rollback 结束当前事务**。 + + 脚本用的是**另一个会话**,在 MySQL REPEATABLE READ 下,本会话若沿用旧快照, + `expire_all()` 也只会拿到事务开始时的老值:脚本改没改都显示没改, + 于是「dry-run 不写库」这类断言会**假绿**。 + """ + self.session.rollback() + row = self.session.get(AccountEvent, event_id) + assert row is not None + return row + + def test_dry_run_writes_nothing(self, script, monkeypatch): + event_id = self._add_event(commodity_id="20260120QYOOB_bag10") + + assert _run(script, monkeypatch, "--dry-run") == 0 + + assert self._reload(event_id).subject_key == "", "dry-run 不该写库" + + def test_apply_fills_subject_key(self, script, monkeypatch): + event_id = self._add_event(commodity_id="20260120QYOOB_bag10") + + assert _run(script, monkeypatch, "--apply") == 0 + + row = self._reload(event_id) + assert row.subject_key == "20260120QYOOB_bag10" + assert row.subject_type == "commodity" + + def test_is_idempotent(self, script, monkeypatch): + event_id = self._add_event(commodity_id="20260120QYOOB_bag10") + + assert _run(script, monkeypatch, "--apply") == 0 + # 第二遍没有可回填的行,应当直接退出且不改动 + assert _run(script, monkeypatch, "--apply") == 0 + + assert self._reload(event_id).subject_key == "20260120QYOOB_bag10" + + def test_does_not_overwrite_existing_subject_key(self, script, monkeypatch): + """只填空值 —— 已有值(哪怕看着不对)不由脚本判断。""" + event_id = self._add_event( + commodity_id="20260120QYOOB_bag10", subject_key="手工填的值" + ) + + assert _run(script, monkeypatch, "--apply") == 0 + + assert self._reload(event_id).subject_key == "手工填的值" + + def test_skips_events_without_commodity_id(self, script, monkeypatch): + """没有 `detail.commodity_id` 的行不动,也不该报失败退出码。""" + event_id = self._add_event(commodity_id=None) + + # 没有可回填的行 → 正常退出(不因为「缺 commodity_id」而误报) + assert _run(script, monkeypatch, "--apply") == 0 + + assert self._reload(event_id).subject_key == "" diff --git a/tests/test_douyu_pool_api.py b/tests/test_douyu_pool_api.py new file mode 100644 index 0000000..32a7902 --- /dev/null +++ b/tests/test_douyu_pool_api.py @@ -0,0 +1,342 @@ +"""账号池接口:供给预览、分配下单、租约释放。 + +**这是新增的一条路**,所以用例同时守住「新增的能用」与「旧的没被动」: +- 池路径要能领到号、建出任务、并让租用人看得到自己的任务; +- `create_douyu_planned_tasks` 不传新参数时行为必须与以前一模一样。 +""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime + +import pytest +from fastapi import HTTPException + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import ( + Account, + AccountLease, + DouyuGoodsSnapshot, + DouyuTask, + User, +) +from web.backend.routers import douyu_pool as pool_router +from web.backend.schemas import DouyuPoolExchangeRequest +from web.backend.services import account_pool as pool + +COMMODITY = "20260120QYOOB_bag10" +NOW = datetime(2026, 10, 7, 12, 0, 0, tzinfo=UTC) + + +class _ApiBase: + def setup_method(self, monkeypatch=None): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.admin = User(username="api-admin", password_hash="h", role="super_admin") + self.support = User(username="api-kf", password_hash="h", role="support") + self.other = User(username="api-kf2", password_hash="h", role="support") + self.session.add_all([self.admin, self.support, self.other]) + self.session.flush() + self.admin_id = self.admin.id + self.support_id = self.support.id + self.other_id = self.other.id + self.session.add( + DouyuGoodsSnapshot( + commodity_id=COMMODITY, + name="套装-兔兔白日梦", + score=1280, + ) + ) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def make_account(self, name, *, points=5000, opened=True): + account = Account( + username=name, + password="p", + email="e@e.com", + email_password="ep", + status="active", + ck_check_status="valid", + cookie="PHPSESSID=x", + elite_opened=opened, + points=points, + ) + self.session.add(account) + self.session.commit() + return account + + +class TestSupply(_ApiBase): + def test_counts_and_channel(self): + self.make_account("rich", points=5000) + self.make_account("poor", points=0) + self.make_account("closed", opened=False, points=5000) + + supply = pool_router.pool_supply( + scope="elite", commodity_id=COMMODITY, db=self.session, current=self.support + ) + assert supply["zero_topup"] == 1 + assert supply["after_topup"] == 2 + assert supply["blocked"]["elite_not_opened"] == 1 + assert supply["commodity"]["name"] == "套装-兔兔白日梦" + assert supply["commodity"]["score"] == 1280 + # 生产当前是微信扫码 → 前端要提前告诉客服"这单得你去扫" + assert supply["recharge_channel"] == "wechat_qr" + assert supply["manual_payment_required"] is True + + def test_missing_goods_is_404(self): + with pytest.raises(HTTPException) as exc: + pool_router.pool_supply( + scope="elite", + commodity_id="nope", + db=self.session, + current=self.support, + ) + assert exc.value.status_code == 404 + + def test_unsupported_scope_is_400(self): + with pytest.raises(HTTPException) as exc: + pool_router.pool_supply( + scope="peace", + commodity_id=COMMODITY, + db=self.session, + current=self.support, + ) + assert exc.value.status_code == 400 + + +class TestExchange(_ApiBase): + def _patch_runner(self, monkeypatch, captured: list): + def fake_start(**kwargs): + captured.append(kwargs) + + monkeypatch.setattr(pool_router, "start_douyu_batch", fake_start) + + def _exchange(self, monkeypatch, **overrides): + captured: list = [] + self._patch_runner(monkeypatch, captured) + payload = { + "scope": "elite", + "commodity_id": COMMODITY, + "quantity": 1, + } + payload.update(overrides) + result = asyncio.run( + pool_router.pool_exchange( + DouyuPoolExchangeRequest(**payload), + db=self.session, + current=self.support, + ) + ) + return result, captured + + def test_allocates_creates_tasks_and_binds_batch(self, monkeypatch): + account = self.make_account("a1", points=5000) + result, captured = self._exchange(monkeypatch) + + assert result.allocated == 1 and result.shortfall == 0 + assert [a.account_id for a in result.accounts] == [account.id] + assert captured and captured[0]["batch_id"] == result.batch_id + + lease = self.session.query(AccountLease).one() + assert lease.status == "active" + assert lease.batch_id == result.batch_id + assert lease.commodity_id == COMMODITY + + task = self.session.query(DouyuTask).one() + assert task.task_type == "exchange_goods" + assert task.handbook_scope == "elite" + assert task.batch_id == result.batch_id + assert task.result["payload"]["commodity_id"] == COMMODITY + assert task.result["payload"]["num"] == 1 + + def test_task_is_visible_to_holder_not_to_account_owner(self, monkeypatch): + """池化后账号归属是运营;任务必须写成**租用人**,否则客服看不到自己的单。""" + self.make_account("a2", points=5000) + result, _ = self._exchange(monkeypatch) + task = self.session.query(DouyuTask).one() + assert task.account_assigned_to == self.support_id + assert task.account_assigned_to != self.admin_id + assert result.batch_id + + def test_shortfall_when_not_enough_accounts(self, monkeypatch): + self.make_account("only", points=5000) + result, _ = self._exchange(monkeypatch, quantity=3) + assert result.allocated == 1 + assert result.shortfall == 2 + assert any("可用账号不足" in reason for reason in result.reasons) + + def test_allow_topup_false_excludes_tier_b(self, monkeypatch): + self.make_account("poor", points=0) + result, captured = self._exchange(monkeypatch, allow_topup=False) + assert result.allocated == 0 + assert captured == [], "没领到号就不该启动批次" + assert any("未允许充值" in reason for reason in result.reasons) + + def test_tier_b_reports_recharge_plan(self, monkeypatch): + self.make_account("poor", points=300) + result, _ = self._exchange(monkeypatch) + account = result.accounts[0] + assert account.tier == "B" + assert account.points == 300 + assert account.topup_gifts == 98 # (1280-300)/10 + assert account.topup_yuan == 98 + assert account.recharge_orders == [98] + + def test_tier_b_split_orders_above_800(self, monkeypatch): + """单次 800 元上限:缺口超过时拆单,而不是被拒。""" + self.session.add( + DouyuGoodsSnapshot( + commodity_id="20260120QYOOB_big", name="仙剑奇侠传大礼包", score=9000 + ) + ) + self.session.commit() + self.make_account("poor2", points=0) + result, _ = self._exchange(monkeypatch, commodity_id="20260120QYOOB_big") + account = result.accounts[0] + assert account.topup_yuan == 900 + assert account.recharge_orders == [800, 100] + + def test_tier_b_creates_full_supply_chain(self, monkeypatch): + """B 梯队要排出完整补给链,且每一步都指向前一步(运行期的 `_depends_on`)。""" + self.make_account("chain", points=300) + result, _ = self._exchange(monkeypatch) + assert result.allocated == 1 + tasks = self.session.query(DouyuTask).order_by(DouyuTask.id.asc()).all() + assert [t.task_type for t in tasks] == [ + "query_gold_balance", + "query_points", + "create_gold_qr", + "donate_elite_gift", + "exchange_goods", + ] + # 前两步(查余额)没有载荷 —— `create_douyu_task` 对空载荷存 None,与旧行为一致 + payloads = [(t.result or {}).get("payload", {}) for t in tasks] + assert payloads[2]["amount"] == 98 # (1280-300)/10 + assert payloads[2]["_skip_when"]["column"] == "gold_balance" + assert payloads[3]["gift_count"] == 98 + assert payloads[3]["_skip_when"]["column"] == "points" + assert "_depends_on" not in payloads[0] + assert payloads[1]["_depends_on"] == tasks[0].id + assert payloads[2]["_depends_on"] == tasks[1].id + assert payloads[3]["_depends_on"] == tasks[2].id + assert payloads[4]["_depends_on"] == tasks[3].id + # 每条链上的任务都要让租用人看得到 + assert {t.account_assigned_to for t in tasks} == {self.support_id} + + def test_tier_a_creates_single_task(self, monkeypatch): + """积分已够的账号**不生成任何充值/赠令步骤** —— 那是在花钱。""" + self.make_account("rich2", points=5000) + self._exchange(monkeypatch) + tasks = self.session.query(DouyuTask).all() + assert [t.task_type for t in tasks] == ["exchange_goods"] + + def test_no_accounts_does_not_start_batch(self, monkeypatch): + result, captured = self._exchange(monkeypatch) + assert result.allocated == 0 + assert result.shortfall == 1 + assert captured == [] + + +class TestLeaseEndpoints(_ApiBase): + def _lease(self, holder_id): + account = self.make_account(f"l{holder_id}", points=5000) + return pool.lease_accounts( + self.session, + holder_user_id=holder_id, + candidates=[ + pool.make_candidate( + account_id=account.id, + username=account.username, + points=5000, + score=1280, + ) + ], + commodity_id=COMMODITY, + )[0] + + def test_release_own_lease(self): + lease = self._lease(self.support_id) + result = pool_router.release_lease( + lease.id, db=self.session, current=self.support + ) + assert result["released"] is True + self.session.expire_all() + assert ( + self.session.query(AccountLease) + .filter(AccountLease.id == lease.id) + .one() + .status + == "released" + ) + + def test_cannot_release_someone_elses_lease(self): + lease = self._lease(self.support_id) + with pytest.raises(HTTPException) as exc: + pool_router.release_lease(lease.id, db=self.session, current=self.other) + assert exc.value.status_code == 403 + + def test_pool_admin_can_release_others(self): + lease = self._lease(self.support_id) + result = pool_router.release_lease( + lease.id, db=self.session, current=self.admin + ) + assert result["released"] is True + + def test_release_batch_requires_admin_permission(self): + lease = self._lease(self.support_id) + pool.bind_leases_to_batch(self.session, [lease], "batch-x") + result = pool_router.release_batch( + "batch-x", db=self.session, current=self.admin + ) + assert result["released"] == 1 + + def test_list_leases_only_mine(self): + self._lease(self.support_id) + self._lease(self.other_id) + rows = pool_router.list_leases( + mine=True, + account_id=None, + include_finished=False, + db=self.session, + current=self.support, + ) + assert len(rows) == 1 + assert rows[0].status == "active" + + +class TestLegacyTaskCreationUnchanged(_ApiBase): + def test_override_defaults_to_account_owner(self): + """不传新参数时 `account_assigned_to` 仍取账号归属 —— 旧路径行为不变。""" + from web.backend.services.douyu_service import create_douyu_planned_tasks + + account = Account( + username="legacy", + password="p", + email="e@e.com", + email_password="ep", + status="active", + ck_check_status="valid", + cookie="PHPSESSID=x", + assigned_to=self.support_id, + ) + self.session.add(account) + self.session.commit() + + batch_id, count = create_douyu_planned_tasks( + self.session, + [account.id], + "exchange_goods", + "elite", + self.support_id, + {"commodity_id": COMMODITY}, + ) + assert count == 1 + task = ( + self.session.query(DouyuTask).filter(DouyuTask.batch_id == batch_id).one() + ) + assert task.account_assigned_to == self.support_id diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 690d69f..2f75feb 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261004_0051" +HEAD_REVISION = "20261007_0052" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/tests/test_pool_account_visibility.py b/tests/test_pool_account_visibility.py new file mode 100644 index 0000000..6d37937 --- /dev/null +++ b/tests/test_pool_account_visibility.py @@ -0,0 +1,217 @@ +"""池化后的账号可见性与操作流水。 + +两条都是**新增**的语义,且直接决定客服能不能干活: +1. 客服租用的账号要能看到(否则刚做完的单查不了); +2. 流水要能看出「谁做的」(71% 的历史事件没有操作人,这是要补的)。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from fastapi import HTTPException + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import Account, AccountEvent, User +from web.backend.routers.accounts import account_events, list_accounts +from web.backend.services import account_pool as pool + +NOW = datetime(2026, 10, 7, 15, 0, 0, tzinfo=UTC) + + +class _Base: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.operator = User(username="vis-op", password_hash="h", role="operation") + self.support = User(username="vis-kf", password_hash="h", role="support") + self.session.add_all([self.operator, self.support]) + self.session.flush() + self.operator_id = self.operator.id + self.support_id = self.support.id + + def teardown_method(self): + self.session.close() + + def make_account(self, name: str, *, assigned_to=None) -> Account: + account = Account( + username=name, + password="p", + email="e@e.com", + email_password="ep", + status="active", + ck_check_status="valid", + cookie="PHPSESSID=x", + elite_opened=True, + points=5000, + assigned_to=assigned_to, + ) + self.session.add(account) + self.session.commit() + return account + + def lease(self, account: Account, holder_id: int): + return pool.lease_accounts( + self.session, + holder_user_id=holder_id, + candidates=[ + pool.make_candidate( + account_id=account.id, + username=account.username, + points=5000, + score=1280, + ) + ], + commodity_id="20260120QYOOB_bag10", + )[0] + + +class TestVisibleAccountsWithLease(_Base): + def _visible_names(self, user) -> set[str]: + rows = list_accounts( + db=self.session, + current=user, + page=None, + page_size=100, + search="", + tag=None, + status="", + ck_status="", + bind_status="", + has_cookie=False, + assign_status="", + assigned_user_id=None, + high_value_filter="", + min_points=None, + max_points=None, + min_gold=None, + max_gold=None, + elite_opened=None, + esports_opened=None, + role_bind_status="", + sort=None, + order=None, + include_sensitive=False, + ) + items = rows["items"] if isinstance(rows, dict) else rows + # 走 `model_dump()`:只看属性会漏掉「模型上声明了、序列化里没列」这类 + # Pydantic 静默丢字段(本仓库已经踩过三次)。 + return {item.model_dump()["username"] for item in items} + + def test_leased_account_is_visible_to_holder(self): + account = self.make_account("leased-vis", assigned_to=self.operator_id) + self.lease(account, self.support_id) + assert "leased-vis" in self._visible_names(self.support) + + def test_unleased_operator_account_is_not_visible(self): + self.make_account("hidden-vis", assigned_to=self.operator_id) + assert "hidden-vis" not in self._visible_names(self.support) + + def test_visibility_ends_after_release(self): + account = self.make_account("temp-vis", assigned_to=self.operator_id) + lease = self.lease(account, self.support_id) + pool.release_lease(self.session, lease, reason="done") + assert "temp-vis" not in self._visible_names(self.support) + + +class TestAccountEventActor(_Base): + def _add_event(self, account_id: int, *, actor_id=None, actor_type=""): + self.session.add( + AccountEvent( + account_id=account_id, + event_type="recharge", + category="business", + occurred_at=NOW, + title="充值 48", + amount=48, + subject_type="recharge", + detail={"channel": "wechat_qr"}, + source="douyu_task", + actor_type=actor_type, + actor_id=actor_id, + ) + ) + self.session.commit() + + def test_events_return_actor_name(self): + account = self.make_account("ev-1", assigned_to=self.support_id) + self._add_event(account.id, actor_id=self.support_id, actor_type="user") + + result = account_events( + account.id, + page=1, + page_size=50, + event_type="", + category="", + actor_id=None, + db=self.session, + current=self.support, + ) + assert result["items"][0]["actor_name"] == "vis-kf" + # 序列化结果里必须真有这两个字段(Pydantic/dict 静默丢字段在本仓库踩过) + assert result["items"][0]["actor_id"] == self.support_id + + def test_filter_by_actor_id(self): + account = self.make_account("ev-2", assigned_to=self.operator_id) + self._add_event(account.id, actor_id=self.support_id, actor_type="user") + self._add_event(account.id, actor_id=self.operator_id, actor_type="user") + + mine = account_events( + account.id, + page=1, + page_size=50, + event_type="", + category="", + actor_id=self.support_id, + db=self.session, + current=self.operator, + ) + assert mine["total"] == 1 + assert mine["items"][0]["actor_id"] == self.support_id + + def test_actorless_event_still_returned(self): + """历史事件没有操作人(71%)—— 不能因此被过滤掉或报错。""" + account = self.make_account("ev-3", assigned_to=self.support_id) + self._add_event(account.id) + result = account_events( + account.id, + page=1, + page_size=50, + event_type="", + category="", + actor_id=None, + db=self.session, + current=self.support, + ) + assert result["total"] == 1 + assert result["items"][0]["actor_name"] == "" + + def test_timeline_of_leased_account_is_readable(self): + account = self.make_account("ev-4", assigned_to=self.operator_id) + self._add_event(account.id, actor_id=self.support_id, actor_type="user") + # 没有租约时客服看不到这个账号 + with pytest.raises(HTTPException): + account_events( + account.id, + page=1, + page_size=50, + event_type="", + category="", + actor_id=None, + db=self.session, + current=self.support, + ) + # 租下来就能看(这正是池化后客服复盘自己那单的路径) + self.lease(account, self.support_id) + result = account_events( + account.id, + page=1, + page_size=50, + event_type="", + category="", + actor_id=None, + db=self.session, + current=self.support, + ) + assert result["total"] == 1 diff --git a/tests/test_supply_chain.py b/tests/test_supply_chain.py new file mode 100644 index 0000000..65629de --- /dev/null +++ b/tests/test_supply_chain.py @@ -0,0 +1,278 @@ +"""补给链:缺多少充多少、该跳过的跳过、前置失败就别往下跑。 + +链上每一步都是**真花钱**的动作(充鱼翅、送精英令),所以两道运行期闸门 +(`_skip_when` / `_depends_on`)必须有逆向用例守着:闸门失效 = 白花钱。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import Account, DouyuTask, User +from web.backend.services import account_pool as pool +from web.backend.services.douyu_runner_core import DouyuBatchRunnerCore + +NOW = datetime(2026, 10, 7, 16, 0, 0, tzinfo=UTC) + + +class TestBuildSupplyChain: + def _candidate(self, points: int, score: int = 1280): + return pool.make_candidate( + account_id=1, username="a", points=points, score=score + ) + + def test_tier_a_has_only_exchange(self): + chain = pool.build_supply_chain(self._candidate(5000), commodity_id="C1") + assert [s.task_type for s in chain] == ["exchange_goods"] + assert chain[0].payload == {"commodity_id": "C1", "num": 1} + + def test_tier_b_refreshes_before_spending(self): + """链首必须先刷余额 —— 否则 `_skip_when` 读的是旧值,会多充钱。""" + chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1") + assert [s.task_type for s in chain][:2] == [ + "query_gold_balance", + "query_points", + ] + + def test_tier_b_topup_amount_is_exact_gap(self): + # 缺口 980 分 → 98 个令 → 98 元 + chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1") + recharge = [s for s in chain if s.task_type == "create_gold_qr"] + assert [s.payload["amount"] for s in recharge] == [98] + donate = [s for s in chain if s.task_type == "donate_elite_gift"] + assert donate[0].payload["gift_count"] == 98 + + def test_tier_b_splits_recharge_above_800(self): + # 缺口 9000 分 → 900 个令 → 900 元 → 必须拆成 800 + 100 + chain = pool.build_supply_chain( + self._candidate(0, score=9000), commodity_id="C1" + ) + recharge = [s for s in chain if s.task_type == "create_gold_qr"] + assert [s.payload["amount"] for s in recharge] == [800, 100] + + def test_recharge_step_skips_when_gold_already_enough(self): + chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1") + recharge = next(s for s in chain if s.task_type == "create_gold_qr") + assert recharge.payload["_skip_when"] == { + "column": "gold_balance", + "op": ">=", + "value": 98, + } + + def test_donate_step_skips_when_points_already_enough(self): + chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1") + donate = next(s for s in chain if s.task_type == "donate_elite_gift") + assert donate.payload["_skip_when"] == { + "column": "points", + "op": ">=", + "value": 1280, + } + + def test_exchange_is_last(self): + chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1") + assert chain[-1].task_type == "exchange_goods" + + def test_esports_uses_its_own_task_type(self): + chain = pool.build_supply_chain( + self._candidate(5000), + commodity_id="C1", + exchange_task_type="exchange_esports_goods", + ) + assert chain[-1].task_type == "exchange_esports_goods" + + +class _GateBase: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.user = User(username="gate-kf", password_hash="h", role="support") + self.session.add(self.user) + self.session.commit() + # `douyu_tasks.account_id` 有外键,任务必须挂在一个真实账号上 + anchor = Account( + username="gate-anchor", + password="p", + email="e@e.com", + email_password="ep", + ) + self.session.add(anchor) + self.session.commit() + self.anchor_id = anchor.id + # 只为调用 core 上的两个闸门方法:绕开 __init__(它要日志队列与事件循环) + self.runner = DouyuBatchRunnerCore.__new__(DouyuBatchRunnerCore) + self.runner.payload = {} + + def teardown_method(self): + self.session.close() + + def _account(self, **fields) -> Account: + account = Account( + username=f"gate-{fields.get('points')}", + password="p", + email="e@e.com", + email_password="ep", + **fields, + ) + self.session.add(account) + self.session.commit() + return account + + def _task(self, payload: dict) -> DouyuTask: + task = DouyuTask( + batch_id="b", + account_id=self.anchor_id, + task_type="create_gold_qr", + handbook_scope="elite", + status="planned", + result={"payload": payload}, + created_by=self.user.id, + ) + self.session.add(task) + self.session.commit() + return task + + +class TestSkipWhenGate(_GateBase): + def test_skips_when_condition_met(self): + account = self._account(points=5000, gold_balance=200) + task = self._task( + {"_skip_when": {"column": "gold_balance", "op": ">=", "value": 98}} + ) + assert "跳过" in self.runner._skip_reason(account, task) + + def test_runs_when_condition_not_met(self): + account = self._account(points=5000, gold_balance=10) + task = self._task( + {"_skip_when": {"column": "gold_balance", "op": ">=", "value": 98}} + ) + assert self.runner._skip_reason(account, task) == "" + + def test_missing_value_does_not_skip(self): + """不知道余额时**不能跳** —— 跳了就等于静默不充值,兑换必然失败。""" + account = self._account(points=5000, gold_balance=None) + task = self._task( + {"_skip_when": {"column": "gold_balance", "op": ">=", "value": 98}} + ) + assert self.runner._skip_reason(account, task) == "" + + def test_unknown_column_does_not_skip(self): + account = self._account(points=5000) + task = self._task( + {"_skip_when": {"column": "password", "op": ">=", "value": 1}} + ) + assert self.runner._skip_reason(account, task) == "" + + def test_unknown_operator_does_not_skip(self): + account = self._account(points=5000) + task = self._task({"_skip_when": {"column": "points", "op": "!!", "value": 1}}) + assert self.runner._skip_reason(account, task) == "" + + def test_no_rule_does_not_skip(self): + account = self._account(points=5000) + assert self.runner._skip_reason(account, self._task({})) == "" + + +class TestDependencyGate(_GateBase): + def _previous(self, status: str) -> DouyuTask: + task = DouyuTask( + batch_id="b", + account_id=self.anchor_id, + task_type="create_gold_qr", + handbook_scope="elite", + status=status, + created_by=self.user.id, + ) + self.session.add(task) + self.session.commit() + return task + + def test_blocks_when_previous_failed(self): + previous = self._previous("failed") + task = self._task({"_depends_on": previous.id}) + assert "前置任务" in self.runner._dependency_reason(self.session, task) + + def test_passes_when_previous_succeeded(self): + previous = self._previous("success") + task = self._task({"_depends_on": previous.id}) + assert self.runner._dependency_reason(self.session, task) == "" + + def test_passes_when_previous_was_skipped(self): + """前置被跳过 = 那一步本来就不需要做 → 不该阻塞后面的兑换。""" + previous = self._previous("skipped") + task = self._task({"_depends_on": previous.id}) + assert self.runner._dependency_reason(self.session, task) == "" + + def test_blocks_when_previous_in_manual_required(self): + previous = self._previous("manual_required") + task = self._task({"_depends_on": previous.id}) + assert "前置任务" in self.runner._dependency_reason(self.session, task) + + def test_no_dependency_runs(self): + assert self.runner._dependency_reason(self.session, self._task({})) == "" + + +class TestManualRequiredKeepsLease(_GateBase): + """转人工时**不能把号还回去** —— 客服还在扫码,账号已经挂在这单上了。 + + 链式执行会把后半段标成 `skipped`,那些终态任务同样会触发释放钩子; + 所以判据是「**批次里还有 manual_required**」而不是只看当前这条任务的状态。 + """ + + def _lease(self, batch_id: str): + account = self._account( + points=5000, + cookie="PHPSESSID=x", + ck_check_status="valid", + elite_opened=True, + status="active", + ) + lease = pool.lease_accounts( + self.session, + holder_user_id=self.user.id, + candidates=[ + pool.make_candidate( + account_id=account.id, + username=account.username, + points=5000, + score=1280, + ) + ], + commodity_id="C1", + batch_id=batch_id, + )[0] + return lease + + def _task_of( + self, batch_id: str, status: str, account_id: int | None = None + ) -> DouyuTask: + task = DouyuTask( + batch_id=batch_id, + account_id=account_id or self.anchor_id, + task_type="exchange_goods", + handbook_scope="elite", + status=status, + created_by=self.user.id, + ) + self.session.add(task) + self.session.commit() + return task + + def test_lease_kept_while_waiting_for_manual(self): + lease = self._lease("batch-manual") + self._task_of("batch-manual", "manual_required", lease.account_id) + skipped = self._task_of("batch-manual", "skipped", lease.account_id) + + self.runner._release_pool_lease(self.session, skipped) + + self.session.rollback() + assert self.session.get(type(lease), lease.id).status == "active" + + def test_lease_released_when_no_manual_waiting(self): + lease = self._lease("batch-done") + done = self._task_of("batch-done", "success", lease.account_id) + + self.runner._release_pool_lease(self.session, done) + + self.session.rollback() + assert self.session.get(type(lease), lease.id).status == "released" diff --git a/web/backend/main.py b/web/backend/main.py index 2933a47..df4fbbb 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -24,6 +24,7 @@ from .routers import ( cookies, dashboard, douyu, + douyu_pool, huya, login, proxy, @@ -152,6 +153,7 @@ app.include_router(proxy.router) app.include_router(cookies.router) app.include_router(huya.router) app.include_router(douyu.router) +app.include_router(douyu_pool.router) app.include_router(yyb.router) app.include_router(audit.router) app.include_router(automation.router) diff --git a/web/backend/migrations/versions/20261007_0052_account_leases.py b/web/backend/migrations/versions/20261007_0052_account_leases.py new file mode 100644 index 0000000..d7fc52c --- /dev/null +++ b/web/backend/migrations/versions/20261007_0052_account_leases.py @@ -0,0 +1,163 @@ +"""账号租约 `account_leases` + `accounts.role_change_available_at` + 操作人索引 + +配套文档:`docs/设计-账号自动分配与回收.md`(§3.2 表结构、§4 换绑冷却、§3.14 流水)。 + +三件事: + +1. **`account_leases`** —— 归属(`assigned_to`,管理员/运营的维护责任)与 + 使用权(租约,客服临时占用)分离。核心是生成列 `active_account_id` + 加唯一索引 `uq_lease_active_account`:一个账号同时只能有一个**有效**租约, + 并发抢号由数据库裁决(撞唯一键即换候选),不需要应用层加锁。 +2. **`accounts.role_change_available_at`** —— `change_role_wait_time` 是接口原文的 + 「剩余秒数」,落库即过期;换绑判定需要的是**绝对时刻**。电竞手册复用既有的 + `esports_can_change_time`(本来就是绝对时刻),不另开副本。 +3. **`ix_account_events_actor_occurred`** —— 池化后客服不再持有账号, + 「某客服这段时间做了什么」只能按 `actor_id` 查;此前没有可用索引。 + +**可重跑**:应用启动时会自动跑迁移,所以每个动作都带存在性守卫。 + +**回退**:drop 表、drop 列、drop 索引。租约是运行期状态,回退即丢失 —— +但账号与任务数据不受影响(`assigned_to`、`douyu_tasks` 一律没动)。 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261007_0052" +down_revision: str | None = "20261004_0051" +branch_labels: str | None = None +depends_on: str | None = None + +TABLE = "account_leases" +ACCOUNT_COLUMN = "role_change_available_at" +ACTOR_INDEX = "ix_account_events_actor_occurred" + +#: 「一个账号同时只能有一个有效租约」的数据库级保证。 +ACTIVE_ACCOUNT_EXPR = "CASE WHEN status = 'active' THEN account_id ELSE NULL END" + + +def _table_exists(name: str) -> bool: + conn = op.get_bind() + count = conn.execute( + sa.text( + "SELECT COUNT(*) FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name = :name" + ), + {"name": name}, + ).scalar() + return bool(count) + + +def _column_exists(table: str, name: str) -> bool: + conn = op.get_bind() + count = conn.execute( + sa.text( + "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_schema = DATABASE() " + "AND table_name = :table AND column_name = :name" + ), + {"table": table, "name": name}, + ).scalar() + return bool(count) + + +def _index_exists(table: str, name: str) -> bool: + conn = op.get_bind() + count = conn.execute( + sa.text( + "SELECT COUNT(*) FROM information_schema.statistics " + "WHERE table_schema = DATABASE() " + "AND table_name = :table AND index_name = :name" + ), + {"table": table, "name": name}, + ).scalar() + return bool(count) + + +def upgrade() -> None: + if not _table_exists(TABLE): + op.create_table( + TABLE, + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("holder_user_id", sa.Integer(), nullable=False), + sa.Column( + "scope", sa.String(length=16), nullable=False, server_default="elite" + ), + sa.Column( + "purpose", + sa.String(length=24), + nullable=False, + server_default="exchange", + ), + sa.Column( + "commodity_id", sa.String(length=64), nullable=False, server_default="" + ), + sa.Column( + "batch_id", sa.String(length=64), nullable=False, server_default="" + ), + sa.Column( + "status", sa.String(length=16), nullable=False, server_default="active" + ), + sa.Column("leased_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("released_at", sa.DateTime(), nullable=True), + sa.Column( + "release_reason", + sa.String(length=64), + nullable=False, + server_default="", + ), + # ⚠️ 生成列必须排在 account_id / status 之后:MySQL 只允许生成列 + # 引用它前面已定义的列。 + sa.Column( + "active_account_id", + sa.Integer(), + sa.Computed(ACTIVE_ACCOUNT_EXPR, persisted=True), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["account_id"], ["accounts.id"], name="fk_lease_account" + ), + sa.ForeignKeyConstraint( + ["holder_user_id"], ["users.id"], name="fk_lease_holder" + ), + ) + + if not _index_exists(TABLE, "uq_lease_active_account"): + op.create_index( + "uq_lease_active_account", TABLE, ["active_account_id"], unique=True + ) + if not _index_exists(TABLE, "ix_leases_holder_status"): + op.create_index("ix_leases_holder_status", TABLE, ["holder_user_id", "status"]) + if not _index_exists(TABLE, "ix_leases_status_expires"): + op.create_index("ix_leases_status_expires", TABLE, ["status", "expires_at"]) + if not _index_exists(TABLE, "ix_account_leases_account_id"): + op.create_index("ix_account_leases_account_id", TABLE, ["account_id"]) + + if not _column_exists("accounts", ACCOUNT_COLUMN): + op.add_column( + "accounts", + sa.Column( + ACCOUNT_COLUMN, + sa.DateTime(), + nullable=True, + comment="精英宝典可换绑时刻(绝对时间);换绑冷却只记录不拦截", + ), + ) + + if not _index_exists("account_events", ACTOR_INDEX): + op.create_index(ACTOR_INDEX, "account_events", ["actor_id", "occurred_at"]) + + +def downgrade() -> None: + if _index_exists("account_events", ACTOR_INDEX): + op.drop_index(ACTOR_INDEX, table_name="account_events") + if _column_exists("accounts", ACCOUNT_COLUMN): + op.drop_column("accounts", ACCOUNT_COLUMN) + if _table_exists(TABLE): + op.drop_table(TABLE) diff --git a/web/backend/models.py b/web/backend/models.py index 16b5687..e1aae36 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -8,6 +8,7 @@ from sqlalchemy import ( JSON, BigInteger, Boolean, + Computed, ForeignKey, Index, Integer, @@ -130,6 +131,14 @@ class Account(Base): # S6 拆解时差点把这个信息丢掉,所以补成独立布尔列。 esports_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True) change_role_wait_time: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 精英宝典**可换绑时刻**(绝对时间)。`change_role_wait_time` 是接口原文的 + # 「剩余秒数」,落库那一刻就开始过期 —— 过几天再读它,既不知道是不是还在冷却, + # 也不知道什么时候能换绑。所以每次写入时同时记下 `now + wait_time`。 + # 不同活动的换绑冷却不同,因此**按作用域分列**(电竞手册用既有的 + # `esports_can_change_time`,它本来就是绝对时刻,不另开副本)。 + role_change_available_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True + ) # 精英宝典开通状态(getUserInfo 的 manualType / expireTime)。 # 光看积分区分不了「开通后积分花完」和「从未开通」,必须单独落库。 elite_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True) @@ -885,9 +894,88 @@ class AccountEvent(Base): "subject_key", "occurred_at", ), + # 「某个客服这段时间做了什么」——池化后客服不再持有账号, + # 这条是管理侧追责与对账的主查询路径,此前没有任何索引可用。 + Index("ix_account_events_actor_occurred", "actor_id", "occurred_at"), ) +class AccountLease(Base): + """账号租约:谁在**此刻**可以使用这个账号。 + + ## 为什么要有它 + + 以前「能不能操作某账号」的唯一判据是 `accounts.assigned_to == 我`。 + 账号因此被**永久独占**:客服名下的账号积分花完了也不会流向别人, + 管理员只能人工调拨。池化之后这层隐式互斥消失,必须有一个显式的 + 「临时使用权」——就是这张表。 + + ## 归属与使用权分离 + + - `accounts.assigned_to` 收窄为**维护责任**(谁负责重登、开通、进货), + 由管理员/运营承担,客服名下不再持有账号; + - `account_leases` 表示**此刻谁在用**,用完即释放。 + + ## 一个账号同时只能有一个有效租约 + + 靠生成列 `active_account_id` + 唯一索引在**数据库层**保证:非 active 时为 NULL, + MySQL 唯一索引允许多个 NULL,所以历史记录不受限。并发抢号由数据库裁决 —— + 两个事务抢同一账号必有一个撞唯一键,捕获后换下一个候选即可,不需要应用层加锁。 + + ⚠️ `active_account_id` 必须声明在 `account_id` 与 `status` **之后**: + MySQL 要求生成列只能引用它前面已定义的列。 + """ + + __tablename__ = "account_leases" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + account_id: Mapped[int] = mapped_column( + ForeignKey("accounts.id"), nullable=False, index=True + ) + holder_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + # 作用域:elite / esports / peace(与 douyu_tasks.handbook_scope 同域) + scope: Mapped[str] = mapped_column(String(16), nullable=False, default="elite") + purpose: Mapped[str] = mapped_column( + String(24), nullable=False, default="exchange", server_default="exchange" + ) + commodity_id: Mapped[str] = mapped_column( + String(64), nullable=False, default="", server_default="" + ) + batch_id: Mapped[str] = mapped_column( + String(64), nullable=False, default="", server_default="" + ) + # active / released / expired + status: Mapped[str] = mapped_column( + String(16), nullable=False, default="active", server_default="active" + ) + leased_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow) + # TTL 兜底:进程重启、页面关掉都能靠它把租约收回来。 + expires_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False) + released_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True) + release_reason: Mapped[str] = mapped_column( + String(64), nullable=False, default="", server_default="" + ) + # 生成列:active 时等于 account_id,其余为 NULL。唯一索引因此只约束「有效租约」。 + active_account_id: Mapped[int | None] = mapped_column( + Integer, + Computed( + "CASE WHEN status = 'active' THEN account_id ELSE NULL END", + persisted=True, + ), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow) + + __table_args__ = ( + Index("uq_lease_active_account", "active_account_id", unique=True), + Index("ix_leases_holder_status", "holder_user_id", "status"), + Index("ix_leases_status_expires", "status", "expires_at"), + ) + + account: Mapped[Account] = relationship("Account") + holder: Mapped[User] = relationship("User") + + class AutomationJob(Base): """自动化作业定义。状态落库,进程重启不丢调度进度。""" diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 7c991bd..4536ed0 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -20,6 +20,9 @@ PERMISSIONS = { "account:assign": "分配账号给客服", "account:delete": "删除账号", "account:manage": "停用/归档账号、开关自动化", + # 账号池(自动分配与回收):归属仍是管理员/运营的维护责任,客服按需租用 + "account:pool_use": "使用账号自动分配(领号执行并释放)", + "account:pool_admin": "查看全局账号池、强制释放他人租约", # 登录任务 "login:batch": "发起批量登录", "login:view_all": "查看所有登录任务", @@ -72,6 +75,8 @@ ROLE_PERMISSIONS = { "account:check", "account:assign", "account:manage", + "account:pool_use", + "account:pool_admin", "login:batch", "login:view_all", "cookie:view", @@ -103,6 +108,7 @@ ROLE_PERMISSIONS = { "account:view_assigned", "account:tag", "account:remark", + "account:pool_use", "douyu:task", "yyb:session", "yyb:history", diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 9b862f7..a6299df 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -11,6 +11,7 @@ from ..deps import get_current_user, require_permission from ..models import ( Account, AccountEvent, + AccountLease, AuditLog, DouyuTask, DouyuWorkbenchAccount, @@ -72,12 +73,23 @@ router = APIRouter(prefix="/api/accounts", tags=["账号管理"]) def _visible_accounts_query(db: Session, current: User): - """返回当前用户可见的斗鱼账号查询。""" + """返回当前用户可见的斗鱼账号查询。 + + 可见 = **我名下** ∪ **我当前持有有效租约**。 + 后半条是账号池加的:池化后客服名下不再挂账号(归属是管理员/运营的维护责任), + 不把自己租到的号算进来,客服连刚做完的那单都看不到。 + """ query = db.query(Account) if user_has_permission(current, "account:view_all"): return query if user_has_permission(current, "account:view_assigned"): - return query.filter(Account.assigned_to == current.id) + my_leases = select(AccountLease.account_id).where( + AccountLease.holder_user_id == current.id, + AccountLease.status == "active", + ) + return query.filter( + or_(Account.assigned_to == current.id, Account.id.in_(my_leases)) + ) raise HTTPException(status_code=403, detail="无权查看账号") @@ -1428,15 +1440,21 @@ def account_events( category: str = Query( "", description="按粗分类筛选:lifecycle / auth / business / ops" ), + actor_id: int | None = Query( + None, description="按操作人筛选:谁做的这件事(池化后按客服查流水)" + ), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """账号时间线(倒序分页)。 - 权限沿用账号可见性:客服只能看自己名下账号的事件。 + 权限沿用账号可见性:客服能看**自己名下 ∪ 自己租用中**的账号事件。 `category` 是给页面用的「大分类」过滤(状态变迁 / 登录与 CK / 业务 / 人工操作), 比逐一点 `event_type` 好用:客服最常看的是「人工操作」与「状态变迁」两条线。 + + `actor_id` 是池化后新增的:客服不再长期持有账号,所以"这个账号被谁动过、 + 动了什么"必须能按人查 —— 配合 `_task_actor` 落库的 `actor_id` 形成完整流水。 """ acc = _visible_accounts_query(db, current).filter(Account.id == account_id).first() if not acc: @@ -1454,6 +1472,9 @@ def account_events( if sql_category: where += " AND category = :category" params["category"] = sql_category + if isinstance(actor_id, int) and not isinstance(actor_id, bool): + where += " AND actor_id = :actor_id" + params["actor_id"] = int(actor_id) total = ( db.execute( @@ -1480,8 +1501,23 @@ def account_events( .all() ) + items = [dict(row) for row in rows] + # 补上操作人的用户名:池化后「谁做的」是这一屏最重要的信息之一, + # 前端拿到 id 还要再查一次不值当。一次 IN 查询即可。 + actor_ids = {item.get("actor_id") for item in items if item.get("actor_id")} + actor_names: dict[int, str] = {} + if actor_ids: + actor_names = { + user_id: name + for user_id, name in db.query(User.id, User.username) + .filter(User.id.in_(actor_ids)) + .all() + } + for item in items: + item["actor_name"] = actor_names.get(item.get("actor_id"), "") + return { - "items": [dict(row) for row in rows], + "items": items, "total": int(total), "page": page, "page_size": page_size, diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 8bb0094..0dad9dc 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -30,6 +30,7 @@ from ..database import SessionLocal, get_db from ..deps import authenticate_websocket, get_current_user, require_permission from ..models import ( Account, + AccountLease, DouyuConfig, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, @@ -240,13 +241,24 @@ def _visible_tasks_query(db: Session, current: User): def _require_task_account_access( db: Session, current: User, account_ids: list[int] ) -> None: - """确保任务只会提交到当前用户可操作的账号。""" + """确保任务只会提交到当前用户可操作的账号。 + + 可操作 = **我名下** ∪ **我当前持有有效租约**。 + 后半条是账号池加的(归属是管理员/运营的维护责任,客服只按需租用)—— + 旧路径「名下账号」一字未改,这里只做**并集**。 + """ requested_ids = set(account_ids) query = db.query(Account.id).filter(Account.id.in_(requested_ids)) if not _can_view_all(current): if not user_has_permission(current, "account:view_assigned"): raise HTTPException(status_code=403, detail="无权操作斗鱼账号") - query = query.filter(Account.assigned_to == current.id) + my_leases = select(AccountLease.account_id).where( + AccountLease.holder_user_id == current.id, + AccountLease.status == "active", + ) + query = query.filter( + or_(Account.assigned_to == current.id, Account.id.in_(my_leases)) + ) allowed_ids = {account_id for (account_id,) in query.all()} if allowed_ids != requested_ids: raise HTTPException(status_code=403, detail="包含无权操作的斗鱼账号") @@ -265,6 +277,40 @@ def _require_batch_owner(db: Session, current: User, batch_id: str) -> None: raise HTTPException(status_code=403, detail="无权操作该斗鱼任务批次") +def start_douyu_batch( + *, + batch_id: str, + task_type: str, + payload: dict, + concurrency: int, +) -> None: + """在线程里启动一个批次的执行器(**必须在有事件循环的 async 路由里调用**)。 + + 抽出来是因为账号池也要起批次:两条路径的执行方式必须完全一致 —— + 复制一份的话,将来改了注册表或线程模型就会出现"老路径好了、池路径没好"。 + + ⚠️ `DouyuBatchRunner` 按 `batch_id` 取**全部 planned 任务、不看 `task_type`**, + 再按每条任务自己的类型分发。所以**一个批次里可以放不同类型的任务**; + 但它们会被并发执行(`concurrency` 个 worker),顺序没有保证 —— + 需要严格先后(充值 → 赠令 → 兑换)时只能把 `concurrency` 设成 1。 + """ + log_queue = asyncio.Queue() + loop = asyncio.get_running_loop() + thread_db = SessionLocal() + runner = DouyuBatchRunner( + db=thread_db, + batch_id=batch_id, + task_type=task_type, + payload=payload, + log_queue=log_queue, + loop=loop, + concurrency=concurrency, + ) + douyu_batch_registry.register(batch_id, log_queue, loop, runner) + thread = threading.Thread(target=runner.run, daemon=True) + thread.start() + + def _parse_account_names(raw_names: str) -> list[str]: """解析前端粘贴的 Excel 账号名,每行一个并去重。""" names: list[str] = [] @@ -715,22 +761,12 @@ async def create_task_batch( ) db.commit() - log_queue = asyncio.Queue() - loop = asyncio.get_running_loop() - thread_db = SessionLocal() - runner = DouyuBatchRunner( - db=thread_db, + start_douyu_batch( batch_id=batch_id, task_type=req.task_type, payload=req.payload, - log_queue=log_queue, - loop=loop, concurrency=req.concurrency, ) - douyu_batch_registry.register(batch_id, log_queue, loop, runner) - - thread = threading.Thread(target=runner.run, daemon=True) - thread.start() return {"batch_id": batch_id, "count": count, "success": True} diff --git a/web/backend/routers/douyu_pool.py b/web/backend/routers/douyu_pool.py new file mode 100644 index 0000000..455fa06 --- /dev/null +++ b/web/backend/routers/douyu_pool.py @@ -0,0 +1,447 @@ +"""账号池路由:自动分配账号 + 用完自动收回。 + +配套文档:`docs/设计-账号自动分配与回收.md`。 + +**这是新增的一条路,不是改造旧的**: +- 旧路径(管理员分配 → 客服导入工作台 → 手动选号)在 `routers/douyu.py` 里一字未动; +- 本模块只提供 `/api/douyu/pool/*`,前端对应**新建**的「账号分配台」页面。 + +与旧路径共享的东西只有三处,都是**扩展而非替换**: +1. `create_douyu_planned_tasks` 多了一个可选参数(默认行为不变); +2. `_require_task_account_access` 的可操作集合变成「我名下 ∪ 我持有的有效租约」; +3. `start_douyu_batch` 抽成函数,两条路径用同一套执行方式。 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import require_permission +from ..models import ( + Account, + AccountLease, + DouyuEsportsGoodsSnapshot, + DouyuGoodsSnapshot, + User, +) +from ..permissions import user_has_permission +from ..schemas import ( + DouyuPoolAccountOut, + DouyuPoolExchangeRequest, + DouyuPoolExchangeResponse, + DouyuPoolLeaseOut, +) +from ..services import account_pool as pool +from ..services.audit_service import record_audit +from ..services.douyu_service import create_douyu_task, ensure_douyu_config +from .douyu import start_douyu_batch + +router = APIRouter(prefix="/api/douyu/pool", tags=["账号池"]) + +#: 池路径一账号一件(一期)。批量商品的 `num` 以后再开。 +ITEM_PER_ACCOUNT = 1 + +#: 池批次默认并发。**串行**是为了让「充值 → 赠令 → 兑换」的先后成立: +#: `DouyuBatchRunner` 按 batch_id 取全部 planned 任务并发执行,顺序没有保证。 +POOL_BATCH_CONCURRENCY = 1 + + +def _goods_snapshot(db: Session, scope: str, commodity_id: str): + model = ( + DouyuGoodsSnapshot if scope == pool.SCOPE_ELITE else DouyuEsportsGoodsSnapshot + ) + return ( + db.query(model) + .filter(model.commodity_id == commodity_id) + .order_by(model.id.desc()) + .first() + ) + + +def _require_scope(scope: str) -> str: + if scope not in pool.SUPPORTED_POOL_SCOPES: + raise HTTPException( + status_code=400, + detail=f"该作用域暂不支持自动分配:{scope}", + ) + return scope + + +def _goods_required(db: Session, scope: str, commodity_id: str) -> int: + goods = _goods_snapshot(db, scope, commodity_id) + if goods is None: + raise HTTPException( + status_code=404, + detail="找不到该商品,请先在对应工作台刷新商品列表", + ) + score = int(goods.score or 0) + if score <= 0: + raise HTTPException(status_code=400, detail="该商品没有有效积分价格,无法分配") + return score + + +def _iso(value: datetime | None) -> str | None: + return value.isoformat() if value else None + + +def _is_pool_admin(current: User) -> bool: + return user_has_permission(current, "account:pool_admin") + + +@router.get("/supply") +def pool_supply( + scope: str = Query("elite", pattern="^(elite|esports)$"), + commodity_id: str = Query(..., min_length=1, max_length=64), + db: Session = Depends(get_db), + current: User = Depends(require_permission("account:pool_use")), +) -> dict[str, Any]: + """**不锁号**地预览「现在能分出几个、要花多少钱」。""" + scope = _require_scope(scope) + score = _goods_required(db, scope, commodity_id) + supply = pool.estimate_supply( + db, scope=scope, score=score, commodity_id=commodity_id + ) + goods = _goods_snapshot(db, scope, commodity_id) + channel = str(douyu_config_value(db, "gold_recharge_channel") or "wechat_qr") + supply["commodity"] = { + "commodity_id": commodity_id, + "name": (goods.name if goods else "") or "", + "score": score, + } + supply["cooling"] = _cooling_summary(db) + supply["recharge_channel"] = channel + supply["manual_payment_required"] = channel != "supplier_api" + return supply + + +def douyu_config_value(db: Session, key: str) -> Any: + """读斗鱼配置(**只读**)。 + + 池路径**绝不写** `gold_recharge_channel` —— 那个开关由管理员在 + `PUT /api/douyu/config` 手动切换(见设计 §3.4.1)。 + """ + config = ensure_douyu_config(db) + return getattr(config, key, None) + + +def _cooling_summary(db: Session) -> dict[str, int]: + """换绑冷却概览(**只记录不拦截**,给前端提示用)。 + + 按第 3 条:不同活动的换绑冷却不同,一期只把「什么时候能换绑」记下来给客服看, + 不作为候选过滤条件 —— 真卡这条,池子会从几千个掉到几百个。 + """ + moment = _utcnow() + base = db.query(Account).filter( + Account.status == "active", Account.ck_check_status == "valid" + ) + in_cooldown = base.filter( + Account.role_change_available_at.isnot(None), + Account.role_change_available_at > moment, + ).count() + changeable = base.filter( + (Account.role_change_available_at.is_(None)) + | (Account.role_change_available_at <= moment) + ).count() + return {"role_change_in_cooldown": in_cooldown, "changeable_now": changeable} + + +def _utcnow() -> datetime: + from datetime import UTC + + return datetime.now(UTC) + + +@router.post("/exchange", response_model=DouyuPoolExchangeResponse) +async def pool_exchange( + req: DouyuPoolExchangeRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("account:pool_use")), +) -> DouyuPoolExchangeResponse: + """提交需求:分配账号 → 建任务 → 启动批次。用完由执行器自动释放租约。""" + scope = _require_scope(req.scope) + score = _goods_required(db, scope, req.commodity_id) + + candidates = pool.eligible_accounts( + db, + scope=scope, + score=score, + commodity_id=req.commodity_id, + limit=max(req.quantity, 1) * 3, + ) + reasons: list[str] = [] + if not req.allow_topup: + before = len(candidates) + candidates = [c for c in candidates if not c.needs_topup] + if len(candidates) < before: + reasons.append( + f"已排除 {before - len(candidates)} 个需要充值积分的账号(本次未允许充值)" + ) + if len(candidates) < req.quantity: + supply = pool.estimate_supply( + db, scope=scope, score=score, commodity_id=req.commodity_id + ) + reasons.append( + f"可用账号不足:满足条件仅 {len(candidates)} 个" + f"(可直接兑 {supply['zero_topup']} 个,充值后可兑 {supply['after_topup']} 个)" + ) + if not candidates: + return DouyuPoolExchangeResponse( + requested=req.quantity, + allocated=0, + shortfall=req.quantity, + reasons=reasons or ["没有可用账号"], + recharge_channel=str(douyu_config_value(db, "gold_recharge_channel") or ""), + manual_payment_required=( + str(douyu_config_value(db, "gold_recharge_channel") or "") + != "supplier_api" + ), + ) + + leases = pool.lease_accounts( + db, + holder_user_id=current.id, + candidates=candidates, + scope=scope, + commodity_id=req.commodity_id, + quantity=req.quantity, + ) + if not leases: + return DouyuPoolExchangeResponse( + requested=req.quantity, + allocated=0, + shortfall=req.quantity, + reasons=[*reasons, "账号刚被其他客服领走,请重试"], + recharge_channel=str(douyu_config_value(db, "gold_recharge_channel") or ""), + ) + + account_ids = [lease.account_id for lease in leases] + task_type = ( + "exchange_goods" if scope == pool.SCOPE_ELITE else "exchange_esports_goods" + ) + by_id = {c.account_id: c for c in candidates} + + # 补给链:A 梯队只有「兑换」一步;B 梯队是「查余额 → 充值 → 查积分 → 赠令 → 兑换」。 + # 每个账号的充值额/赠令数都不一样,所以逐账号建任务(不能共用一份 payload)。 + # 顺序由「同一批次 + 并发 1」保证;运行期另有两道闸门 + # (`_depends_on` 前置失败就跳过、`_skip_when` 条件已满足就跳过,见 runner)。 + batch_id = uuid.uuid4().hex[:12] + accounts_by_id = { + acc.id: acc + for acc in db.query(Account).filter(Account.id.in_(account_ids)).all() + } + created = 0 + no_ck: list[AccountLease] = [] + for lease in leases: + account = accounts_by_id.get(lease.account_id) + if account is None: + continue + if not (account.cookie or ""): + no_ck.append(lease) + continue + previous_id: int | None = None + for step in pool.build_supply_chain( + by_id[lease.account_id] + if lease.account_id in by_id + else pool.make_candidate( + account_id=account.id, + username=account.username, + points=account.points, + score=score, + ), + commodity_id=req.commodity_id, + exchange_task_type=task_type, + ): + payload = dict(step.payload) + if previous_id is not None: + payload["_depends_on"] = previous_id + task = create_douyu_task( + db, + account, + step.task_type, + scope, + current.id, + payload, + batch_id=batch_id, + # 池路径:任务列表对客服的过滤走这一列,写**租用人**客服才看得到自己的单子。 + account_assigned_to=current.id, + ) + previous_id = task.id + created += 1 + + if no_ck: + # 账号没有可用 CK:把刚领的租约立刻还回去,别占着。 + pool.release_many(db, no_ck, reason="no_ck") + pool.bind_leases_to_batch(db, leases, batch_id) + + if created == 0: + pool.release_many(db, leases, reason="no_ck") + raise HTTPException( + status_code=400, + detail="分配到的账号当前没有可用 Cookie,已释放,请稍后重试", + ) + + record_audit( + db, + current, + action="douyu:pool:exchange", + target=f"douyu_batch:{batch_id}", + detail={ + "scope": scope, + "commodity_id": req.commodity_id, + "requested": req.quantity, + "allocated": len(leases), + "account_ids": account_ids, + "topup_gifts": sum(c.topup_gifts for c in candidates[: len(leases)]), + }, + ) + db.commit() + + start_douyu_batch( + batch_id=batch_id, + task_type=task_type, + payload={"commodity_id": req.commodity_id, "num": ITEM_PER_ACCOUNT}, + concurrency=POOL_BATCH_CONCURRENCY, + ) + + channel = str(douyu_config_value(db, "gold_recharge_channel") or "") + return DouyuPoolExchangeResponse( + batch_id=batch_id, + requested=req.quantity, + allocated=len(leases), + shortfall=max(0, req.quantity - len(leases)), + accounts=[ + _account_out(db, lease, by_id.get(lease.account_id)) for lease in leases + ], + reasons=reasons, + recharge_channel=channel, + manual_payment_required=channel != "supplier_api", + ) + + +def _account_out(db: Session, lease: AccountLease, candidate) -> DouyuPoolAccountOut: + account = db.query(Account).filter(Account.id == lease.account_id).first() + topup = int(candidate.topup_gifts) if candidate else 0 + return DouyuPoolAccountOut( + account_id=lease.account_id, + username=(account.username if account else ""), + tier=(candidate.tier if candidate else "A"), + points=int(candidate.points) if candidate else 0, + topup_gifts=topup, + topup_yuan=topup, + recharge_orders=pool.recharge_amounts_for(topup), + role_change_available_at=_iso( + account.role_change_available_at if account else None + ), + ) + + +@router.get("/leases", response_model=list[DouyuPoolLeaseOut]) +def list_leases( + mine: bool = Query(True, description="只看我自己的(默认)"), + account_id: int | None = Query( + None, description="按账号查它的租赁记录(账号详情页的「租赁记录」用)" + ), + include_finished: bool = Query( + False, description="是否带上已释放/已过期的历史记录" + ), + db: Session = Depends(get_db), + current: User = Depends(require_permission("account:pool_use")), +) -> list[DouyuPoolLeaseOut]: + query = db.query(AccountLease) + if not include_finished: + query = query.filter(AccountLease.status == "active") + if account_id is not None: + query = query.filter(AccountLease.account_id == int(account_id)) + # 查某个账号的租赁记录时不再要求「必须是我租的」—— + # 否则管理员复盘某账号时只会看到空白。能看到这个账号的人就能看它的租约史。 + elif mine or not _is_pool_admin(current): + query = query.filter(AccountLease.holder_user_id == current.id) + rows = query.order_by(AccountLease.id.desc()).limit(500).all() + account_ids = [row.account_id for row in rows] + accounts: dict[int, Account] = {} + if account_ids: + accounts = { + acc.id: acc + for acc in db.query(Account).filter(Account.id.in_(account_ids)).all() + } + holder_ids = {row.holder_user_id for row in rows} + holders: dict[int, str] = {} + if holder_ids: + holders = { + user_id: name + for user_id, name in db.query(User.id, User.username) + .filter(User.id.in_(holder_ids)) + .all() + } + return [ + DouyuPoolLeaseOut( + id=row.id, + account_id=row.account_id, + username=( + accounts.get(row.account_id).username + if accounts.get(row.account_id) + else "" + ), + holder_user_id=row.holder_user_id, + holder_name=holders.get(row.holder_user_id, ""), + scope=row.scope, + commodity_id=row.commodity_id, + batch_id=row.batch_id, + status=row.status, + leased_at=_iso(row.leased_at), + expires_at=_iso(row.expires_at), + role_change_available_at=_iso( + accounts[row.account_id].role_change_available_at + if row.account_id in accounts + else None + ), + points=( + accounts[row.account_id].points if row.account_id in accounts else None + ), + ) + for row in rows + ] + + +@router.post("/leases/{lease_id}/release") +def release_lease( + lease_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("account:pool_use")), +) -> dict[str, Any]: + """手动释放租约(客服放弃、或用完提前还)。""" + lease = db.query(AccountLease).filter(AccountLease.id == lease_id).first() + if lease is None: + raise HTTPException(status_code=404, detail="租约不存在") + if lease.holder_user_id != current.id and not _is_pool_admin(current): + raise HTTPException(status_code=403, detail="无权释放他人的租约") + released = pool.release_lease(db, lease, reason="manual_release") + return {"success": True, "released": released} + + +@router.post("/leases/release-batch/{batch_id}") +def release_batch( + batch_id: str, + db: Session = Depends(get_db), + current: User = Depends(require_permission("account:pool_admin")), +) -> dict[str, Any]: + """超管强制释放一个批次的全部有效租约。""" + count = pool.release_batch(db, batch_id, reason="admin_release") + record_audit( + db, + current, + action="douyu:pool:release_batch", + target=f"douyu_batch:{batch_id}", + detail={"released": count}, + ) + db.commit() + return {"success": True, "released": count} + + +__all__ = ["router", "start_douyu_batch"] diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 10af0ce..2625ee9 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -850,6 +850,64 @@ class DouyuWorkbenchAccountsUpdate(BaseModel): account_ids: list[int] = Field(default_factory=list) +class DouyuPoolExchangeRequest(BaseModel): + """账号池:客服选皮肤 + 数量,由系统分配账号。 + + - `quantity` 是**账号个数**(一期一账号一件,`num=1`); + - `allow_topup=True` 时积分不足的账号也会入选,并按缺口自动充值积分; + - `allow_fallback=True` 时某个账号执行失败(已达上限/无库存等)会自动补位。 + """ + + scope: str = Field("elite", pattern="^(elite|esports)$") + commodity_id: str = Field(..., min_length=1, max_length=64) + quantity: int = Field(1, ge=1, le=50) + allow_topup: bool = True + allow_fallback: bool = False + + +class DouyuPoolAccountOut(BaseModel): + account_id: int + username: str = "" + # A = 积分已够(免充值);B = 需要充值 + tier: str = "A" + points: int = 0 + topup_gifts: int = 0 + # 需要充多少元(1 元 = 1 鱼翅 = 1 精英令) + topup_yuan: int = 0 + # 充值订单金额列表(每笔 ≤ 800 元);空 = 不需要充值 + recharge_orders: list[int] = Field(default_factory=list) + # 该账号何时可以换绑游戏角色(只提示,不做候选过滤) + role_change_available_at: str | None = None + + +class DouyuPoolExchangeResponse(BaseModel): + batch_id: str = "" + requested: int = 0 + allocated: int = 0 + shortfall: int = 0 + accounts: list[DouyuPoolAccountOut] = Field(default_factory=list) + reasons: list[str] = Field(default_factory=list) + # 当前鱼翅充值渠道(**只读回显**,让客服提交前就知道要不要去扫码) + recharge_channel: str = "" + manual_payment_required: bool = False + + +class DouyuPoolLeaseOut(BaseModel): + id: int + account_id: int + username: str = "" + holder_user_id: int = 0 + holder_name: str = "" + scope: str = "" + commodity_id: str = "" + batch_id: str = "" + status: str = "" + leased_at: str | None = None + expires_at: str | None = None + role_change_available_at: str | None = None + points: int | None = None + + class DouyuTaskOut(BaseModel): id: int batch_id: str diff --git a/web/backend/services/account_pool.py b/web/backend/services/account_pool.py new file mode 100644 index 0000000..3662c4a --- /dev/null +++ b/web/backend/services/account_pool.py @@ -0,0 +1,734 @@ +"""账号池:候选筛选、两梯队排序、供给预估、租约领取与释放。 + +配套文档:`docs/设计-账号自动分配与回收.md`(§3.3 候选与排序、§3.4 补给链、 +§3.5 原子领取、§3.9 自动收回)。 + +## 为什么要拆「纯函数」与「SQL」 + +排序与充值量计算是**业务规则的集中地**(先花快过期的、先用小余额、 +缺多少补多少),也是最容易被改错、必须能被反向验证的部分 —— +所以它们是不碰数据库的纯函数,用例可以脱离 MySQL 直接喂数据。 + +SQL 只负责**筛选**(谁的 CK 有效、谁已开通、谁 365 天内没兑过、谁没被租走)。 + +## 两个必须记住的约束 + +1. **`elite_opened = 1` 是硬门槛**:开通精英宝典要人工扫码 + (`create_elite_qr` 生成支付码),系统不能自动开通 —— 未开通的账号进池也白进。 +2. **并发抢号靠数据库**:`account_leases.active_account_id` 上的唯一索引裁决胜负, + 撞键就换下一个候选(SAVEPOINT 包住,不影响已经领到的那些)。 + 不要退回「先查再插」——那在并发下必然双发。 +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import and_, func, not_, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from ..models import Account, AccountEvent, AccountLease, DouyuTask, User +from .account_binding import SCOPE_ELITE, SCOPE_ESPORTS + +#: 租约默认有效期(分钟)。到点由 `account_lease_reaper` 兜底回收。 +DEFAULT_LEASE_TTL_MINUTES = 60 + +#: 同一件皮肤多久内不允许在同一账号再兑(滚动天数)。 +EXCHANGE_COOLDOWN_DAYS = 365 + +#: 1 个精英令 = 10 精英积分 = 1 鱼翅 = 1 元(2026-10-07 生产实测)。 +POINTS_PER_GIFT = 10 + +#: 单次鱼翅充值上限(元)。见设计 §3.4.2。 +MAX_RECHARGE_AMOUNT = 800 + +#: 池成员 = 未分配 ∪ 管理员/运营名下;客服名下账号不进池(不跨归属)。 +_POOL_OWNER_ROLES = ("super_admin", "operation") + +#: 作用域 → (积分列名,开通标记列名)。和平小店没有「开通」概念。 +_SCOPE_COLUMNS: dict[str, tuple[str, str | None]] = { + SCOPE_ELITE: ("points", "elite_opened"), + SCOPE_ESPORTS: ("esports_points", "esports_opened"), +} + +SUPPORTED_POOL_SCOPES = tuple(_SCOPE_COLUMNS) + + +def _now(now: datetime | None = None) -> datetime: + return now or datetime.now(UTC) + + +@dataclass(frozen=True) +class Candidate: + """一个可分配账号在**本次兑换**下的画像。""" + + account_id: int + username: str + points: int + score: int + tier: str # "A" = 积分已够(免充值);"B" = 需要充值 + topup_gifts: int + points_expire_at: datetime | None = None + role_change_available_at: datetime | None = None + + @property + def topup_yuan(self) -> int: + """需要充多少元(1 元 = 1 鱼翅 = 1 精英令)。""" + return self.topup_gifts + + @property + def needs_topup(self) -> bool: + return self.topup_gifts > 0 + + +def topup_gifts_for(points: int | None, score: int) -> int: + """凑够 `score` 还差几个精英令(向下取整的缺口向上取整到整数个令)。 + + >>> topup_gifts_for(300, 1280) + 98 + >>> topup_gifts_for(1280, 1280) + 0 + >>> topup_gifts_for(None, 20) + 2 + """ + current = int(points or 0) + missing = max(0, int(score) - current) + return (missing + POINTS_PER_GIFT - 1) // POINTS_PER_GIFT + + +def _sort_key(candidate: Candidate): + """两梯队排序键(文档 §3.3)。 + + A 梯队(免充值):先花**快过期**的 → 再用**小余额**(把大额账号留给贵商品)。 + B 梯队(需充值):先选**充值最少**的 → 再用**大余额**(留存量的先用掉)。 + + `points_expire_at` 为空排最后;最后统一用 `account_id` 兜稳定顺序, + 保证同样的输入永远得到同样的顺序(否则用例无法断言)。 + """ + if candidate.tier == "A": + expire_missing = candidate.points_expire_at is None + expire_key = candidate.points_expire_at or datetime.max.replace(tzinfo=UTC) + return (0, expire_missing, expire_key, candidate.points, candidate.account_id) + return ( + 1, + candidate.topup_gifts, + -candidate.points, + candidate.account_id, + ) + + +def make_candidate( + *, + account_id: int, + username: str, + points: int | None, + score: int, + points_expire_at: datetime | None = None, + role_change_available_at: datetime | None = None, +) -> Candidate: + """由账号行构造候选(纯函数,便于用例直接喂数据)。""" + current = int(points or 0) + gifts = topup_gifts_for(current, score) + return Candidate( + account_id=account_id, + username=username, + points=current, + score=int(score), + tier="A" if gifts == 0 else "B", + topup_gifts=gifts, + points_expire_at=points_expire_at, + role_change_available_at=role_change_available_at, + ) + + +def rank_candidates(candidates: Iterable[Candidate]) -> list[Candidate]: + """按两梯队规则排序(纯函数)。""" + return sorted(candidates, key=_sort_key) + + +def latest_exchange_exists_clause(commodity_id: str, since: datetime): + """「该账号在 `since` 之后兑过这件商品」的子查询条件。""" + return ( + select(AccountEvent.id) + .where( + AccountEvent.account_id == Account.id, + AccountEvent.event_type == "exchange", + # 只按 subject_key 判:D1 回填后我方任务与官方补录都是商品 ID。 + # 不额外约束 subject_type —— 部分回填的历史行 subject_type 仍是 goods, + # 按它过滤会漏判(这正是 D1 要修的东西)。 + AccountEvent.subject_key == commodity_id, + AccountEvent.occurred_at >= since, + ) + .exists() + ) + + +def active_lease_exists_clause(): + """「该账号存在有效租约」的子查询条件。""" + return ( + select(AccountLease.id) + .where(AccountLease.active_account_id == Account.id) + .exists() + ) + + +def pool_membership_clause(): + """池成员:未分配 ∪ 管理员/运营名下(客服名下账号不进池)。""" + owner_ids = select(User.id).where(User.role.in_(_POOL_OWNER_ROLES)) + return or_(Account.assigned_to.is_(None), Account.assigned_to.in_(owner_ids)) + + +def candidate_query( + *, + scope: str, + score: int, + commodity_id: str, + now: datetime | None = None, +): + """候选账号的基础查询(**未排序、未限量**,供计数与取行共用)。""" + if scope not in _SCOPE_COLUMNS: + raise ValueError(f"暂不支持的作用域: {scope}") + points_column_name, opened_column_name = _SCOPE_COLUMNS[scope] + points_column = getattr(Account, points_column_name) + since = _now(now) - timedelta(days=EXCHANGE_COOLDOWN_DAYS) + + conditions: list[Any] = [ + Account.status == "active", + Account.ck_check_status == "valid", + Account.cookie.isnot(None), + Account.cookie != "", + Account.auto_managed.is_(True), + pool_membership_clause(), + not_(active_lease_exists_clause()), + not_(latest_exchange_exists_clause(commodity_id, since)), + ] + if opened_column_name: + # 未开通的账号系统开不了(要人工扫码),直接排除。 + # `NULL` = 没查过,同样排除 —— 不能把「不知道」当「已开通」放行。 + conditions.append(getattr(Account, opened_column_name).is_(True)) + # 积分未知(没查过)时按 0 处理会把充值量算错,先排除。 + conditions.append(points_column.isnot(None)) + return select(Account).where(and_(*conditions)), points_column + + +def eligible_accounts( + db: Session, + *, + scope: str = SCOPE_ELITE, + score: int, + commodity_id: str, + now: datetime | None = None, + limit: int | None = None, +) -> list[Candidate]: + """筛出可分配账号并按两梯队排序。 + + `limit` 是为了「要 5 个」时不用把整个池拉出来 —— 但**排序键里有 Python 侧 + 的表达式**(到期时间、充值量),所以 SQL 只做粗筛 + 有界取行,再在 Python 里排。 + 取值上限给足余量(默认 `limit*5`,至少 200),否则「积分够的账号排在后面」 + 会被截断掉。 + """ + query, points_column = candidate_query( + scope=scope, score=score, commodity_id=commodity_id, now=now + ) + query = query.order_by(points_column.desc(), Account.id.asc()) + if limit is not None: + query = query.limit(max(200, int(limit) * 5)) + rows = db.execute(query).scalars().all() + candidates = [ + make_candidate( + account_id=row.id, + username=row.username, + points=getattr(row, _SCOPE_COLUMNS[scope][0]), + score=score, + points_expire_at=row.points_expire_at, + role_change_available_at=row.role_change_available_at, + ) + for row in rows + ] + return rank_candidates(candidates) + + +def estimate_supply( + db: Session, + *, + scope: str = SCOPE_ELITE, + score: int, + commodity_id: str, + now: datetime | None = None, +) -> dict[str, Any]: + """不做任何锁定,预估「现在能分出几个、要花多少钱」(文档 §3.8)。 + + 返回的四个数在写死的口径下**互斥且可加**: + `zero_topup + (after_topup - zero_topup) + blocked 三项 = 池外无关账号` 不计。 + """ + if scope not in _SCOPE_COLUMNS: + raise ValueError(f"暂不支持的作用域: {scope}") + points_column_name, opened_column_name = _SCOPE_COLUMNS[scope] + points_column = getattr(Account, points_column_name) + opened_column = getattr(Account, opened_column_name) if opened_column_name else None + since = _now(now) - timedelta(days=EXCHANGE_COOLDOWN_DAYS) + + valid_base = [ + Account.status == "active", + Account.ck_check_status == "valid", + Account.cookie.isnot(None), + Account.cookie != "", + Account.auto_managed.is_(True), + pool_membership_clause(), + ] + not_redeemed = not_(latest_exchange_exists_clause(commodity_id, since)) + + def count(*extra) -> int: + return int( + db.execute( + select(func.count(Account.id)).where(and_(*valid_base, *extra)) + ).scalar() + or 0 + ) + + opened_ok = ( + [opened_column.is_(True), points_column.isnot(None)] + if opened_column is not None + else [points_column.isnot(None)] + ) + eligible = count(*opened_ok, not_redeemed, not_(active_lease_exists_clause())) + zero_topup = count( + *opened_ok, + not_redeemed, + not_(active_lease_exists_clause()), + points_column >= score, + ) + # 已兑过(含仍在冷却期)的账号里,只看「手册已开通」的那部分 —— + # 未开通的账号无论如何都用不了,混进这个数会让人误以为是"冷却挡住了"。 + redeemed = count(*opened_ok, latest_exchange_exists_clause(commodity_id, since)) + not_opened = ( + count(points_column.isnot(None), not_(opened_column.is_(True))) + if opened_column is not None + else 0 + ) + leased = count(*opened_ok, not_redeemed, active_lease_exists_clause()) + + gap_expr = func.greatest(0, score - func.coalesce(points_column, 0)) + total_gap = int( + db.execute( + select(func.coalesce(func.sum(gap_expr), 0)).where( + and_( + *valid_base, + *opened_ok, + not_redeemed, + not_(active_lease_exists_clause()), + ) + ) + ).scalar() + or 0 + ) + est_gifts = (total_gap + POINTS_PER_GIFT - 1) // POINTS_PER_GIFT + + return { + "scope": scope, + "commodity_id": commodity_id, + "score": int(score), + "zero_topup": zero_topup, + "after_topup": eligible, + "leased": leased, + "blocked": { + f"{scope}_not_opened": not_opened, + "redeemed_within_cooldown": redeemed, + }, + "cost": {"est_gifts": est_gifts, "est_yuan": est_gifts}, + "cooldown_days": EXCHANGE_COOLDOWN_DAYS, + } + + +def active_lease_for_account(db: Session, account_id: int) -> AccountLease | None: + return ( + db.query(AccountLease) + .filter( + AccountLease.account_id == account_id, + AccountLease.status == "active", + ) + .first() + ) + + +def leased_account_ids( + db: Session, *, holder_user_id: int, include_expired: bool = False +) -> list[int]: + """当前用户持有的有效租约对应的账号 ID。""" + query = db.query(AccountLease.account_id).filter( + AccountLease.holder_user_id == holder_user_id, + AccountLease.status == "active", + ) + if not include_expired: + query = query.filter(AccountLease.expires_at > _now()) + return [int(row[0]) for row in query.all()] + + +def lease_accounts( + db: Session, + *, + holder_user_id: int, + candidates: Sequence[Candidate], + scope: str = SCOPE_ELITE, + commodity_id: str = "", + batch_id: str = "", + quantity: int | None = None, + ttl_minutes: int = DEFAULT_LEASE_TTL_MINUTES, + now: datetime | None = None, +) -> list[AccountLease]: + """按顺序领取租约,返回**真正领到**的那些。 + + 并发抢号由 `uq_lease_active_account` 裁决:每个候选用一个 SAVEPOINT 包住, + 撞唯一键就跳过它继续下一个 —— 已领到的不受影响(`rollback()` 会连坐, + 所以必须用 `begin_nested()`)。 + + **本函数会 commit**:租约必须先落库,后续建任务才有依据。 + """ + moment = _now(now) + expires_at = moment + timedelta(minutes=max(1, int(ttl_minutes))) + leased: list[AccountLease] = [] + for candidate in candidates: + if quantity is not None and len(leased) >= int(quantity): + break + lease = AccountLease( + account_id=candidate.account_id, + holder_user_id=holder_user_id, + scope=scope, + purpose="exchange", + commodity_id=commodity_id, + batch_id=batch_id, + status="active", + leased_at=moment, + expires_at=expires_at, + release_reason="", + created_at=moment, + ) + try: + with db.begin_nested(): + db.add(lease) + db.flush() + except IntegrityError: + # 撞唯一键 = 别人刚抢走这个账号。这是**预期内**的并发结果,不是故障。 + # SAVEPOINT 已经把这一条回滚掉了,直接换下一个候选。 + if lease in db: + db.expunge(lease) + continue + leased.append(lease) + db.commit() + return leased + + +def bind_leases_to_batch( + db: Session, leases: Sequence[AccountLease], batch_id: str +) -> None: + """把租约与任务批次关联起来(任务创建后才拿得到 batch_id)。""" + if not leases or not batch_id: + return + for lease in leases: + lease.batch_id = batch_id + db.commit() + + +def release_lease( + db: Session, + lease: AccountLease, + *, + reason: str, + now: datetime | None = None, +) -> bool: + """释放单个租约;已经是终态则返回 False(幂等)。""" + if lease.status != "active": + return False + lease.status = "released" + lease.released_at = _now(now) + lease.release_reason = str(reason or "")[:64] + db.commit() + return True + + +def release_for_task( + db: Session, + task: DouyuTask, + *, + reason: str, + now: datetime | None = None, +) -> bool: + """任务终态时释放它对应账号的租约(文档 §3.9 的即时释放)。 + + 按 `(batch_id, account_id)` 定位:一个批次里每个账号各有一条租约, + 不能因为某一条任务结束就把整批的租约都释放掉。 + """ + if not task.batch_id: + return False + lease = ( + db.query(AccountLease) + .filter( + AccountLease.batch_id == task.batch_id, + AccountLease.account_id == task.account_id, + AccountLease.status == "active", + ) + .first() + ) + if lease is None: + return False + return release_lease(db, lease, reason=reason, now=now) + + +def release_many( + db: Session, + leases: Sequence[AccountLease], + *, + reason: str, + now: datetime | None = None, + status: str = "released", +) -> int: + """批量释放(补位失败、客服放弃、超管强制释放)。 + + `status` 只允许 `released`(人/流程主动放)与 `expired`(TTL 或孤儿批次兜底)—— + 两者都会让生成列变 NULL、从而释放唯一索引,区别只在于事后怎么看这件事。 + """ + moment = _now(now) + count = 0 + for lease in leases: + if lease.status != "active": + continue + lease.status = status + lease.released_at = moment + lease.release_reason = str(reason or "")[:64] + count += 1 + if count: + db.commit() + return count + + +def release_batch( + db: Session, batch_id: str, *, reason: str, now: datetime | None = None +) -> int: + """释放一个任务批次下的全部有效租约。""" + leases = ( + db.query(AccountLease) + .filter(AccountLease.batch_id == batch_id, AccountLease.status == "active") + .all() + ) + return release_many(db, leases, reason=reason, now=now) + + +def reap_expired( + db: Session, *, now: datetime | None = None, batch_size: int = 500 +) -> int: + """把过期租约置为 expired(兜底作业调用)。 + + 只动**已过期**的:正在跑的任务即使超了 TTL 也先留着, + 避免把还在执行中的账号放给第二个人 —— TTL 到期的第一批由客服/运营处理, + 真正兜底的是「批次已不在内存」那条路径(见 reaper 作业)。 + """ + moment = _now(now) + leases = ( + db.query(AccountLease) + .filter(AccountLease.status == "active", AccountLease.expires_at <= moment) + .order_by(AccountLease.id.asc()) + .limit(max(1, int(batch_size))) + .all() + ) + return release_many(db, leases, reason="ttl_expired", now=moment, status="expired") + + +def orphan_leases(db: Session, *, active_batch_ids: set[str], limit: int = 500): + """批次已不在内存注册表(进程重启)的有效租约 —— 交给 reaper 释放。""" + query = db.query(AccountLease).filter(AccountLease.status == "active") + if active_batch_ids: + query = query.filter(AccountLease.batch_id.notin_(list(active_batch_ids))) + return query.order_by(AccountLease.id.asc()).limit(max(1, int(limit))).all() + + +#: 任务还没跑完的状态(与 `douyu_service.DOUYU_ACTIVE_TASK_STATUSES` 同义)。 +#: 这里写一份而不是 import,是为了不让纯函数模块反向依赖整条 runner 依赖链。 +_ACTIVE_TASK_STATUSES = ("planned", "pending", "running") + + +def reap_missed_releases(db: Session, *, limit: int = 500) -> int: + """回收「任务已经终态、租约却还挂着」的租约。 + + `_mark_task` 的即时释放是主路径,但进程被杀、任务行被保留策略删掉、 + 释放时抛异常,都会留下悬空租约。判据是**该批次已经没有未完成任务**: + + - 批次里还有 `planned/pending/running` → 说明还在跑,不动它; + - `batch_id` 为空 → 租约刚建、任务还没建出来,不动(由 TTL 兜底)。 + + 只动「没有在跑的任务」的租约,所以绝不会把正在执行的账号交给第二个人。 + """ + active_batches = select(DouyuTask.batch_id).where( + DouyuTask.status.in_(_ACTIVE_TASK_STATUSES) + ) + leases = ( + db.query(AccountLease) + .filter( + AccountLease.status == "active", + AccountLease.batch_id != "", + AccountLease.batch_id.notin_(active_batches), + ) + .order_by(AccountLease.id.asc()) + .limit(max(1, int(limit))) + .all() + ) + return release_many(db, leases, reason="task_finished_missed") + + +def recharge_amounts_for(gold_needed: int) -> list[int]: + """把一笔充值需求拆成每笔不超过 800 元的订单(文档 §3.4.2)。 + + 返回**空列表**表示不需要充值。单账号一件 1280 分的套装只需要 128 元, + 正常不会拆单;一次补很多分才会走到这里。 + + >>> recharge_amounts_for(0) + [] + >>> recharge_amounts_for(98) + [98] + >>> recharge_amounts_for(1700) + [800, 800, 100] + """ + remaining = max(0, int(gold_needed)) + orders: list[int] = [] + while remaining > 0: + chunk = min(remaining, MAX_RECHARGE_AMOUNT) + orders.append(chunk) + remaining -= chunk + return orders + + +@dataclass(frozen=True) +class SupplyStep: + """补给链里的一步:任务类型 + 该任务自己的载荷。""" + + task_type: str + payload: dict + + +def build_supply_chain( + candidate: Candidate, + *, + commodity_id: str, + exchange_task_type: str = "exchange_goods", +) -> list[SupplyStep]: + """给一个 B 梯队账号排出「充值 → 赠令 → 兑换」的执行链(纯函数)。 + + 设计要点(都是被现实逼出来的): + + 1. **先查余额再决定要不要花钱**。链首固定放 `query_gold_balance` /`query_points`: + 充值步骤带 `_skip_when` 闸门,依据的是**执行那一刻**账号上的鱼翅/积分; + 不先刷新一次,闸门读的是旧值 —— 旧值偏低就会**多充真金白银**。 + 2. **缺多少充多少**,超过 800 元自动拆单(服务端另有硬校验)。 + 3. **赠令带 `points >= score` 闸门**:积分已经够了就别再送礼(送出去是钱)。 + 4. A 梯队(积分已够)**不生成补给步骤**,链上只有兑换一步。 + + 执行顺序由「同一批次 + 并发 1」保证(`DouyuBatchRunner` 按任务 id 顺序取行)。 + """ + if not candidate.needs_topup: + return [ + SupplyStep( + exchange_task_type, + {"commodity_id": commodity_id, "num": 1}, + ) + ] + + steps: list[SupplyStep] = [ + SupplyStep("query_gold_balance", {}), + SupplyStep("query_points", {}), + ] + # 充值:余额够就跳过(闸门在执行时求值,读的是刚刷新过的账号字段) + for amount in recharge_amounts_for(candidate.topup_gifts): + steps.append( + SupplyStep( + "create_gold_qr", + { + "amount": amount, + "_skip_when": { + "column": "gold_balance", + "op": ">=", + "value": candidate.topup_gifts, + }, + }, + ) + ) + steps.append( + SupplyStep( + "donate_elite_gift", + { + "gift_count": candidate.topup_gifts, + "_skip_when": { + "column": "points", + "op": ">=", + "value": candidate.score, + }, + }, + ) + ) + steps.append( + SupplyStep(exchange_task_type, {"commodity_id": commodity_id, "num": 1}) + ) + return steps + + +def validate_recharge_amount(amount: int | None) -> int: + """校验单次充值金额(元)。 + + 只做**上限**校验,不做下限 —— 下限由调用方按业务给默认值。 + """ + value = int(amount or 0) + if value <= 0: + raise ValueError("充值金额必须大于 0") + if value > MAX_RECHARGE_AMOUNT: + raise ValueError( + f"单次充值不得超过 {MAX_RECHARGE_AMOUNT} 元,本次请求 {value} 元" + ) + return value + + +def lease_status_summary(db: Session, *, holder_user_id: int) -> dict[str, int]: + """给池工作台用的租约概览。""" + moment = _now() + base = db.query(AccountLease).filter( + AccountLease.holder_user_id == holder_user_id, + AccountLease.status == "active", + ) + return { + "active": base.count(), + "expiring_soon": base.filter( + AccountLease.expires_at <= moment + timedelta(minutes=10) + ).count(), + } + + +__all__ = [ + "DEFAULT_LEASE_TTL_MINUTES", + "EXCHANGE_COOLDOWN_DAYS", + "MAX_RECHARGE_AMOUNT", + "POINTS_PER_GIFT", + "SUPPORTED_POOL_SCOPES", + "Candidate", + "SupplyStep", + "active_lease_for_account", + "bind_leases_to_batch", + "build_supply_chain", + "eligible_accounts", + "estimate_supply", + "lease_accounts", + "lease_status_summary", + "leased_account_ids", + "make_candidate", + "orphan_leases", + "rank_candidates", + "reap_expired", + "reap_missed_releases", + "recharge_amounts_for", + "release_batch", + "release_for_task", + "release_lease", + "release_many", + "topup_gifts_for", + "validate_recharge_amount", +] diff --git a/web/backend/services/automation/__init__.py b/web/backend/services/automation/__init__.py index c384411..26b6c34 100644 --- a/web/backend/services/automation/__init__.py +++ b/web/backend/services/automation/__init__.py @@ -6,6 +6,12 @@ from __future__ import annotations +from .jobs.account_lease_reaper import ( + JOB_KEY as ACCOUNT_LEASE_REAPER_KEY, +) +from .jobs.account_lease_reaper import ( + run_account_lease_reaper, +) from .jobs.account_overview_refresh import ( JOB_KEY as ACCOUNT_OVERVIEW_REFRESH_KEY, ) @@ -39,6 +45,7 @@ from .scheduler import ( ) __all__ = [ + "ACCOUNT_LEASE_REAPER_KEY", "ACCOUNT_OVERVIEW_REFRESH_KEY", "CK_HEALTH_KEY", "CK_RELOGIN_KEY", @@ -51,6 +58,7 @@ __all__ = [ "register_job", "registered_jobs", "reset_registry", + "run_account_lease_reaper", "run_account_overview_refresh", "run_ck_health", "run_ck_relogin", @@ -140,3 +148,20 @@ def register_default_jobs() -> None: run=run_ck_health, ) ) + register_job( + JobSpec( + key=ACCOUNT_LEASE_REAPER_KEY, + title="账号租约回收", + # 租约的主释放路径是任务终态钩子;本作业只是**保险丝**, + # 兜住「进程重启 / 释放抛异常 / 任务行被清」三种漏网情况。 + # 跑得勤一点(1 分钟)成本极低:只扫带索引的 status+expires_at, + # 而且悬空租约会让账号白白锁着 —— 早收早能用。 + # `interval_seconds` 对它是无意义的(本作业自己按三类规则选行), + # 但 JobSpec 要求必填,给一个与 run_interval 同量级的值。 + interval_seconds=60, + run_interval_seconds=60, + batch_size=500, + concurrency=1, + run=run_account_lease_reaper, + ) + ) diff --git a/web/backend/services/automation/jobs/account_lease_reaper.py b/web/backend/services/automation/jobs/account_lease_reaper.py new file mode 100644 index 0000000..9b24f7b --- /dev/null +++ b/web/backend/services/automation/jobs/account_lease_reaper.py @@ -0,0 +1,95 @@ +"""作业:兜底回收账号租约(账号池的「自动收回」保险丝)。 + +## 为什么必须有它 + +租约的主释放路径是 `_mark_task` 写终态时的即时钩子(`release_for_task`), +覆盖 99% 的情况。但下面三种情况会留下**悬空租约**,而这个账号会一直显示 +"被占用"、谁也领不走: + +1. **进程被杀 / 重启**:执行器线程没了,任务永远停在 `pending/running`; +2. **释放时抛异常**:`_mark_task` 里那一步是旁路,失败只记 warning; +3. **任务行被保留策略删掉**:批次看着"没有未完成任务",但钩子再也不会跑。 + +本作业按三条规则收: + +| 规则 | 判据 | 置为 | +| --- | --- | --- | +| TTL 到期 | `expires_at <= now` | `expired` | +| 漏释放 | 批次**已无**未完成任务(`planned/pending/running`) | `released` | +| 孤儿批次 | 批次不在**本进程**的执行器注册表里 | `expired` | + +## ⚠️ 部署前提 + +「孤儿批次」这条规则依赖**单进程部署**:注册表 `douyu_batch_registry` 在内存里, +只有跑这个批次的那个进程知道它。生产是 `uvicorn --workers 1`(见 +`docker-entrypoint.sh`),所以成立。 + +**如果将来改成多 worker,这条规则会误杀别的 worker 正在跑的批次** —— +到那时必须换成落库的批次心跳(或在 `douyu_tasks` 上记执行者标识), +不能只靠内存注册表。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from loguru import logger +from sqlalchemy.orm import Session + +from ....models import AutomationJob +from ...account_pool import ( + orphan_leases, + reap_expired, + reap_missed_releases, + release_many, +) + +JOB_KEY = "account_lease_reaper" + +#: 一轮最多处理多少条租约(三类各用这个上限)。 +DEFAULT_BATCH = 500 + + +def _positive_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + +def run_account_lease_reaper( + db: Session, job: AutomationJob, *, now: datetime | None = None +) -> dict[str, Any]: + batch = _positive_int(job.batch_size, DEFAULT_BATCH) + moment = now or datetime.now(UTC) + + # 延迟导入:作业模块在应用启动时被加载,顶层导入 runner 会把 + # 「路由 → runner → account_pool」整条链在启动期拉起来,容易出循环导入。 + from ....services.douyu_runner import douyu_batch_registry + + # ① TTL 到期 + expired = reap_expired(db, now=moment, batch_size=batch) + # ② 任务已终态但租约没释放 + missed = reap_missed_releases(db, limit=batch) + # ③ 批次已不在本进程注册表(进程重启) + active_batch_ids = douyu_batch_registry.active_ids() + orphans = orphan_leases(db, active_batch_ids=active_batch_ids, limit=batch) + orphaned = release_many(db, orphans, reason="orphan_batch", status="expired") + + total = expired + missed + orphaned + message = ( + f"租约回收 {total} 条" + f"(TTL 到期 {expired} / 漏释放 {missed} / 孤儿批次 {orphaned})" + ) + if total: + logger.info(message) + return { + "scanned": total, + "expired": expired, + "missed_release": missed, + "orphan_batch": orphaned, + "active_batches": len(active_batch_ids), + "message": message, + } diff --git a/web/backend/services/business_events.py b/web/backend/services/business_events.py index 5ab6b18..66df9b8 100644 --- a/web/backend/services/business_events.py +++ b/web/backend/services/business_events.py @@ -64,6 +64,23 @@ def task_ref(task_id: int, kind: str = "") -> str: return f"{base}:{kind}" if kind else base +def task_actor(task: Any) -> dict[str, Any]: + """业务事件的「谁做的」—— 发起这条任务的用户。 + + 为什么用 `task.created_by`:任务由客服在界面上发起(池路径下也就是**租用该账号 + 的客服**),所以它就是操作人。以前所有业务事件(充值/兑换/开通/积分)都不带这个 + 字段,生产 34,462 条事件里有 24,502 条(71%)查不出责任人 —— 池化之后 + 「客服租用期间做了什么」是必须能回答的,所以补齐。 + + 调用处一律用 `**task_actor(task)`,默认值仍是「无操作人」, + 因此老调用方一行不改也不会变。 + """ + created_by = getattr(task, "created_by", None) + if not created_by: + return {"actor_type": "system", "actor_id": None} + return {"actor_type": "user", "actor_id": int(created_by)} + + def record_recharge( db: Session, account: Account, @@ -77,6 +94,8 @@ def record_recharge( douyu_uid: str = "", balance_after: int | None = None, confirmed: bool = True, + actor_type: str = "", + actor_id: int | None = None, ) -> bool: """充值鱼翅到账。 @@ -110,6 +129,8 @@ def record_recharge( subject_type="recharge", subject_key=order_id or supplier_order_id or "", source_ref=task_ref(task_id), + actor_type=actor_type, + actor_id=actor_id, ) @@ -128,6 +149,8 @@ def record_exchange( game_channel: str = "", points_after: int | None = None, scope: str = "elite", + actor_type: str = "", + actor_id: int | None = None, ) -> bool: """商品/皮肤兑换成功 —— **只在真的消耗了积分并兑换成功时调用**。 @@ -160,6 +183,8 @@ def record_exchange( subject_type="commodity", subject_key=commodity_id or name or "", source_ref=task_ref(task_id), + actor_type=actor_type, + actor_id=actor_id, ) @@ -174,6 +199,8 @@ def record_gold_spent( source_label: str = "", balance_after: int | None = None, confirmed: bool = True, + actor_type: str = "", + actor_id: int | None = None, ) -> bool: """鱼翅消耗(送礼等)。 @@ -203,6 +230,8 @@ def record_gold_spent( subject_type="gold", # 副事件:同一个任务还会发「积分到账」,所以带后缀避免撞幂等键 source_ref=task_ref(task_id, "gold"), + actor_type=actor_type, + actor_id=actor_id, ) @@ -214,6 +243,8 @@ def record_points_expire( occurred_at: datetime | None = None, expires_at: Any = None, confirmed: bool = False, + actor_type: str = "", + actor_id: int | None = None, ) -> bool: """积分到期(资产减少)。 @@ -236,6 +267,8 @@ def record_points_expire( detail={"confirmed": confirmed, "expires_at": expires_at}, source="points_expire", subject_type="points", + actor_type=actor_type, + actor_id=actor_id, ) @@ -250,6 +283,8 @@ def record_points_credit( expire_at: Any = None, confirmed: bool = True, source_label: str = "精英令", + actor_type: str = "", + actor_id: int | None = None, ) -> bool: """积分到账(送礼/任务发放)。 @@ -281,6 +316,8 @@ def record_points_credit( source="douyu_task", subject_type="points", source_ref=task_ref(task_id), + actor_type=actor_type, + actor_id=actor_id, ) @@ -295,6 +332,8 @@ def record_bind( act_alias: str = "", rebind: bool = False, weak: bool = False, + actor_type: str = "", + actor_id: int | None = None, ) -> bool: """角色绑定 / 换绑**真的发生了**。 @@ -332,6 +371,8 @@ def record_bind( subject_type=f"{scope}_role", subject_key=role_name or "", source_ref=task_ref(task_id), + actor_type=actor_type, + actor_id=actor_id, ) @@ -346,6 +387,8 @@ def record_official_exchange( show_name: str = "", exchange_num: int | None = None, scope: str = "elite", + actor_type: str = "", + actor_id: int | None = None, ) -> bool: """把斗鱼官方兑换记录写成账号事件。 @@ -386,10 +429,21 @@ def record_official_exchange( "scope": scope, }, source=OFFICIAL_SYNC_SOURCE, - subject_type="goods", + # 台账检索键必须是**斗鱼商品 ID**:365 天规则与 `ix_account_events_ledger` + # 都按 `subject_key` 判「该账号该商品最近一次兑换」。 + # + # 2026-10-07 修:这里以前漏传 `subject_key`(只设了 subject_type="goods"), + # 于是 10,612 条官方补录事件的主键全是空串 —— 用 subject_key 查历史会漏掉 + # 87% 的记录,365 天规则形同虚设。`subject_type` 同时统一为 commodity, + # 与 `record_exchange`(我方任务直发)保持一致。 + # 幂等靠下面的 `source_ref`,与 subject_key 无关,改这里不会造成重复。 + subject_type="commodity", + subject_key=commodity_id, source_ref=( f"official_exchange:{scope}:{account.id}:{commodity_id}:{_stamp(occurred_at)}" ), + actor_type=actor_type, + actor_id=actor_id, ) diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 57ba3a7..3f16fe1 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -59,6 +59,18 @@ class DouyuBatchRunner( self._push_log( "info", f"[{current}/{total}] 开始: {self._account_name(account)}" ) + # 补给链的两个运行期闸门(见 `account_pool.build_supply_chain`): + # `_depends_on` 前置没成功就别跑,`_skip_when` 条件已满足就别做(省的是钱)。 + # 放在 CK 检测**之前** —— 被跳过的任务不该因为 CK 失效再报一次失败。 + skip_reason = self._dependency_reason(worker_db, task) or self._skip_reason( + account, task + ) + if skip_reason: + self._mark_task(worker_db, task, "skipped", skip_reason) + self._push_log( + "info", f"[{current}] {self._account_name(account)} {skip_reason}" + ) + return # CK 与健康状态都挂在账号上(2026-10-04 CK 解耦)。 cookie = account.cookie or "" if not cookie: diff --git a/web/backend/services/douyu_runner_bind.py b/web/backend/services/douyu_runner_bind.py index 74479f1..f2ea18b 100644 --- a/web/backend/services/douyu_runner_bind.py +++ b/web/backend/services/douyu_runner_bind.py @@ -19,7 +19,7 @@ from .account_binding import ( SCOPE_ESPORTS, set_bind_status, ) -from .business_events import record_bind +from .business_events import record_bind, task_actor DOUYU_LEGACY_BIND_ACT_ALIAS = "20250213NQCYX" DOUYU_BIND_ROLE_POLL_SECONDS = 65 @@ -932,6 +932,7 @@ class BindMixin: db, account, task_id=task.id, + **task_actor(task), scope="elite", role_name=role_name, act_alias=use_confirm_alias, @@ -968,6 +969,7 @@ class BindMixin: db, account, task_id=task.id, + **task_actor(task), scope="elite", role_name=final_role_name, act_alias=use_confirm_alias, @@ -1125,6 +1127,7 @@ class BindMixin: db, account, task_id=task.id, + **task_actor(task), scope="esports", role_name=role_text, act_alias=act_alias, diff --git a/web/backend/services/douyu_runner_core.py b/web/backend/services/douyu_runner_core.py index e4777a7..50b2739 100644 --- a/web/backend/services/douyu_runner_core.py +++ b/web/backend/services/douyu_runner_core.py @@ -3,10 +3,11 @@ from __future__ import annotations import asyncio +import operator import threading import time from datetime import UTC, datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, ClassVar from loguru import logger from sqlalchemy.orm import Session @@ -24,6 +25,7 @@ from ..models import ( from ..models import ( ProxyConfig as ProxyConfigModel, ) +from .account_pool import release_for_task from .douyu_service import ( DOUYU_CONFIG_FIELDS, douyu_config_value, @@ -36,6 +38,9 @@ if TYPE_CHECKING: # 支付/到账轮询(手册与充值共用) DOUYU_PAYMENT_POLL_SECONDS = 600 + +#: 支付等待超时后的状态:**转人工**,不再自动轮询,租约也先不还。 +MANUAL_REQUIRED_STATUS = "manual_required" DOUYU_PAYMENT_POLL_INTERVAL = 5 # 走代理的任务类型:写操作/消耗类(兑换、锁单、支付、开通、充值、送礼)。 @@ -219,8 +224,98 @@ class DouyuBatchRunnerCore: task.result = result task.finished_at = datetime.now(UTC) db.commit() + # 账号池:任务走到终态就**把租约还回去**(设计 §3.9 的即时释放)。 + # 这一步覆盖 99% 的情况;漏掉的(进程被杀、任务行被清)由 + # `account_lease_reaper` 兜底。释放失败绝不能影响任务结果本身。 + self._release_pool_lease(db, task) self._push_task_event(task) + def _release_pool_lease(self, db: Session, task: DouyuTask) -> None: + """释放该任务对应账号的租约(不是整批 —— 同批每个账号各有一条)。""" + try: + if not task.batch_id: + return + # 转人工的任务**先不还号**:客服还在等支付/处理,账号已经挂在这单上。 + # 同理,同一批次里只要还有 `manual_required` 的任务(补给链后半段会被 + # 前置闸门标成 skipped),整条链的号都先留着。 + # 账号不会被永久占住 —— 租约到期由 `account_lease_reaper` 兜底收回。 + if task.status == MANUAL_REQUIRED_STATUS: + return + waiting_manual = ( + db.query(DouyuTask.id) + .filter( + DouyuTask.batch_id == task.batch_id, + DouyuTask.status == MANUAL_REQUIRED_STATUS, + ) + .first() + ) + if waiting_manual: + return + release_for_task(db, task, reason=f"task_{task.status}") + except Exception as exc: # noqa: BLE001 旁路动作,绝不能带崩任务 + logger.warning(f"释放账号租约失败 task={task.id}: {exc}") + + # ── 补给链的两个运行期闸门(见 account_pool.build_supply_chain)────────── + + #: `_skip_when` 支持的账号字段与比较符。 + _SKIP_COLUMNS: ClassVar[tuple[str, ...]] = ( + "points", + "gold_balance", + "exchange_balance", + "esports_points", + ) + _SKIP_OPS: ClassVar[dict[str, Any]] = { + ">=": operator.ge, + ">": operator.gt, + "<=": operator.le, + "<": operator.lt, + "==": operator.eq, + } + + def _skip_reason(self, account: Account, task: DouyuTask) -> str: + """`_skip_when` 闸门:满足条件就跳过这一步。 + + 补给链用它实现「余额已经够了就别再充钱」。**只在能确定时跳过**: + 字段缺失、比较符不认识、值解析失败一律不跳(宁可多跑一步, + 也不要因为解析不出来就静默把钱省掉/花掉)。 + """ + rule = self._task_payload(task).get("_skip_when") + if not isinstance(rule, dict): + return "" + column = str(rule.get("column") or "") + if column not in self._SKIP_COLUMNS: + return "" + compare = self._SKIP_OPS.get(str(rule.get("op") or "")) + if compare is None: + return "" + current = getattr(account, column, None) + if current is None: + return "" + try: + target = int(rule.get("value")) + except (TypeError, ValueError): + return "" + if compare(int(current), target): + return f"跳过:{column}={int(current)} {rule.get('op')} {target} 已满足" + return "" + + def _dependency_reason(self, db: Session, task: DouyuTask) -> str: + """`_depends_on` 闸门:前置任务没成功就不必再跑(省得连着一串失败)。""" + raw = self._task_payload(task).get("_depends_on") + if not raw: + return "" + try: + depends_on = int(raw) + except (TypeError, ValueError): + return "" + previous = db.query(DouyuTask).filter(DouyuTask.id == depends_on).first() + if previous is None: + return "" + # 前置「成功」或「被跳过」都算通过:跳过意味着那一步本来就不需要做。 + if previous.status in ("success", "skipped"): + return "" + return f"前置任务 #{depends_on} 未成功({previous.status}),跳过" + def _update_task_progress( self, db: Session, diff --git a/web/backend/services/douyu_runner_donate.py b/web/backend/services/douyu_runner_donate.py index d11bfe5..4886f73 100644 --- a/web/backend/services/douyu_runner_donate.py +++ b/web/backend/services/douyu_runner_donate.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from core.douyu import DouyuActivityClient from ..models import Account, DouyuTask -from .business_events import record_gold_spent, record_points_credit +from .business_events import record_gold_spent, record_points_credit, task_actor DOUYU_GIFT_POINTS_REFRESH_TIMES = 3 DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2 @@ -197,6 +197,7 @@ class DonateMixin: db, account, task_id=task.id, + **task_actor(task), gain=_esports_gift_points(result, baseline_points), points_after=account.esports_points, confirmed=confirmed, @@ -206,6 +207,7 @@ class DonateMixin: db, account, task_id=task.id, + **task_actor(task), amount=_gold_spent(baseline_gold, account.gold_balance), reason=f"赠送{gift_name} x{gift_count}", source_label=f"赠送{gift_name}", @@ -332,6 +334,7 @@ class DonateMixin: db, account, task_id=task.id, + **task_actor(task), gain=max(0, gift_count) * 10, points_after=account.points, expire_at=expire_at, @@ -344,6 +347,7 @@ class DonateMixin: db, account, task_id=task.id, + **task_actor(task), amount=_gold_spent(baseline_gold, account.gold_balance), reason=f"赠送精英令 x{gift_count}", source_label="赠送精英令", diff --git a/web/backend/services/douyu_runner_gold.py b/web/backend/services/douyu_runner_gold.py index acdde11..d9d8839 100644 --- a/web/backend/services/douyu_runner_gold.py +++ b/web/backend/services/douyu_runner_gold.py @@ -18,8 +18,13 @@ from core.douyu import ( ) from ..models import Account, DouyuTask -from .business_events import record_recharge -from .douyu_runner_core import DOUYU_PAYMENT_POLL_INTERVAL, DOUYU_PAYMENT_POLL_SECONDS +from .account_pool import validate_recharge_amount +from .business_events import record_recharge, task_actor +from .douyu_runner_core import ( + DOUYU_PAYMENT_POLL_INTERVAL, + DOUYU_PAYMENT_POLL_SECONDS, + MANUAL_REQUIRED_STATUS, +) from .douyu_service import update_account_profile_from_cookie @@ -247,7 +252,15 @@ class GoldMixin: self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict ): payload = self._task_payload(task) - amount = int(payload.get("amount") or payload.get("gold_amount") or 1) + # 单次充值上限(元)。**必须服务端校验**:前端限制只是体验, + # 任务可以绕过界面直接建(`POST /api/douyu/tasks/batch` 的 payload 是自由字典)。 + try: + amount = validate_recharge_amount( + int(payload.get("amount") or payload.get("gold_amount") or 1) + ) + except ValueError as exc: + self._mark_task(db, task, "failed", str(exc)) + return channel = str(config.get("gold_recharge_channel") or "wechat_qr") if channel == "supplier_api": try: @@ -289,6 +302,7 @@ class GoldMixin: db, account, task_id=task.id, + **task_actor(task), amount=amount, channel=channel, balance_after=account.gold_balance, @@ -305,8 +319,9 @@ class GoldMixin: self._mark_task( db, task, - "failed", - f"未检测到鱼翅到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}", + MANUAL_REQUIRED_STATUS, + f"等待 {DOUYU_PAYMENT_POLL_SECONDS // 60} 分钟未检测到鱼翅到账,已转人工处理" + f"(当前余额: {account.gold_balance if account.gold_balance is not None else '-'})", result, ) @@ -446,6 +461,7 @@ class GoldMixin: db, account, task_id=task.id, + **task_actor(task), amount=amount, order_id=str(result.get("out_order_id") or ""), supplier_order_id=str(result.get("order_id") or ""), diff --git a/web/backend/services/douyu_runner_goods.py b/web/backend/services/douyu_runner_goods.py index 427908e..70e5260 100644 --- a/web/backend/services/douyu_runner_goods.py +++ b/web/backend/services/douyu_runner_goods.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from core.douyu import DouyuActivityClient, DouyuActivityError from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask -from .business_events import record_exchange +from .business_events import record_exchange, task_actor # 兑换节奏与重试(对齐 8.30 浏览器抓包:锁单->支付间隔约 2.2~4.2s;火爆类错误要长退避而不是秒级连打) DOUYU_EXCHANGE_PRE_CREATE_JITTER = (0.3, 1.2) @@ -214,6 +214,7 @@ class GoodsMixin: db, account, task_id=task.id, + **task_actor(task), name=(goods.name if goods else "") or commodity_id or order_id, score=score, commodity_id=commodity_id, @@ -520,6 +521,7 @@ class GoodsMixin: db, account, task_id=task.id, + **task_actor(task), name=name, score=locked.get("score"), commodity_id=commodity_id, @@ -635,6 +637,7 @@ class GoodsMixin: db, account, task_id=task.id, + **task_actor(task), name=name, score=spent if spent and spent > 0 else None, commodity_id=commodity_id, diff --git a/web/backend/services/douyu_runner_overview.py b/web/backend/services/douyu_runner_overview.py index 3ced522..5a30f4c 100644 --- a/web/backend/services/douyu_runner_overview.py +++ b/web/backend/services/douyu_runner_overview.py @@ -34,7 +34,7 @@ from core.douyu import DouyuActivityClient from ..models import Account, DouyuTask from .account_binding import derive_from_bind_info, set_bind_status -from .business_events import record_official_exchange +from .business_events import record_official_exchange, task_actor from .douyu_runner_donate import _parse_expire_at, _points_expire_at #: 默认关注的高价值商品。按**商品名包含**匹配 —— 官方列表里的名字带前缀 @@ -151,12 +151,18 @@ def _group_by_scope( def _record_official_exchanges( - db: Session, account: Account, pairs: list[tuple[str, dict[str, Any]]] + db: Session, + account: Account, + pairs: list[tuple[str, dict[str, Any]]], + task: Any = None, ) -> int: """把官方兑换记录补成账号事件,返回**新写入**的条数。 幂等由 `record_official_exchange` 的 `source_ref` 保证(含作用域), 所以一键查询反复执行不会重复落事件。 + + `task` 只用来取**操作人**(`task.created_by`)—— 谁点的这次一键查询, + 这条兑换历史就记在谁名下。不传则退化为「无操作人」,与老行为一致。 """ added = 0 for scope, rec in pairs: @@ -181,6 +187,7 @@ def _record_official_exchanges( show_name=str(rec.get("showName") or rec.get("show_name") or ""), exchange_num=rec.get("exchangeNum") or rec.get("count"), scope=scope, + **task_actor(task), ): added += 1 return added @@ -511,7 +518,7 @@ class OverviewMixin: overview["exchange_record_count"] = len(all_records) # 把官方记录**补成账号事件**(带真实兑换时间、按作用域去重)。 overview["exchange_events_added"] = _record_official_exchanges( - db, account, all_records + db, account, all_records, task ) overview["exchange_records"] = { scope: rows[:EXCHANGE_RECORD_LIMIT] diff --git a/web/backend/services/douyu_runner_xpd.py b/web/backend/services/douyu_runner_xpd.py index 1bba1d9..e717f37 100644 --- a/web/backend/services/douyu_runner_xpd.py +++ b/web/backend/services/douyu_runner_xpd.py @@ -18,7 +18,7 @@ from .account_binding import ( SCOPE_PEACE, set_bind_status, ) -from .business_events import record_exchange +from .business_events import record_exchange, task_actor DOUYU_XPD_BIND_POLL_SECONDS = 300 DOUYU_XPD_BIND_POLL_INTERVAL = 5 @@ -591,6 +591,7 @@ class XpdMixin: db, account, task_id=task.id, + **task_actor(task), name=goods.name or commodity_id, score=price, commodity_id=commodity_id, diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 68afac0..a7e454a 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -265,6 +265,58 @@ def account_uid(account: Account, cookie: str) -> str: return account.uid or cookie_value(cookie, "acf_uid") +def create_douyu_task( + db: Session, + account: Account, + task_type: str, + handbook_scope: str, + created_by: int, + payload: dict | None = None, + *, + batch_id: str | None = None, + account_assigned_to: int | None = None, + commit: bool = True, +) -> DouyuTask: + """建**一条**斗鱼任务(单账号、单类型、载荷可各账号不同)。 + + 存在的理由:`create_douyu_planned_tasks` 给一批账号**共用一个** `payload`, + 而账号池的补给链每个账号的充值额/赠令数都不一样,共用载荷根本表达不了。 + + `batch_id` 传值即并入该批次 —— 执行器按批取任务、按任务自己的类型分发, + 所以**一个批次里可以放不同类型的任务**(这正是补给链的实现方式)。 + 顺序由「同一批次 + 并发 1」保证(见 `routers/douyu.py::start_douyu_batch`)。 + """ + if task_type not in SUPPORTED_DOUYU_TASK_TYPES: + raise ValueError("不支持的任务类型") + if handbook_scope not in DOUYU_HANDBOOK_SCOPES: + raise ValueError("无效的工作台") + if task_type not in DOUYU_HANDBOOK_TASK_TYPES[handbook_scope]: + raise ValueError("该任务不属于当前工作台") + + task = DouyuTask( + batch_id=batch_id or uuid.uuid4().hex[:12], + account_id=account.id, + account_assigned_to=( + account_assigned_to + if account_assigned_to is not None + else account.assigned_to + ), + task_type=task_type, + handbook_scope=handbook_scope, + status="planned", + message="任务已创建,等待执行", + result={"payload": payload} if payload else None, + created_by=created_by, + ) + db.add(task) + if commit: + db.commit() + db.refresh(task) + else: + db.flush() + return task + + def create_douyu_planned_tasks( db: Session, account_ids: list[int], @@ -272,8 +324,20 @@ def create_douyu_planned_tasks( handbook_scope: str, created_by: int, payload: dict | None = None, + *, + account_assigned_to_override: int | None = None, ) -> tuple[str, int]: - """创建斗鱼任务记录,等待后台执行器消费。""" + """创建斗鱼任务记录,等待后台执行器消费。 + + `account_assigned_to_override` **只给账号池路径用**:池化后账号归属是 + 管理员/运营,若仍按归属写,租用该账号的客服在任务列表里**看不到自己提交的任务** + —— 列表对客服的过滤正是 `DouyuTask.account_assigned_to == 我` + (见 `routers/douyu.py` 的 `_visible_tasks_query`)。 + + 这一列本来就是「账号归属客服冗余字段,配合复合索引消除任务列表全表 filesort」, + 实际作用是「谁能在列表里看到这条任务」。池路径写**租用人**既保住索引命中, + 又让客服看得到自己的任务。**默认不传 = 保持旧行为**,旧路径完全不受影响。 + """ if task_type not in SUPPORTED_DOUYU_TASK_TYPES: raise ValueError("不支持的任务类型") if handbook_scope not in DOUYU_HANDBOOK_SCOPES: @@ -296,7 +360,11 @@ def create_douyu_planned_tasks( DouyuTask( batch_id=batch_id, account_id=account.id, - account_assigned_to=account.assigned_to, + account_assigned_to=( + account_assigned_to_override + if account_assigned_to_override is not None + else account.assigned_to + ), task_type=task_type, handbook_scope=handbook_scope, status="planned", diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 38e9686..ca5e989 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -21,6 +21,7 @@ const UsersPage = lazy(() => import('./pages/UsersPage')); const CookiePage = lazy(() => import('./pages/CookiePage')); const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage')); const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage')); +const DouyuPoolPage = lazy(() => import('./pages/DouyuPoolPage')); const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage')); const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage')); const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage')); @@ -90,6 +91,7 @@ function AppContent() { )} /> )} /> } /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/douyuPool.ts b/web/frontend/src/api/douyuPool.ts new file mode 100644 index 0000000..ea58ed7 --- /dev/null +++ b/web/frontend/src/api/douyuPool.ts @@ -0,0 +1,43 @@ +import api from './client'; +import type { + DouyuPoolExchangeRequest, + DouyuPoolExchangeResult, + DouyuPoolLease, + DouyuPoolSupply, + MessageResponse, +} from './types'; + +/** + * 账号池(自动分配与回收)。 + * + * 单独一个模块而不是塞进 `douyuApi`:这是**新增的一条路**, + * 旧工作台(精英宝典/电竞手册)的接口一个都没动。 + */ +export const douyuPoolApi = { + /** 不锁号地预览「能分出几个、要花多少钱」 */ + supply: (scope: 'elite' | 'esports', commodityId: string) => + api.get('/douyu/pool/supply', { + params: { scope, commodity_id: commodityId }, + }), + /** 提交需求:分配账号 + 建任务 + 启动批次 */ + exchange: (data: DouyuPoolExchangeRequest) => + api.post('/douyu/pool/exchange', data), + leases: (mine = true) => + api.get('/douyu/pool/leases', { params: { mine } }), + /** 某个账号的租赁记录(账号详情页用;`includeFinished` 带历史) */ + leasesOfAccount: (accountId: number, includeFinished = true) => + api.get('/douyu/pool/leases', { + params: { account_id: accountId, include_finished: includeFinished }, + }), + releaseLease: (leaseId: number) => + api.post<{ success: boolean; released: boolean }, { success: boolean; released: boolean }>( + `/douyu/pool/leases/${leaseId}/release`, + ), + releaseBatch: (batchId: string) => + api.post<{ success: boolean; released: number }, { success: boolean; released: number }>( + `/douyu/pool/leases/release-batch/${batchId}`, + ), + /** 复用旧接口拿商品列表(皮肤下拉的数据源) */ + goods: () => api.get<{ commodity_id: string; name: string; score: number | null }[], { commodity_id: string; name: string; score: number | null }[]>('/douyu/goods'), + stopBatch: (batchId: string) => api.post(`/douyu/stop/${batchId}`), +}; diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index 4fce821..2590540 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -8,6 +8,7 @@ export { automationApi } from './automation'; export { cookieApi } from './cookies'; export { dashboardApi } from './dashboard'; export { douyuApi } from './douyu'; +export { douyuPoolApi } from './douyuPool'; export { huyaApi } from './huya'; export { loginApi } from './login'; export { proxyApi } from './proxy'; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 2d2ef6d..041fa10 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -146,6 +146,8 @@ export interface AccountEventItem { source: string; actor_type?: string; actor_id?: number | null; + /** 操作人用户名(后端联表补上;池化后「谁做的」是这一屏的关键信息) */ + actor_name?: string; } export interface AccountEventPage { @@ -1163,3 +1165,73 @@ export interface AccountExchangeRecords { total: number; queried_at: string | null; } + +/** 账号池:供给预览(不锁号) */ +export interface DouyuPoolSupply { + scope: string; + commodity_id: string; + score: number; + /** 积分已够、可直接兑的账号数(0 充值) */ + zero_topup: number; + /** 充值积分后可兑的账号数 */ + after_topup: number; + /** 当前被别处租用中的数量 */ + leased: number; + blocked: Record; + cost: { est_gifts: number; est_yuan: number }; + cooldown_days: number; + commodity: { commodity_id: string; name: string; score: number }; + cooling: { role_change_in_cooldown: number; changeable_now: number }; + /** 当前鱼翅充值渠道(只读回显) */ + recharge_channel: string; + /** 是否需要在扫码渠道下人工支付 */ + manual_payment_required: boolean; +} + +export interface DouyuPoolAccount { + account_id: number; + username: string; + /** A = 积分已够;B = 需要充值 */ + tier: string; + points: number; + topup_gifts: number; + topup_yuan: number; + /** 充值订单金额列表(每笔 ≤ 800 元) */ + recharge_orders: number[]; + role_change_available_at: string | null; +} + +export interface DouyuPoolExchangeRequest { + scope: 'elite' | 'esports'; + commodity_id: string; + quantity: number; + allow_topup: boolean; + allow_fallback: boolean; +} + +export interface DouyuPoolExchangeResult { + batch_id: string; + requested: number; + allocated: number; + shortfall: number; + accounts: DouyuPoolAccount[]; + reasons: string[]; + recharge_channel: string; + manual_payment_required: boolean; +} + +export interface DouyuPoolLease { + id: number; + account_id: number; + username: string; + holder_user_id: number; + holder_name: string; + scope: string; + commodity_id: string; + batch_id: string; + status: string; + leased_at: string | null; + expires_at: string | null; + role_change_available_at: string | null; + points: number | null; +} diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index dc1aaab..5098dd2 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -8,6 +8,7 @@ import { SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined, BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined, SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined, + ThunderboltOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { getUser, clearAuth, type AuthUser } from '../store/auth'; @@ -84,6 +85,10 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: }); douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: }); } + // 账号分配台:新增的独立工作台(旧的三个工作台原样保留) + if (can('account:pool_use')) { + douyuItems.push({ key: '/douyu/pool', label: '账号分配台', icon: }); + } // 虎牙:客服只保留浏览器兑换;管理员/运营显示完整入口 if (can('huya:task')) { diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx index 8fc9dfa..b7d197f 100644 --- a/web/frontend/src/pages/AccountDetailPage.tsx +++ b/web/frontend/src/pages/AccountDetailPage.tsx @@ -7,7 +7,7 @@ import type { TableProps } from 'antd'; import { ArrowLeftOutlined, EditOutlined, ReloadOutlined, ThunderboltOutlined, } from '@ant-design/icons'; -import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules'; +import { accountApi, douyuApi, douyuPoolApi, userApi, type AccountEventItem, type AccountOverview, type DouyuPoolLease, type UserInfo } from '../api/modules'; import AccountOverviewPanel from './douyu-tasks/AccountOverviewPanel'; import { getErrorMessage } from '../utils/error'; import { message } from '../utils/antdMessage'; @@ -100,6 +100,7 @@ export default function AccountDetailPage() { const [eventType, setEventType] = useState(''); const [category, setCategory] = useState(''); const [eventsLoading, setEventsLoading] = useState(false); + const [leases, setLeases] = useState([]); const [tagDraft, setTagDraft] = useState(''); // 一键查询:向斗鱼实时查一次该账号的全部关键信息(异步任务 + 轮询结果) const [ovLoading, setOvLoading] = useState(false); @@ -209,6 +210,22 @@ export default function AccountDetailPage() { void loadEvents(); }, [loadEvents]); + // 租赁记录:池化后「这个账号被谁租过、用完还回去没有」。 + // 没有 `account:pool_use` 权限的账号会 403 —— 静默留空,不影响页面其它部分。 + useEffect(() => { + if (!accountId) return; + let alive = true; + (async () => { + try { + const rows = await douyuPoolApi.leasesOfAccount(accountId); + if (alive) setLeases(rows); + } catch { + if (alive) setLeases([]); + } + })(); + return () => { alive = false; }; + }, [accountId]); + const columns: TableProps['columns'] = [ { title: '时间', @@ -238,6 +255,28 @@ export default function AccountDetailPage() { render: (val: number | null) => val === null || val === undefined ? - : val, }, + { + // 池化后客服不再长期持有账号,「这件事是谁做的」必须一眼看得到。 + // 历史事件(占 71%)没有操作人 —— 如实显示「-」,不要假装是系统做的。 + title: '操作人 / 渠道', + width: 170, + render: (_val, record) => { + const channel = (record.detail?.channel as string | undefined) || ''; + const channelLabel = channel === 'supplier_api' + ? 'API 直充' + : channel === 'wechat_qr' + ? '微信扫码' + : channel; + return ( + + {record.actor_name + ? {record.actor_name} + : -} + {channelLabel && {channelLabel}} + + ); + }, + }, ]; if (!accountId) { @@ -678,6 +717,41 @@ export default function AccountDetailPage() { + 客服按需租用,用完自动收回} + > + }} + columns={[ + { + title: '客服', dataIndex: 'holder_name', width: 120, + render: (value: string, row: DouyuPoolLease) => value || `#${row.holder_user_id}`, + }, + { title: '商品', dataIndex: 'commodity_id', width: 200 }, + { title: '批次', dataIndex: 'batch_id', width: 130 }, + { + title: '状态', dataIndex: 'status', width: 100, + render: (value: string) => ( + {value} + ), + }, + { + title: '租用时间', dataIndex: 'leased_at', width: 170, + render: (value: string | null) => (value ? formatTime(value) : '-'), + }, + { + title: '释放时间', dataIndex: 'expires_at', width: 170, + render: (value: string | null) => (value ? formatTime(value) : '-'), + }, + ]} + /> + + ([]); + const [commodityId, setCommodityId] = useState(''); + const [quantity, setQuantity] = useState(1); + const [allowTopup, setAllowTopup] = useState(true); + const [supply, setSupply] = useState(null); + const [result, setResult] = useState(null); + const [leases, setLeases] = useState([]); + const [loading, setLoading] = useState(false); + const [previewing, setPreviewing] = useState(false); + + const loadGoods = useCallback(async () => { + try { + const rows = await douyuPoolApi.goods(); + const usable = rows.filter((item) => (item.score ?? 0) > 0); + setGoods(usable); + if (usable.length > 0 && !commodityId) setCommodityId(usable[0].commodity_id); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } + }, [commodityId]); + + const loadLeases = useCallback(async () => { + try { + setLeases(await douyuPoolApi.leases(true)); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } + }, []); + + useEffect(() => { void loadGoods(); }, [loadGoods]); + useEffect(() => { void loadLeases(); }, [loadLeases]); + + const loadSupply = useCallback(async () => { + if (!commodityId) return; + setPreviewing(true); + try { + setSupply(await douyuPoolApi.supply('elite', commodityId)); + } catch (e: unknown) { + setSupply(null); + message.error(getErrorMessage(e)); + } finally { + setPreviewing(false); + } + }, [commodityId]); + + useEffect(() => { void loadSupply(); }, [loadSupply]); + + const submit = async () => { + if (!commodityId) { message.warning('请先选择皮肤'); return; } + setLoading(true); + try { + const data = await douyuPoolApi.exchange({ + scope: 'elite', commodity_id: commodityId, quantity, + allow_topup: allowTopup, allow_fallback: true, + }); + setResult(data); + if (data.shortfall > 0) { + message.warning(`实配 ${data.allocated} / 需求 ${data.requested},缺 ${data.shortfall}`); + } else { + message.success(`已分配 ${data.allocated} 个账号,批次 ${data.batch_id}`); + } + await Promise.all([loadSupply(), loadLeases()]); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + const releaseLease = async (leaseId: number) => { + try { + await douyuPoolApi.releaseLease(leaseId); + message.success('已释放'); + await Promise.all([loadSupply(), loadLeases()]); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } + }; + + const goodsOptions = useMemo( + () => goods.map((item) => ({ + value: item.commodity_id, + label: `${item.name}(${item.score ?? 0} 分)`, + })), + [goods], + ); + + const accountColumns: ColumnsType = [ + { title: '账号ID', dataIndex: 'account_id', width: 90 }, + { title: '账号', dataIndex: 'username', ellipsis: true }, + { + title: '梯队', dataIndex: 'tier', width: 90, + render: (tier: string) => ( + + {tier === 'A' ? '免充值' : '需充值'} + + ), + }, + { title: '积分', dataIndex: 'points', width: 90 }, + { + title: '需充值', width: 160, + render: (_, row) => (row.topup_yuan > 0 + ? {row.topup_yuan} 元({row.recharge_orders.join(' + ')}) + : 无需), + }, + { + title: '可换绑时间', dataIndex: 'role_change_available_at', width: 180, + render: (value: string | null) => (value ? value.replace('T', ' ').slice(0, 19) : '—'), + }, + ]; + + const leaseColumns: ColumnsType = [ + { title: '账号', dataIndex: 'username', ellipsis: true }, + { title: '商品', dataIndex: 'commodity_id', width: 200 }, + { title: '批次', dataIndex: 'batch_id', width: 130 }, + { + title: '到期', dataIndex: 'expires_at', width: 170, + render: (value: string | null) => (value ? value.replace('T', ' ').slice(0, 19) : '—'), + }, + { + title: '操作', width: 100, + render: (_, row) => ( + + ), + }, + ]; + + return ( + + } onClick={() => { void loadGoods(); void loadSupply(); void loadLeases(); }}> + 刷新 + + )} + > + + + 皮肤 + setAllowTopup(value === 'topup')} + options={[ + { value: 'topup', label: '自动充值积分' }, + { value: 'skip', label: '只用已有积分' }, + ]} + /> + + + + + + + {supply && ( + + + + + + + + + + + + + + + )} + + {supply?.manual_payment_required && ( + + )} + + + {result && ( + + + {result.requested} + {result.allocated} + + 0 ? 'danger' : undefined}>{result.shortfall} + + {result.recharge_channel || '—'} + + {result.reasons.length > 0 && ( + + {result.reasons.map((reason) =>
· {reason}
)} +
+ )} +
+ + )} + + +
+ + + ); +} diff --git a/web/frontend/src/pages/douyu-tasks/config.tsx b/web/frontend/src/pages/douyu-tasks/config.tsx index 6f661ae..ef67709 100644 --- a/web/frontend/src/pages/douyu-tasks/config.tsx +++ b/web/frontend/src/pages/douyu-tasks/config.tsx @@ -15,6 +15,10 @@ export const TASK_STATUS_COLORS: Record = { failed: 'error', error: 'error', stopped: 'warning', + // 账号池补给链:条件已满足 / 前置未成功 → 跳过(不是失败,别标红) + skipped: 'default', + // 鱼翅等待超时转人工:需要有人去处理,用醒目但不等于失败的颜色 + manual_required: 'warning', }; export const TASK_STATUS_LABELS: Record = { @@ -25,6 +29,8 @@ export const TASK_STATUS_LABELS: Record = { failed: '失败', error: '异常', stopped: '已停止', + skipped: '已跳过', + manual_required: '转人工', }; export const ELITE_QUICK_ACTIONS = [