diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index 0f80bf6..8141ad6 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -163,6 +163,35 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重 `ensure_shops_registered()` 会把它登记进字典(新建/编辑/粘贴导入共用同一条路); 3. **字典从空开始**:不回填历史订单里出现过的店铺名(用户明确"不用看之前的")。 +### 3.5 表 `huya_external_order_metrics`(账号全生命周期快照,2026-10-16 加) + +一次「查积分 / 兑换记录」= 一行。**只追加、不更新**(要改就是重新查询,历史留在那里), +但**不是事件表**:它是"这一刻账号长什么样"的证据,跟 §7.3 的 `audit_logs`(谁改了什么)分工不同。 +设计动机见 §5.7。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | int PK | | +| `order_id` | int FK `huya_external_orders.id` **ON DELETE CASCADE** index | 随订单删除一起清掉 | +| `order_no` / `uid` | varchar | 冗余快照(订单删了也看得懂导出过的报表) | +| `sid` | int | 查询用的活动 ID(工作台 `huya_config.sid`,见 §11.4) | +| `score` / `gained_score` / `used_score` | int null | 可用 / 累计获得 / 已消耗积分 | +| `records_total` / `records_matched` | int | 兑换记录条数 / 命中这一单礼包的条数 | +| `verdict` | varchar(16) index | `success` / `failed` / `unknown`(三态,见 §5.7) | +| `message` | varchar(512) | 人话结论(含"为什么不判/为什么没自动改状态") | +| `source` | varchar(16) | `manual`(单行点)/ `batch`(勾选批量) | +| `raw` | JSON | 接口原始体裁剪(**不含任何凭据**,见下) | +| `created_by` | int FK `users.id` | 谁查的 | +| `created_at` | datetime index | 查询时刻 | + +**四条取舍**: + +1. **不存 CK**:`raw` 只放探针/活动接口返回的字段(积分、记录、命中预览), + 凭据不落这张表 —— 表会随查询次数线性增长,多一处凭据就多一处泄漏面; +2. **verdict 三态**:`unknown` 是"数据不够,判不了",不是"失败"。否则接口抖一下就把单子判死; +3. **只追加**:业务上要的是"积分变化",能被覆盖的快照看不出变化; +4. **随订单删除**:订单是主体,快照是附属(`ON DELETE CASCADE`)。 + --- @@ -171,29 +200,54 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重 ### 4.1 业务状态机(一行的进度) ``` -pending ──绑定成功──▶ bound ──充值──▶ recharged ──上脚本──▶ scripted ──归档──▶ done - │ │ │ │ - └────────────────────┴────────────────┴───────────────────┴──────▶ void(作废) +pending ─人工确认─▶ bound ─充值─▶ recharged ─上脚本─▶ scripted ─兑换成功─▶ done + │ │ │ │ │ + └─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ failed(兑换失败) + │ │ │ │ │ + └─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ void(作废) ``` | 状态 | 含义 | 进入条件 | 谁可触发 | | --- | --- | --- | --- | -| `pending` | 待绑定 | 新建行(默认) | — | -| `bound` | 已绑定 | 填了 `bind_douyu_account` **且** 至少 1 张绑定图 | 所有人 | -| `recharged` | 已充值 | 从 `bound` 前进;写 `recharged_at` | 所有人 | -| `scripted` | 已上脚本 | 从 `recharged` 前进;写 `scripted_at` | 所有人 | -| `done` | 已完成 | 从 `scripted` 前进;写 `done_at` | 所有人 | -| `void` | 作废 | 任意状态可进;`void_reason` 必填 | 所有人 | +| `pending` | 待处理 | 新建 / 上传导入后的默认态(**上传完就是这里**) | — | +| `bound` | **已确认绑定** | 人工核对「探针读到的绑定角色」与「主页截图」一致后手动切 | 人工(单行 / 批量) | +| `recharged` | 已充值 | 充值完成后手动切(可跳步);写 `recharged_at` | 人工 | +| `scripted` | 已上脚本 | 脚本上完后手动切(可跳步);写 `scripted_at` | 人工 | +| `done` | 已完成 | 人工切;**或**「已上脚本」的单查积分/兑换记录命中礼包后自动切(§5.7) | 人工 / 系统判定 | +| `failed` | **兑换失败** | 人工切;**或**「已上脚本」的单查兑换记录**没有**这个礼包后自动切(§5.7) | 人工 / 系统判定 | +| `void` | 已作废 | 任意流程内状态可进;`void_reason` 必填 | 人工 | -**规则**: -1. 状态**只能向前**。要回退必须显式勾选"回退"并从下拉里选目标态, - 审计里记为 `status_rollback`(保留痕迹,不静默改)。 -2. 进入 `bound` 的前置校验在**接口层**做(缺绑定图/缺绑定斗鱼号 → 400, - 错误信息要说清缺什么),不在前端做——前端只是体验,不能当校验。 -3. **不用一个布尔列表达"充值/上脚本"**:`recharged_at` / `scripted_at` 是 +**规则**(2026-10-16 用户拍板的口径,旧版"只能一步一步往前"作废): + +1. **人工切换允许往前跳步**:目标阶段序号 ≥ 当前即放行(`is_forward_status()`)。 + 实际场景就是会跳(没记充值就直接上脚本),逼人一步一步点只会逼出假数据。 + 跳步会把沿途的 `*_at` 时间戳**补齐**(`_sync_stage_timestamps()`), + 否则会出现"已上脚本但没有充值时间"的自相矛盾。 +2. **回退必须显式声明**:目标阶段序号 < 当前时必须 `rollback=true`(界面勾「回退」), + 会**清掉后面阶段的时间戳**(`scripted → pending` 之后 `bound_at/recharged_at/scripted_at` 全为 null), + 审计里 `rollback=true` 留痕。 +3. **`failed` / `void` 是旁路**:流程内任意状态都能进(不用勾回退); + 从这两态回到流程内**必须** `rollback=true`。`void` 不可逆(只能编辑或新建), + `failed` 可回流程内(人工确认后重试)。 +4. **作废 / 失败不动流程时间戳**:`void` / `failed` 保留原来的 `scripted_at` 等 —— + 那是"什么时候上的脚本"的证据,与"结果成没成"无关。 +5. **进入 `bound` 的前置校验放在服务层**(`_assert_can_enter_bound()`): + **CK + 绑定角色(探针)+ 主页截图**三样缺一不可,缺什么在错误里写清(400)。 + 绑定图(`bind_images`)与自由文本「绑定斗鱼号」**不再**参与这门校验 + —— 绑定关系现在由探针读,绑定图已停用(§11.3)。 +6. **不用一个布尔列表达"充值/上脚本"**:`recharged_at` / `scripted_at` 是 "什么时候做过"的证据,`status` 是"现在卡在哪"。两者都留, 才能既显示"已充值/已上脚本"这种组合,也能显示"只充了没上脚本"的中间态。 +**批量改状态**(`set_status_batch()`,接口 `POST /external-orders/status/batch`): + +- 勾选多行一次切到**同一个**目标状态;**逐行 SAVEPOINT 隔离**, + 某一行前置校验没过(例如没主页截图进不了「已确认绑定」)只记在该行的 `message` 里, + 不影响其它行(同 §5.1 导入的做法);一次上限 `PROBE_BATCH_MAX=50`; +- 单行与批量走**同一个** `set_status()`,规则不会漂移;错误信息统一带上 + "要往前的话下一步是 X(也可以直接跳到更后面的状态)"。 + + ### 4.2 CK 状态机(凭据本身) ``` @@ -324,6 +378,39 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 **点单号直接切到那一单的详情**);详情抽屉里也有一段「同一个买家的其它单」。 - 这是**软识别**:不写库、不改任何状态,识别错了也不会污染数据(`method` 字段在界面上写明口径)。 +### 5.7 账号全生命周期:积分 / 兑换记录(2026-10-16 用户提的) + +抢购跑完之后要回答的问题只有一个:**到底兑换成功没有**。能拿来判断的线索有两条 —— +**兑换记录里有没有这一单的礼包** + **积分有没有变化**。所以每查一次就往 +`huya_external_order_metrics`(§3.5)追加一条快照,**只追加、不更新**, +详情抽屉里按时间倒序排成一条时间线;列表上只回填最近一次(`last_score` / `last_verdict`, +整页一次查询,不按行发 SQL)。 + +判定规则(`query_order_activity()`): + +| 情况 | verdict | 业务状态 | +| --- | --- | --- | +| 兑换记录里有这一单的礼包(名字互相包含即算命中,`gift_matches_records()`) | `success` | 「已上脚本」→ 自动「已完成」`done` | +| 接口通了,但记录里没有这个礼包 | `failed` | 「已上脚本」→ 自动「兑换失败」`failed` | +| 接口没通 / 这单没填礼包名 | `unknown` | **不动**(数据不够,不许瞎判) | + +**四条边界**: + +1. **只手动触发**:单行「查积分 / 兑换记录」+ 勾选批量查询(串行 + `PROBE_BATCH_INTERVAL_SECONDS` + 间隔,上限 `PROBE_BATCH_MAX=50`);**不做定时轮询** —— 虎牙风控(§12), + 而且"什么时候去查"本身是运营的判断(抢购是人工触发的); +2. **只有 `scripted` 的单会自动改状态**:抢购发生在上脚本之后, + 别的阶段查到什么都说明不了问题;这种情况 metric 照写,结论里写明 + "只有「已上脚本」的单才会按查询结果自动改状态",由人决定要不要手动切; +3. **接口失败也写快照**(verdict=`unknown`):失败本身也是"这一刻的状态", + 下次查到成功就能在时间线上看出"积分从 X 涨到 Y",这正是要的证据; +4. **`void` 的单不查**、没 CK 的单不查(`void_order` / `no_cookie`,400), + 两种情况都会写审计(`huya:collab:activity_query`,含 `score/records_total/ + records_matched/verdict/auto_status`)。 + +礼包匹配用**互相包含**(`prize_name` 与 `order.gift_name` 双向包含即命中), +但名字短于 2 个字(`_MIN_GIFT_MATCH_LENGTH`)不参与 —— 免得"礼包"两个字匹配一堆记录。 + --- ## 6. 图片(复用已落地的上传能力) @@ -701,6 +788,43 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 列宽 150 → 170,全文放 Tooltip(附验证时间)。 顺带把礼包 / 备注那格的可编辑文本加上 `wordBreak: break-word`,防止一长串无空格字符串顶穿列。 +### 11.7 2026-10-16 第四轮:状态机新口径 + 账号全生命周期 + +用户原话(这一轮的全部需求): +"业务状态刷新一下,目前上传后都属于待处理"、 +"手动确认绑定角色信息和主页截图相同后,可以手动切换到已确认绑定"、 +"充值完成后可以切换到已充值"、"完成上脚本后可以切换到已上脚本"、 +"抢购完成后可以查询兑换记录、积分等等,每次查询积分变化都要写入这个账号的全生命周期, +这样就可以通过兑换记录和积分变化确认是否兑换成功"、 +"可以根据查询的兑换是否成功或失败,自动改变状态",并且"手动切换一个或者勾选多个都可以"。 + +**落地清单**: + +| 面 | 落地 | +| --- | --- | +| 状态机(服务层) | 新增终态 `failed`(兑换失败);`bound` 标签改「已确认绑定」;`is_forward_status()` 允许往前跳步;`_assert_can_enter_bound()` 改成 CK + 绑定角色 + 主页截图;`void`/`failed` 不动时间戳 | +| 批量改状态 | `set_status_batch()` + `POST /external-orders/status/batch`(逐行 SAVEPOINT 隔离,上限 50) | +| 全生命周期 | 新表 `huya_external_order_metrics` + 迁移 `20261016_0059`;`query_order_activity()` / `query_activity_batch()`;`POST /{id}/activity/query`、`POST /activity/query`、`GET /{id}/metrics`;列表回填 `last_score/last_metric_at/last_verdict/last_metric_message` | +| 前端 | 状态筛选与汇总卡片加「兑换失败」;工具栏「批量改状态(下拉选目标)」+「批量查积分/兑换」;行操作加「查积分 / 兑换记录」「改状态…」;列表加「积分」列(积分 + 判定徽标 + 时间,全文进 Tooltip);详情抽屉加「账号全生命周期」时间线 + 逐条命中的兑换记录 | +| 测试 | 新增 `TestCollabStatusBatch`(4 例)、`TestCollabActivityLifecycle`(9 例);`tests/test_migrations.py` 的 `HEAD_REVISION` → `20261016_0059`;全量 `pytest` 944 passed / 6 skipped | +| 接口冒烟 | `/tmp/smoke_collab_api.py` 补到 **67 项全绿**:批量跳步 + 时间戳补齐、单行查询命中→自动「已完成」、非 `scripted` 只记快照、时间线倒序、列表回填、批量查询判定→自动「兑换失败」、`failed` 状态筛选、批量逐行隔离、回退仍要 `rollback`、删单级联删快照 | + +**这一轮踩到/定下的点**: + +1. **「已确认绑定」的门槛是"能核对"**,不是"已经绑定":要求探针读到了**角色** + (没角色就没法跟截图对)**且**至少有 1 张主页截图。CK 本身也在必查项里。 +2. **跳步必须补齐时间戳**,否则列表上会出现"已上脚本但没有充值时间"的自相矛盾; + 反过来,回退必须清掉后面阶段的时间戳(`_sync_stage_timestamps()` 两个方向都管)。 +3. **自动改状态只在 `scripted` 生效**,其它状态只写快照 + 在结论里说清原因 —— + 自动改状态是最容易"越权"的地方,口径必须窄。 +4. **`unknown`(判不了)不写状态**:接口抖一下就把单子判成"兑换失败",比不判更糟。 +5. **`statusAction` 弹窗合一**:单行的「改状态 / 回退 / 作废」共用一个 Modal, + 靠 `mode: 'set' | 'rollback' | 'void'` 分支(`set` 允许选全部状态含 `failed`/`void`, + `rollback` 只在流程链里选),避免三个弹窗三套校验。 +6. **尚未真机验证**:`getUserScore` / `getUserPrizeRecords` 两个接口只做了假客户端单测, + 真机字段名(`available_score` / `records[].prize_name`)还没拿真 CK 跑过一次; + 真机跑通前不要把"兑换失败"当成定论(先用单行查询看一眼 `raw`)。 + --- ## 12. 风险 @@ -709,6 +833,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 | --- | --- | | 与 `huya_accounts` 出现"两份 CK 谁是准的" | §2.3 已写死:本表 CK 只在协作语境有效,不自动同步;需要跑任务时人工导入 | | 探针接口被风控 | 只做人工触发、串行、带间隔;不做定时轮询 | +| 积分 / 兑换记录查询被风控 | 同上:只手动触发(单行 + 勾选批量,串行带间隔),不做定时轮询(§5.7) | +| 自动改状态误判(接口抖动 → 判成"兑换失败") | 三态判定:取不到数据一律 `unknown` 且不动状态;只有 `scripted` 的单才自动改;每次查询留快照,人工可回退(`rollback`) | | 活动换 ID(SID 过期)→ 礼包列表空白 | 礼包 SID 一律读 `huya_config.sid`(工作台「虎牙配置」可改,见 §11.4);空列表时结论直接提示去改 SID | | 自由文本的"绑定斗鱼号"没法校验 | 先按文本存(Excel 里就是文本);能拿到结构化 uid 时补 `bind_douyu_uid` | | 图片越攒越多 | §6.4 孤儿清理脚本 + `file_assets` 台账已有 `size_bytes` 可统计 | diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 1061594..bc3be45 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -81,17 +81,28 @@ class TestCollabBase: ) def _bind_ready(self, order: HuyaExternalOrder) -> HuyaExternalOrder: - """把一行推到「可以标记已绑定」的状态。""" + """把一行推到「可以标记为已确认绑定」的状态。 + + 2026-10-16 口径:进 `bound` 需要 **探针读到的绑定角色 + 主页截图**(人工核对这两样一致), + 绑定图与「绑定斗鱼号」都不再要求。 + """ collab.set_ck(self.db, self.user, order, fake_ck("1199647239697")) - collab.update_order( - self.db, self.user, order, {"bind_douyu_account": "用户4241534208"} + collab.verify_ck( + self.db, + self.user, + order, + client=FakeProbeClient( + FakeBindResp( + nick="微信用户", role="吃鸡199706", plat="苹果(iOS)", area="微信" + ) + ), ) collab.set_images( self.db, self.user, order, - "bind_images", - ["collab/2026/10/16/bind01.png"], + "home_images", + ["collab/2026/10/16/home01.png"], ) return order @@ -251,23 +262,52 @@ class TestCollabCrud(TestCollabBase): class TestCollabStatusMachine(TestCollabBase): - def test_cannot_skip_forward_without_explicit_rollback(self): - """pending 只能走到 bound,直接跳到 recharged 要被挡下。""" - order = self._create() + def test_forward_jump_is_allowed_but_backward_needs_rollback(self): + """2026-10-16 口径:人工切换允许**往前跳步**,回退必须显式 rollback。""" + order = self._bind_ready(self._create()) + + # pending → scripted(跳过 已确认绑定/已充值)是允许的 + collab.set_status(self.db, self.user, order, "scripted") + assert order.status == "scripted" + # 跳步时中间阶段的时间戳补上,别让时间线断档 + assert order.bound_at is not None + assert order.recharged_at is not None + assert order.scripted_at is not None + with pytest.raises(collab.CollabError) as excinfo: collab.set_status(self.db, self.user, order, "recharged") assert excinfo.value.code == "status_not_forward" - assert order.status == "pending" + assert order.status == "scripted" - def test_bound_requires_ck_bind_account_and_image(self): + def test_bound_requires_ck_probe_role_and_home_screenshot(self): order = self._create() with pytest.raises(collab.CollabError) as excinfo: collab.set_status(self.db, self.user, order, "bound") message = excinfo.value.detail # 错误信息必须说清缺什么,否则客服只能猜 assert "CK" in message - assert "绑定斗鱼号" in message - assert "绑定图" in message + assert "绑定角色" in message + assert "主页截图" in message + + # 只贴 CK + 读到角色,但没传主页截图 → 还是不让进 + collab.set_ck(self.db, self.user, order, fake_ck("1199647001234")) + collab.verify_ck( + self.db, + self.user, + order, + client=FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")), + ) + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status(self.db, self.user, order, "bound") + assert "主页截图" in excinfo.value.detail + assert "绑定图" not in excinfo.value.detail # 绑定图已停用,不该再被要求 + + collab.set_images( + self.db, self.user, order, "home_images", ["collab/2026/10/16/home01.png"] + ) + collab.set_status(self.db, self.user, order, "bound") + assert order.status == "bound" + assert order.bound_at is not None def test_forward_flow_sets_timestamps(self): order = self._bind_ready(self._create()) @@ -858,14 +898,14 @@ class TestCollabCkProbe(TestCollabBase): assert detail["after"]["bind_game_role"] == "新角色" def test_binding_role_from_probe_satisfies_the_bound_precondition(self): - """只验证过 CK、读到绑定角色的行,不该再被「绑定斗鱼号」挡在 bound 外面。""" + """探针读到绑定角色 + 有主页截图 → 可以进「已确认绑定」(绑定斗鱼号/绑定图都不再要求)。""" order = self._row_with_ck() collab.set_images( self.db, self.user, order, - "bind_images", - ["collab/2026/10/16/bind01.png"], + "home_images", + ["collab/2026/10/16/home01.png"], ) collab.verify_ck( self.db, @@ -874,6 +914,7 @@ class TestCollabCkProbe(TestCollabBase): client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706")), ) assert order.bind_douyu_account == "" + assert order.bind_images == [] collab.set_status(self.db, self.user, order, "bound") @@ -1494,6 +1535,319 @@ class TestCollabBuyerCluster(TestCollabBase): assert alone["buyer_cluster"] is None +class FakeScoreResp: + """合成 ``getUserScore`` 响应(积分)。""" + + def __init__( + self, + *, + status: int = 200, + msg: str = "", + score: int = 1234, + gained: int = 5000, + used: int = 3766, + ): + self.status = status + self.msg = msg + self.score = score + self.gainScore = gained + self.usedScore = used + self.newGainScore = gained + self.newUsedScore = used + + @property + def available_score(self) -> int: + return max(0, int(self.newGainScore or 0) - int(self.newUsedScore or 0)) + + def to_dict(self) -> dict: + return { + "status": self.status, + "msg": self.msg, + "score": self.score, + "gain_score": self.gainScore, + "used_score": self.usedScore, + } + + +class FakeRecordsResp: + """合成 ``getUserPrizeRecords`` 响应(兑换记录)。""" + + def __init__( + self, *, status: int = 200, msg: str = "", records: list | None = None + ): + self.status = status + self.msg = msg + self._records = ( + records + if records is not None + else [ + { + "prize_name": "不朽伊人大礼包", + "score": 200, + "status_label": "已发放", + "order_id": "H-ORDER-1", + "exchange_time": 1792000000, + } + ] + ) + + def to_dict(self) -> dict: + return { + "status": self.status, + "msg": self.msg, + "record_count": len(self._records), + "records": self._records, + } + + +class FakeActivityClient: + """离线「积分 + 兑换记录」客户端:两个接口分别可按脚本给出响应 / 异常。""" + + def __init__(self, *, score: object = None, records: object = None): + self.score = score + self.records = records + self.score_calls: list[dict] = [] + self.record_calls: list[dict] = [] + + def query_user_score(self, **kwargs): + self.score_calls.append(kwargs) + if isinstance(self.score, Exception): + raise self.score + return self.score if self.score is not None else FakeScoreResp() + + def get_user_prize_records(self, **kwargs): + self.record_calls.append(kwargs) + if isinstance(self.records, Exception): + raise self.records + return self.records if self.records is not None else FakeRecordsResp() + + +class TestCollabStatusBatch(TestCollabBase): + """勾选多行一次改状态:部分失败不影响其它行(2026-10-16 用户要的批量切换)。""" + + def test_batch_updates_selected_rows(self): + first = self._bind_ready(self._create(order_no="BATCH-1")) + second = self._bind_ready(self._create(order_no="BATCH-2")) + + result = collab.set_status_batch( + self.db, self.user, [first, second], "scripted" + ) + + assert result["updated"] == 2 and result["failed"] == 0 + assert first.status == second.status == "scripted" + + def test_batch_reports_per_row_failure_without_touching_others(self): + ready = self._bind_ready(self._create(order_no="BATCH-3")) + not_ready = self._create(order_no="BATCH-4") # 没 CK / 没角色 / 没主页截图 + + result = collab.set_status_batch( + self.db, self.user, [ready, not_ready], "bound" + ) + + assert result["updated"] == 1 and result["failed"] == 1 + by_no = {item["order_no"]: item for item in result["results"]} + assert by_no["BATCH-3"]["ok"] is True + assert by_no["BATCH-4"]["ok"] is False + assert "主页截图" in by_no["BATCH-4"]["message"] + assert ready.status == "bound" + assert not_ready.status == "pending" + + def test_batch_backward_needs_rollback_flag(self): + order = self._bind_ready(self._create(order_no="BATCH-5")) + collab.set_status(self.db, self.user, order, "scripted") + + rejected = collab.set_status_batch(self.db, self.user, [order], "recharged") + assert rejected["failed"] == 1 + assert order.status == "scripted" + + accepted = collab.set_status_batch( + self.db, self.user, [order], "recharged", rollback=True + ) + assert accepted["updated"] == 1 + assert order.status == "recharged" + + def test_batch_rejects_empty_and_too_many(self): + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status_batch(self.db, self.user, [], "bound") + assert excinfo.value.code == "status_batch_empty" + + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status_batch( + self.db, self.user, [None] * (collab.PROBE_BATCH_MAX + 1), "bound" + ) + assert excinfo.value.code == "status_batch_too_large" + + +class TestCollabActivityLifecycle(TestCollabBase): + """抢购之后:查积分 + 兑换记录 → 落生命周期快照 → 按结果自动改状态。""" + + def _scripted_order( + self, + order_no: str = "ACT-1", + *, + gift_name: str = "不朽伊人大礼包", + ) -> HuyaExternalOrder: + order = self._bind_ready(self._create(order_no=order_no, gift_name=gift_name)) + collab.set_status(self.db, self.user, order, "scripted") + return order + + def _metrics(self, order: HuyaExternalOrder) -> list: + return collab.list_metrics(self.db, order) + + def test_success_verdict_promotes_scripted_order_to_done(self): + order = self._scripted_order() + client = FakeActivityClient() + + result = collab.query_order_activity(self.db, self.user, order, client=client) + + assert result["verdict"] == collab.VERDICT_SUCCESS + assert result["records_matched"] == 1 + assert result["auto_status"] == "done" + assert order.status == "done" + assert order.done_at is not None + assert client.score_calls[0]["uid"] == 1199647239697 + assert client.record_calls[0]["sid"] == collab.GIFTS_SID + + metrics = self._metrics(order) + assert len(metrics) == 1 + assert metrics[0].score == 1234 + assert metrics[0].verdict == "success" + assert metrics[0].records_total == 1 + assert "huya:collab:activity_query" in [ + row.action for row in self._audits(order) + ] + + def test_missing_gift_marks_failed_without_clearing_scripted_time(self): + order = self._scripted_order("ACT-2") + client = FakeActivityClient( + records=FakeRecordsResp( + records=[{"prize_name": "别的礼包", "status_label": "已发放"}] + ) + ) + + result = collab.query_order_activity(self.db, self.user, order, client=client) + + assert result["verdict"] == collab.VERDICT_FAILED + assert result["auto_status"] == "failed" + assert order.status == "failed" + # failed 是旁路终态:上脚本的时间戳必须留着(不然不知道什么时候上的) + assert order.scripted_at is not None + assert order.done_at is None + + # 从「兑换失败」回流程内要显式回退 + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status(self.db, self.user, order, "scripted") + assert excinfo.value.code == "status_not_forward" + collab.set_status(self.db, self.user, order, "scripted", rollback=True) + assert order.status == "scripted" + + def test_status_untouched_when_order_is_not_scripted(self): + order = self._bind_ready( + self._create(order_no="ACT-3", gift_name="不朽伊人大礼包") + ) + collab.set_status(self.db, self.user, order, "bound") + assert order.status == "bound" + + result = collab.query_order_activity( + self.db, self.user, order, client=FakeActivityClient() + ) + + assert result["verdict"] == collab.VERDICT_SUCCESS + assert result["auto_status"] == "" + assert order.status == "bound" + assert "只有「已上脚本」" in result["message"] + + def test_unknown_verdict_when_interface_fails(self): + order = self._scripted_order("ACT-4") + client = FakeActivityClient( + score=RuntimeError("boom"), + records=FakeRecordsResp(status=500, msg="活动已结束"), + ) + + result = collab.query_order_activity(self.db, self.user, order, client=client) + + assert result["verdict"] == collab.VERDICT_UNKNOWN + assert result["auto_status"] == "" + assert order.status == "scripted" # 数据不够,不许瞎判 + metrics = self._metrics(order) + assert len(metrics) == 1 and metrics[0].verdict == "unknown" + + def test_unknown_when_gift_name_missing(self): + order = self._scripted_order("ACT-5", gift_name="") + + result = collab.query_order_activity( + self.db, self.user, order, client=FakeActivityClient() + ) + + assert result["verdict"] == collab.VERDICT_UNKNOWN + assert "没填礼包名" in result["message"] + assert order.status == "scripted" + + def test_query_requires_cookie_and_rejects_void(self): + order = self._create(order_no="ACT-6") + with pytest.raises(collab.CollabError) as excinfo: + collab.query_order_activity( + self.db, self.user, order, client=FakeActivityClient() + ) + assert excinfo.value.code == "no_cookie" + + void = self._scripted_order("ACT-7") + collab.set_status(self.db, self.user, void, "void", reason="客户退款") + with pytest.raises(collab.CollabError) as excinfo: + collab.query_order_activity( + self.db, self.user, void, client=FakeActivityClient() + ) + assert excinfo.value.code == "void_order" + + def test_batch_query_isolates_failures(self): + first = self._scripted_order("ACT-8") + broken = self._create(order_no="ACT-9") # 没 CK + + result = collab.query_activity_batch( + self.db, + self.user, + [first, broken], + client=FakeActivityClient(), + interval_seconds=0, + ) + + assert result["total"] == 2 + assert result["success"] == 1 and result["unknown"] == 1 + assert result["auto_changed"] == 1 + assert first.status == "done" + assert broken.status == "pending" + assert any("还没有 CK" in item["message"] for item in result["results"]) + + def test_metrics_timeline_is_newest_first(self): + order = self._scripted_order("ACT-10") + + collab.query_order_activity( + self.db, + self.user, + order, + client=FakeActivityClient( + score=FakeScoreResp(score=1500, gained=5000, used=3500) + ), + ) + collab.query_order_activity( + self.db, + self.user, + order, + client=FakeActivityClient( + score=FakeScoreResp(score=800, gained=5000, used=4200) + ), + ) + + metrics = self._metrics(order) + assert [row.score for row in metrics] == [800, 1500] + + latest = collab.latest_metrics(self.db, [order])[int(order.id)] + assert latest.score == 800 + payload = collab.serialize_order(order, last_metric=latest) + assert payload["last_score"] == 800 + assert payload["last_verdict"] == "success" + + class TestProbeCache(TestCollabBase): def test_same_ck_probed_twice_only_hits_the_api_once(self, monkeypatch): calls: list[dict] = [] diff --git a/tests/test_migrations.py b/tests/test_migrations.py index dd84391..d6c65a6 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 = "20261016_0058" +HEAD_REVISION = "20261016_0059" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/migrations/versions/20261016_0059_huya_order_metrics.py b/web/backend/migrations/versions/20261016_0059_huya_order_metrics.py new file mode 100644 index 0000000..23a993f --- /dev/null +++ b/web/backend/migrations/versions/20261016_0059_huya_order_metrics.py @@ -0,0 +1,76 @@ +"""新增 `huya_external_order_metrics`:账号全生命周期快照(积分 + 兑换记录)。 + +背景(2026-10-16 用户需求):抢购完成后要能查 `getUserScore`(积分)与 +`getUserPrizeRecords`(兑换记录),**每次查询都落一条快照** —— 这样"积分变化 + 兑换记录" +两条线索合起来就能判断这一单到底兑换成功没有,而不是只看某一次的结果。 + +只追加、不更新;`verdict` 三态 success / failed / unknown;`raw` 存接口原始明细 +(里面**没有凭据**)。订单被删时这些快照跟着删(外键 CASCADE)。 + +可重复执行:表已存在就直接返回。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261016_0059" +down_revision: str | None = "20261016_0058" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "huya_external_order_metrics" +ORDERS = "huya_external_orders" +USERS = "users" + + +def upgrade() -> None: + bind = op.get_bind() + if TABLE in set(sa.inspect(bind).get_table_names()): + return + op.create_table( + TABLE, + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("order_id", sa.Integer(), nullable=False), + sa.Column("order_no", sa.String(length=64), nullable=False, server_default=""), + sa.Column("uid", sa.String(length=32), nullable=False, server_default=""), + sa.Column("sid", sa.Integer(), nullable=True), + sa.Column("score", sa.Integer(), nullable=True), + sa.Column("gained_score", sa.Integer(), nullable=True), + sa.Column("used_score", sa.Integer(), nullable=True), + sa.Column("records_total", sa.Integer(), nullable=False, server_default="0"), + sa.Column("records_matched", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "verdict", sa.String(length=16), nullable=False, server_default="unknown" + ), + sa.Column("message", sa.String(length=512), nullable=False, server_default=""), + sa.Column( + "source", sa.String(length=16), nullable=False, server_default="manual" + ), + sa.Column("raw", sa.JSON(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["order_id"], + [f"{ORDERS}.id"], + name=f"fk_{TABLE}_order_id", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["created_by"], [f"{USERS}.id"], name=f"fk_{TABLE}_created_by" + ), + ) + op.create_index(f"ix_{TABLE}_order_id", TABLE, ["order_id"]) + op.create_index(f"ix_{TABLE}_uid", TABLE, ["uid"]) + op.create_index(f"ix_{TABLE}_verdict", TABLE, ["verdict"]) + op.create_index(f"ix_{TABLE}_created_at", TABLE, ["created_at"]) + + +def downgrade() -> None: + bind = op.get_bind() + if TABLE in set(sa.inspect(bind).get_table_names()): + # MySQL 删表会连索引一起删。 + op.drop_table(TABLE) diff --git a/web/backend/models.py b/web/backend/models.py index 77c4023..c0486a6 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -1191,3 +1191,56 @@ class HuyaExternalOrder(Base): updated_at: Mapped[datetime] = mapped_column( UTCDateTime, default=_utcnow, onupdate=_utcnow, index=True ) + + +class HuyaExternalOrderMetric(Base): + """虎牙外部订单的**账号全生命周期**快照:每查一次积分/兑换记录写一条。 + + 2026-10-16 用户需求:抢购完成后查兑换记录与积分,**每次积分变化都要落一条**, + 这样才能靠"兑换记录 + 积分变化"判断到底兑换成功没有。 + + 设计取舍: + + 1. **只追加、不更新、不删除**(快照语义):历史要能回放,改一行就等于毁证; + 2. **不存凭据**:`raw` 只放积分响应与兑换记录明细(里面没有 cookie), + 凭据永远只待在 `huya_external_orders.cookie`; + 3. `verdict` 三态:`success`(兑换记录里有这一单的礼包)/ `failed`(接口通了但没记录)/ + `unknown`(接口没通或没可比的礼包名),**只有 success / failed 会去改业务状态**; + 4. 订单删掉时这条也跟着删(`ondelete="CASCADE"`)—— 它没有独立价值。 + """ + + __tablename__ = "huya_external_order_metrics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + order_id: Mapped[int] = mapped_column( + ForeignKey("huya_external_orders.id", ondelete="CASCADE"), index=True + ) + # 冗余一份单号与 uid:订单被改单号后,历史快照仍然说得清"当时查的是谁"。 + order_no: Mapped[str] = mapped_column(String(64), default="", server_default="") + uid: Mapped[str] = mapped_column( + String(32), default="", server_default="", index=True + ) + sid: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 积分快照(可用积分 / 累计获得 / 已消耗)—— 变化靠相邻两条相减就能看出来 + score: Mapped[int | None] = mapped_column(Integer, nullable=True) + gained_score: Mapped[int | None] = mapped_column(Integer, nullable=True) + used_score: Mapped[int | None] = mapped_column(Integer, nullable=True) + records_total: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + records_matched: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + # success / failed / unknown(见类文档)+ 一句人话结论 + verdict: Mapped[str] = mapped_column( + String(16), default="unknown", server_default="unknown", index=True + ) + message: Mapped[str] = mapped_column(String(512), default="", server_default="") + # manual(人工点)/ batch(勾选批量):出问题时能分清是谁按的 + source: Mapped[str] = mapped_column( + String(16), default="manual", server_default="manual" + ) + # 接口原始内容(积分响应 + 兑换记录明细;**不含凭据**) + raw: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=_utcnow, index=True + ) diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index d8d85a1..92867bf 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -26,9 +26,13 @@ from ...models import AuditLog, HuyaExternalOrder, User from ...schemas import ( HuyaCollabCkPreviewOut, HuyaCollabCkPreviewRequest, + HuyaCollabMetricOut, HuyaCollabShopCreate, HuyaCollabShopOut, HuyaCollabShopUpdate, + HuyaExternalOrderActivityItemOut, + HuyaExternalOrderActivityQueryOut, + HuyaExternalOrderActivityQueryRequest, HuyaExternalOrderAuditOut, HuyaExternalOrderCkRequest, HuyaExternalOrderCkStatusRequest, @@ -38,6 +42,8 @@ from ...schemas import ( HuyaExternalOrderListOut, HuyaExternalOrderOut, HuyaExternalOrderOwnerOut, + HuyaExternalOrderStatusBatchOut, + HuyaExternalOrderStatusBatchRequest, HuyaExternalOrderStatusRequest, HuyaExternalOrderSummaryOut, HuyaExternalOrderUpdate, @@ -58,10 +64,12 @@ def _http_error(exc: collab.CollabError) -> HTTPException: def _serialize_one(db: Session, order: HuyaExternalOrder) -> dict: clusters = collab.buyer_clusters(db, [order]) + metrics = collab.latest_metrics(db, [order]) return collab.serialize_order( order, users=collab.users_by_id(db, [order]), buyer_cluster=clusters.get(int(order.id)), + last_metric=metrics.get(int(order.id)), ) @@ -200,12 +208,16 @@ def list_external_orders( page_size=page_size, ) names = collab.users_by_id(db, rows) - # 「同一买家的其它单」整页一次算完(按绑定账号 + 绑定角色软识别,只读派生) + # 「同一买家的其它单」+「最近一次积分/兑换快照」整页一次算完,别按行发 SQL clusters = collab.buyer_clusters(db, rows) + metrics = collab.latest_metrics(db, rows) return { "items": [ collab.serialize_order( - row, users=names, buyer_cluster=clusters.get(int(row.id)) + row, + users=names, + buyer_cluster=clusters.get(int(row.id)), + last_metric=metrics.get(int(row.id)), ) for row in rows ], @@ -289,6 +301,62 @@ def preview_external_order_ck( return preview +@router.post( + "/external-orders/status/batch", response_model=HuyaExternalOrderStatusBatchOut +) +def batch_update_external_order_status( + req: HuyaExternalOrderStatusBatchRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """勾选多行一次改业务状态(单个也走这里)。 + + 人工切换允许**往前跳步**(待处理 → 已上脚本),回退要带 ``rollback=true``; + 某一行前置校验没过(如没主页截图不能进「已确认绑定」)只记在这一条的 ``message`` 里。 + """ + try: + orders = collab.list_orders_by_ids(db, req.ids) + result = collab.set_status_batch( + db, + current, + orders, + req.status, + reason=req.reason, + rollback=req.rollback, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + result["target_label"] = collab.ORDER_STATUS_LABELS.get(req.status, req.status) + return result + + +@router.post( + "/external-orders/activity/query", response_model=HuyaExternalOrderActivityQueryOut +) +def batch_query_external_order_activity( + req: HuyaExternalOrderActivityQueryRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """勾选多行查询积分 + 兑换记录(**串行带间隔**,防风控)。 + + 每行都会落一条生命周期快照;「已上脚本」的单按查询结果自动改状态 + (查到礼包 → 已完成;没查到 → 兑换失败),其它状态只记录不动状态。 + """ + try: + orders = collab.list_orders_by_ids(db, req.ids) + result = collab.query_activity_batch(db, current, orders) + except collab.CollabError as exc: + raise _http_error(exc) from exc + for item in result["results"]: + item["status_label"] = collab.ORDER_STATUS_LABELS.get( + item.get("status", ""), item.get("status", "") + ) + auto = item.get("auto_status") or "" + item["auto_status_label"] = collab.ORDER_STATUS_LABELS.get(auto, auto) + return result + + @router.post("/external-orders/ck/verify", response_model=HuyaExternalOrderVerifyOut) def verify_external_orders_ck( req: HuyaExternalOrderVerifyRequest, @@ -399,6 +467,51 @@ def update_external_order_status( return _serialize_one(db, order) +@router.post( + "/external-orders/{order_id}/activity/query", + response_model=HuyaExternalOrderActivityItemOut, +) +def query_external_order_activity( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """查这一单的积分 + 兑换记录(单行「查询」按钮):写一条生命周期快照。 + + 「已上脚本」的单按查询结果自动改状态:查到这一单的礼包 → 已完成; + 接口通了但没查到 → 兑换失败;接口没通 → 不动(数据不够不许瞎判)。 + """ + try: + order = collab.get_order(db, order_id) + result = collab.query_order_activity(db, current, order) + except collab.CollabError as exc: + raise _http_error(exc) from exc + result["status_label"] = collab.ORDER_STATUS_LABELS.get( + result.get("status", ""), result.get("status", "") + ) + auto = result.get("auto_status") or "" + result["auto_status_label"] = collab.ORDER_STATUS_LABELS.get(auto, auto) + return result + + +@router.get( + "/external-orders/{order_id}/metrics", response_model=list[HuyaCollabMetricOut] +) +def list_external_order_metrics( + order_id: int, + db: Session = Depends(get_db), + _current: User = Depends(require_permission("huya:collab:view")), +): + """这一单的「账号全生命周期」时间线:每次查积分/兑换记录的快照(新→旧)。""" + try: + order = collab.get_order(db, order_id) + except collab.CollabError as exc: + raise _http_error(exc) from exc + rows = collab.list_metrics(db, order) + names = collab.user_names(db, [row.created_by for row in rows]) + return [collab.serialize_metric(row, users=names) for row in rows] + + @router.post("/external-orders/{order_id}/ck", response_model=HuyaExternalOrderOut) def update_external_order_ck( order_id: int, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 8e27ad2..5f3f67f 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1268,6 +1268,107 @@ class HuyaExternalOrderVerifyOut(BaseModel): results: list[HuyaExternalOrderVerifyItemOut] = Field(default_factory=list) +class HuyaExternalOrderStatusBatchRequest(BaseModel): + """勾选多行,一次切到同一个业务状态(单个也走这里,ids 只放一个)。""" + + ids: list[int] = Field(default_factory=list) + status: str = Field(..., min_length=1, max_length=16) + reason: str = Field("", max_length=256) + # 目标不是「向前」时必须显式置 True(和单行接口一致,避免误点) + rollback: bool = False + + +class HuyaExternalOrderStatusBatchItemOut(BaseModel): + """批量改状态里的一行结论(``ok=False`` 时看 ``message``,不影响其它行)。""" + + id: int + order_no: str = "" + ok: bool = False + message: str = "" + + +class HuyaExternalOrderStatusBatchOut(BaseModel): + """批量改状态汇总:``updated + failed = total``。""" + + total: int + updated: int + failed: int + target: str = "" + target_label: str = "" + results: list[HuyaExternalOrderStatusBatchItemOut] = Field(default_factory=list) + + +class HuyaExternalOrderActivityQueryRequest(BaseModel): + """勾选多行,一次查这些行的积分 + 兑换记录(一次最多 50 条,串行带间隔)。""" + + ids: list[int] = Field(default_factory=list) + + +class HuyaCollabMetricMatchedOut(BaseModel): + """匹配到的那条兑换记录(只留界面要用的几个字段)。""" + + prize_name: str = "" + score: int | None = None + status_label: str = "" + order_id: str = "" + exchange_time: int | None = None + + +class HuyaCollabMetricOut(BaseModel): + """一条「账号全生命周期」快照(积分 + 兑换记录 + 判定)。""" + + id: int + order_id: int + order_no: str = "" + uid: str = "" + sid: int | None = None + score: int | None = None + gained_score: int | None = None + used_score: int | None = None + records_total: int = 0 + records_matched: int = 0 + verdict: str = "unknown" + verdict_label: str = "" + message: str = "" + source: str = "manual" + matched_preview: list[HuyaCollabMetricMatchedOut] = Field(default_factory=list) + created_by: int | None = None + created_by_name: str = "" + created_at: datetime | None = None + + +class HuyaExternalOrderActivityItemOut(BaseModel): + """批量查询里的一行结论。""" + + ok: bool = False + order_id: int + order_no: str = "" + verdict: str = "unknown" + verdict_label: str = "" + score: int | None = None + gained_score: int | None = None + used_score: int | None = None + records_total: int = 0 + records_matched: int = 0 + message: str = "" + # 查询后是否自动改了状态(空 = 没改,见服务层:只有「已上脚本」的单会自动改) + auto_status: str = "" + auto_status_label: str = "" + status: str = "" + status_label: str = "" + + +class HuyaExternalOrderActivityQueryOut(BaseModel): + """批量查询汇总:``success + failed + unknown = total``。""" + + total: int + success: int + failed: int + unknown: int + auto_changed: int = 0 + results: list[HuyaExternalOrderActivityItemOut] = Field(default_factory=list) + + class HuyaCollabBuyerClusterOut(BaseModel): """同一买家的多单(软识别,只读派生)。 @@ -1346,6 +1447,13 @@ class HuyaExternalOrderOut(BaseModel): # 同一买家的其它单(软识别:绑定账号 + 绑定角色相同;只读派生,没识别到就是 null) buyer_cluster: HuyaCollabBuyerClusterOut | None = None + # 最近一次「积分 / 兑换记录」快照(完整时间线走 GET /{id}/metrics) + last_score: int | None = None + last_metric_at: datetime | None = None + last_verdict: str = "" + last_verdict_label: str = "" + last_metric_message: str = "" + class HuyaExternalOrderListOut(BaseModel): """列表响应:共享视图,带上总数供分页使用。""" diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 812589d..bc208c8 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -35,7 +35,13 @@ from ..huya_defaults import ( HUYA_DEFAULT_OUTER_ACT_ID, HUYA_DEFAULT_SID, ) -from ..models import HuyaCollabShop, HuyaConfig, HuyaExternalOrder, User +from ..models import ( + HuyaCollabShop, + HuyaConfig, + HuyaExternalOrder, + HuyaExternalOrderMetric, + User, +) from .audit_service import record_audit from .file_service import build_file_url from .file_storage import normalize_object_key @@ -50,6 +56,7 @@ STATUS_BOUND = "bound" STATUS_RECHARGED = "recharged" STATUS_SCRIPTED = "scripted" STATUS_DONE = "done" +STATUS_FAILED = "failed" STATUS_VOID = "void" ORDER_STATUSES: tuple[str, ...] = ( @@ -58,19 +65,32 @@ ORDER_STATUSES: tuple[str, ...] = ( STATUS_RECHARGED, STATUS_SCRIPTED, STATUS_DONE, + STATUS_FAILED, STATUS_VOID, ) ORDER_STATUS_LABELS = { STATUS_PENDING: "待处理", - STATUS_BOUND: "已绑定", + # 人工核对过「探针读到的绑定角色」与「主页截图」一致,才允许进这一步(见 _assert_can_enter_bound) + STATUS_BOUND: "已确认绑定", STATUS_RECHARGED: "已充值", STATUS_SCRIPTED: "已上脚本", STATUS_DONE: "已完成", + # 旁路终态:抢购后查兑换记录,没查到这一单的礼包 → 判失败(人工确认后可作废或回退重试) + STATUS_FAILED: "兑换失败", STATUS_VOID: "已作废", } -# 「向前一步」的下一站;done 是终点,void 是任意态可达的旁路,都不在这条链上。 +# 流程链(时间戳同步与「能不能向前走」都只看这条链);failed / void 是**旁路**终态。 +STATUS_FLOW: tuple[str, ...] = ( + STATUS_PENDING, + STATUS_BOUND, + STATUS_RECHARGED, + STATUS_SCRIPTED, + STATUS_DONE, +) + +# 「向前一步」的下一站;done 是终点,failed / void 是任意态可达的旁路,都不在这条链上。 NEXT_STATUS = { STATUS_PENDING: STATUS_BOUND, STATUS_BOUND: STATUS_RECHARGED, @@ -94,6 +114,25 @@ STATUS_TIMESTAMP_FIELDS = { STATUS_DONE: "done_at", } + +def is_forward_status(from_status: str, target: str) -> bool: + """这一步算不算「向前」(人工切换允许往前跳步,回退必须显式声明)。 + + 2026-10-16 用户拍板:以前只允许一步一步推进,但人工实际会跳(比如没记充值就直接上脚本)。 + 现在规则: + + - 流程链内:目标阶段序号 **≥ 当前** 就是向前(含原地重复设置,幂等); + - `void`(作废):任意流程内状态都能进; + - `failed`(兑换失败):流程内任意状态都能进(兑换记录查出来没有就是没有); + - 从 `void` / `failed` 回到流程内:**必须** rollback(这两步是"要人工再确认一次"的)。 + """ + if target == STATUS_VOID or target == STATUS_FAILED: + return from_status not in (STATUS_VOID, STATUS_FAILED) + if from_status in (STATUS_VOID, STATUS_FAILED): + return False + return STAGE_INDEX[target] >= STAGE_INDEX[from_status] + + # -------------------------------------------------------------------------- # CK 状态机(凭据本身) # -------------------------------------------------------------------------- @@ -386,26 +425,27 @@ def _apply_ck(order: HuyaExternalOrder, cookie_text: str) -> dict: def _assert_can_enter_bound(order: HuyaExternalOrder) -> None: - """进入「已绑定」的前置校验(在服务层做,前端校验只是体验)。 + """进入「已确认绑定」的前置校验(服务层做,前端校验只是体验)。 - 绑定对象有两条来源:人工填的 `bind_douyu_account`(Excel D 列),或**探针读到的 - `bind_game_role`**(2026-10-16 起探针把绑定角色写进快照列)。有任意一条就算"绑上了", - 否则只验证过 CK、没读过绑定角色的行会被挡在 `bound` 外面。 + 2026-10-16 用户口径:**人工核对过「探针读到的绑定角色」与「主页截图」一致**,才允许 + 切到这一步。所以这里要两样东西齐全: + + - **绑定角色**(`bind_game_role`,探针写)—— 没读到角色就没人知道该跟哪张图对; + - **主页截图**(`home_images`,人工上传)—— 没有图就没有"核对"这件事的依据。 + + 绑定图已停用(绑定关系改由探针读),不再要求;`bind_douyu_account` 也不再算数 + (那是人工自由文本,且已从界面下线)。 """ missing: list[str] = [] if not str(order.cookie or "").strip(): missing.append("CK") - has_binding = bool( - str(order.bind_douyu_account or "").strip() - or str(order.bind_game_role or "").strip() - ) - if not has_binding: - missing.append("绑定账号(人工填绑定斗鱼号,或先「验证 CK」读到绑定角色)") - if not images_of(order, "bind_images"): - missing.append("绑定图") + if not str(order.bind_game_role or "").strip(): + missing.append("绑定角色(先贴 CK 让它自动探针读一次)") + if not images_of(order, "home_images"): + missing.append("主页截图(至少 1 张)") if missing: raise CollabError( - f"还不能标记为「已绑定」,缺少:{'、'.join(missing)}", + f"还不能标记为「{ORDER_STATUS_LABELS[STATUS_BOUND]}」,缺少:{'、'.join(missing)}", code="bound_precondition", ) @@ -418,9 +458,11 @@ def _sync_stage_timestamps( - 目标状态**之前**(含自身)缺失的时间戳补上:状态说「已上脚本」, 时间戳就不能是空的,否则前端那格显示空白、没人知道什么时候做的; - 目标状态**之后**的时间戳清空:回退之后还留着「已上脚本时间」, - 就是状态与时间戳互相矛盾。 + 就是状态与时间戳互相矛盾; + - `void` / `failed` 是旁路终态:不动流程链上的时间戳(从"已上脚本"判失败时, + "什么时候上的脚本"这条信息必须留着)。 """ - if target == STATUS_VOID: + if target in (STATUS_VOID, STATUS_FAILED): return target_index = STAGE_INDEX[target] for stage, field in STATUS_TIMESTAMP_FIELDS.items(): @@ -443,10 +485,13 @@ def set_status( ) -> dict: """推进/回退业务状态(本模块唯一的 ``status`` 写入口)。 + 人工切换**允许往前跳步**(如 待处理 → 已上脚本),回退必须显式 ``rollback=True``; + 规则见 :func:`is_forward_status`。 + Args: target: 目标状态,必须在 ``ORDER_STATUSES`` 里。 reason: 作废原因(``target=void`` 时必填)。 - rollback: 目标不是「向前一步」时,必须显式置 True 才允许改。 + rollback: 目标不是「向前」时必须显式置 True 才允许改。 """ if target not in ORDER_STATUSES: raise CollabError(f"未知状态:{target}", code="status_unknown") @@ -454,16 +499,18 @@ def set_status( return {} from_status = order.status - if target != STATUS_VOID and target != NEXT_STATUS.get(from_status): + forward = is_forward_status(from_status, target) + if not forward: if not rollback: next_status = NEXT_STATUS.get(from_status) hint = ( - f"只能推进到「{ORDER_STATUS_LABELS[next_status]}」" + f"要往前的话下一步是「{ORDER_STATUS_LABELS[next_status]}」(也可以直接跳到更后面的状态)" if next_status - else "这单已经走到终点,只能作废" + else "这单已经走到终点" ) raise CollabError( - f"状态只能向前推进({hint});要回退请显式勾选「回退」", + f"不能把「{ORDER_STATUS_LABELS.get(from_status, from_status)}」改回" + f"「{ORDER_STATUS_LABELS[target]}」({hint});确实要回退请显式勾选「回退」", code="status_not_forward", ) if from_status == STATUS_VOID: @@ -494,7 +541,7 @@ def set_status( detail={ "from": from_status, "to": target, - "rollback": bool(rollback and target != NEXT_STATUS.get(from_status)), + "rollback": bool(not forward), "reason": order.void_reason, "order_no": order.order_no, }, @@ -505,6 +552,81 @@ def set_status( return {"from": from_status, "to": target} +def set_status_batch( + db: Session, + current: User | None, + orders: Sequence[HuyaExternalOrder], + target: str, + *, + reason: str = "", + rollback: bool = False, +) -> dict: + """批量改状态:勾选多行一次切到同一个状态(部分失败不影响其它行)。 + + 逐行用 SAVEPOINT 隔离(同 :func:`import_orders` 的做法):某一行因为前置校验没过 + (例如没主页截图不能进「已确认绑定」)只记在这一条的 ``message`` 里,整体不整体失败。 + 上限与批量验证一致(``PROBE_BATCH_MAX``)—— 一次别点太多,人会看不过来。 + """ + rows = list(orders) + if not rows: + raise CollabError("请先勾选要改状态的行", code="status_batch_empty") + if len(rows) > PROBE_BATCH_MAX: + raise CollabError( + f"一次最多改 {PROBE_BATCH_MAX} 行,请分批操作", + code="status_batch_too_large", + ) + + results: list[dict] = [] + updated = 0 + for order in rows: + try: + with db.begin_nested(): + change = set_status( + db, + current, + order, + target, + reason=reason, + rollback=rollback, + commit=False, + ) + except CollabError as exc: + results.append( + { + "id": int(order.id), + "order_no": order.order_no, + "ok": False, + "message": exc.detail, + } + ) + continue + if change: + updated += 1 + results.append( + { + "id": int(order.id), + "order_no": order.order_no, + "ok": True, + "message": ( + f"{ORDER_STATUS_LABELS.get(change['from'], change['from'])}" + f" → {ORDER_STATUS_LABELS.get(change['to'], change['to'])}" + if change + else "已经是这个状态" + ), + } + ) + db.commit() + for order in rows: + db.refresh(order) + return { + "total": len(rows), + "updated": updated, + "failed": len(rows) - updated, + "target": target, + "results": results, + } + + # -------------------------------------------------------------------------- # CK 写入口 # -------------------------------------------------------------------------- @@ -969,6 +1091,395 @@ def fetch_activity_gifts( return gifts, f"已拉取 {len(gifts)} 个礼包" +# -------------------------------------------------------------------------- +# 账号全生命周期(积分 / 兑换记录:抢购之后靠它判断到底兑换成功没有) +# -------------------------------------------------------------------------- + +ACTIVITY_QUERY_TIMEOUT_SECONDS = 15.0 + +VERDICT_SUCCESS = "success" +VERDICT_FAILED = "failed" +VERDICT_UNKNOWN = "unknown" + +VERDICT_LABELS = { + VERDICT_SUCCESS: "兑换成功", + VERDICT_FAILED: "兑换失败", + VERDICT_UNKNOWN: "无法判定", +} + +# metric.raw 里最多留多少条兑换记录(避免 JSON 列被几百条记录撑爆) +_METRIC_RECORD_LIMIT = 50 +# 兑换记录里跟订单礼包名比对时,只取有意义的长度(太短的名字容易误匹配) +_MIN_GIFT_MATCH_LENGTH = 2 + + +def _metric_message(text: Any, limit: int = 480) -> str: + piece = str(text or "").strip() + return piece if len(piece) <= limit else piece[: limit - 3] + "..." + + +def gift_matches_records( + records: Sequence[Mapping[str, Any]], gift_name: str +) -> list[dict[str, Any]]: + """在兑换记录里找这一单的礼包(名字完全相等或互相包含都算)。 + + 虎牙记录里的 ``prize_name`` 与活动列表里的名字基本一字不差,但偶尔带后缀/前缀, + 所以用「互相包含」宽松匹配;太短的名字(<2 字)不参与,免得 "礼包" 两个字匹配一堆。 + """ + wanted = str(gift_name or "").strip() + if len(wanted) < _MIN_GIFT_MATCH_LENGTH: + return [] + matched: list[dict[str, Any]] = [] + for record in records: + name = str(record.get("prize_name") or "").strip() + if not name: + continue + if name == wanted or wanted in name or name in wanted: + matched.append(dict(record)) + return matched + + +def _extract_score(resp: Any) -> tuple[int | None, int | None, int | None, str]: + """从 ``getUserScore`` 响应里取「可用 / 累计获得 / 已消耗」+ 可读结论。""" + if resp is None: + return None, None, None, "积分接口没有返回数据" + status = int(getattr(resp, "status", 0) or 0) + if status != 200: + msg = _clip(getattr(resp, "msg", "")) + return ( + None, + None, + None, + f"积分接口 status={status}{(':' + msg) if msg else ''}", + ) + available = getattr(resp, "available_score", None) + if available is None: + available = getattr(resp, "score", 0) + gained = int(getattr(resp, "newGainScore", 0) or 0) or int( + getattr(resp, "gainScore", 0) or 0 + ) + used = int(getattr(resp, "newUsedScore", 0) or 0) or int( + getattr(resp, "usedScore", 0) or 0 + ) + return int(available or 0), gained, used, "" + + +def _extract_records(resp: Any) -> tuple[list[dict[str, Any]], str]: + """从 ``getUserPrizeRecords`` 响应里取兑换记录列表 + 可读结论。""" + if resp is None: + return [], "兑换记录接口没有返回数据" + status = int(getattr(resp, "status", 0) or 0) + if status != 200: + msg = _clip(getattr(resp, "msg", "")) + return [], f"兑换记录接口 status={status}{(':' + msg) if msg else ''}" + data = resp.to_dict() or {} + return list(data.get("records") or []), "" + + +def serialize_metric( + metric: HuyaExternalOrderMetric, *, users: Mapping[int, str] | None = None +) -> dict: + """一条生命周期快照的输出(时间线用)。""" + names = users or {} + raw = metric.raw if isinstance(metric.raw, dict) else {} + matched = raw.get("matched") if isinstance(raw.get("matched"), list) else [] + return { + "id": int(metric.id), + "order_id": int(metric.order_id), + "order_no": metric.order_no or "", + "uid": metric.uid or "", + "sid": metric.sid, + "score": metric.score, + "gained_score": metric.gained_score, + "used_score": metric.used_score, + "records_total": int(metric.records_total or 0), + "records_matched": int(metric.records_matched or 0), + "verdict": metric.verdict or VERDICT_UNKNOWN, + "verdict_label": VERDICT_LABELS.get(metric.verdict, metric.verdict or ""), + "message": metric.message or "", + "source": metric.source or "manual", + "matched_preview": [ + { + "prize_name": str(item.get("prize_name") or ""), + "score": item.get("score"), + "status_label": str(item.get("status_label") or ""), + "order_id": str(item.get("order_id") or ""), + "exchange_time": item.get("exchange_time"), + } + for item in matched[:5] + ], + "created_by": metric.created_by, + "created_by_name": names.get(int(metric.created_by or 0), ""), + "created_at": metric.created_at, + } + + +def list_metrics( + db: Session, order: HuyaExternalOrder, *, limit: int = 100 +) -> list[HuyaExternalOrderMetric]: + """某个订单的生命周期快照(**倒序**:最近一次在前,界面直接铺时间线)。""" + return ( + db.query(HuyaExternalOrderMetric) + .filter(HuyaExternalOrderMetric.order_id == int(order.id)) + .order_by(HuyaExternalOrderMetric.id.desc()) + .limit(max(1, min(500, int(limit)))) + .all() + ) + + +def latest_metrics( + db: Session, orders: Sequence[HuyaExternalOrder] +) -> dict[int, HuyaExternalOrderMetric]: + """一次查出这批行各自「最近一条」快照(列表要显示最新积分,别按行发 SQL)。""" + ids = [int(order.id) for order in orders] + if not ids: + return {} + newest = ( + db.query(func.max(HuyaExternalOrderMetric.id).label("metric_id")) + .filter(HuyaExternalOrderMetric.order_id.in_(ids)) + .group_by(HuyaExternalOrderMetric.order_id) + .subquery() + ) + metric_ids = [row[0] for row in db.query(newest.c.metric_id).all()] + if not metric_ids: + return {} + rows = ( + db.query(HuyaExternalOrderMetric) + .filter(HuyaExternalOrderMetric.id.in_(metric_ids)) + .all() + ) + return {int(row.order_id): row for row in rows} + + +def query_order_activity( + db: Session, + current: User | None, + order: HuyaExternalOrder, + *, + client: Any | None = None, + source: str = "manual", + commit: bool = True, +) -> dict: + """查一次这个账号的**积分 + 兑换记录**,落一条生命周期快照,必要时自动改状态。 + + 判定规则(2026-10-16 用户口径): + + | 情况 | verdict | 业务状态 | + | --- | --- | --- | + | 兑换记录里有这一单的礼包 | ``success`` | 「已上脚本」的单 → 自动「已完成」 | + | 接口通了,但记录里没有这个礼包 | ``failed`` | 「已上脚本」的单 → 自动「兑换失败」 | + | 接口没通 / 这单没填礼包名 | ``unknown`` | **不动**(数据不够,不许瞎判) | + + 只有 ``已上脚本`` 的单会被自动改状态 —— 抢购是上脚本之后的事,其它阶段去改属于误判; + 被跳过时结论里会写清楚原因。每次调用都写一条 metric(哪怕接口失败), + 「积分变化 + 兑换记录」两条线索合起来才看得清有没有真的抢到。 + """ + if order.status == STATUS_VOID: + raise CollabError("已作废的单子不查积分/兑换记录", code="void_order") + uid = str(order.uid or "").strip() + cookie = str(order.cookie or "").strip() + if not uid or not cookie: + raise CollabError("这行还没有 CK,先贴一份再查积分/兑换记录", code="no_cookie") + + sid = resolve_gifts_sid(db) + activity_client = client or _build_probe_client() + score_resp: Any = None + records_resp: Any = None + errors: list[str] = [] + try: + score_resp = activity_client.query_user_score( + uid=int(uid), cookie=cookie, sid=sid, timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS + ) + except Exception as exc: # noqa: BLE001 一个接口炸了不影响另一个 + errors.append(f"积分接口异常:{type(exc).__name__}: {_clip(exc)}") + try: + records_resp = activity_client.get_user_prize_records( + uid=int(uid), cookie=cookie, sid=sid, timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS + ) + except Exception as exc: # noqa: BLE001 + errors.append(f"兑换记录接口异常:{type(exc).__name__}: {_clip(exc)}") + + score, gained, used, score_msg = _extract_score(score_resp) + records, records_msg = _extract_records(records_resp) + matched = gift_matches_records(records, order.gift_name) + + if not str(order.gift_name or "").strip(): + verdict = VERDICT_UNKNOWN + message = "这单没填礼包名,没法跟兑换记录比对" + elif records_msg or errors: + verdict = VERDICT_UNKNOWN + message = ( + ";".join([*errors, records_msg]).strip(";") or "兑换记录没取到,无法判定" + ) + elif matched: + verdict = VERDICT_SUCCESS + latest = matched[0] + message = ( + f"兑换记录里查到 {len(matched)} 条匹配「{order.gift_name}」" + f"(最近一条:{latest.get('status_label') or '状态未知'}" + f"{(',单号 ' + str(latest.get('order_id'))) if latest.get('order_id') else ''})" + ) + else: + verdict = VERDICT_FAILED + message = ( + f"兑换记录里没有「{order.gift_name}」" + f"(当前共 {len(records)} 条记录,都没有匹配上)" + ) + + auto_status = "" + if verdict == VERDICT_SUCCESS: + target = STATUS_DONE + elif verdict == VERDICT_FAILED: + target = STATUS_FAILED + else: + target = "" + if target and order.status == STATUS_SCRIPTED: + set_status(db, current, order, target, commit=False) + auto_status = target + elif target: + message = ( + f"{message}(当前状态是「{ORDER_STATUS_LABELS.get(order.status, order.status)}」," + f"只有「{ORDER_STATUS_LABELS[STATUS_SCRIPTED]}」的单才会按查询结果自动改状态)" + ) + + raw: dict[str, Any] = { + "verdict": verdict, + "score_available": score, + "score_gained": gained, + "score_used": used, + "records_count": len(records), + "matched": matched[:5], + "records": records[:_METRIC_RECORD_LIMIT], + } + if score_resp is not None: + try: + raw["score_response"] = score_resp.to_dict() + except Exception as exc: # noqa: BLE001 没有 to_dict 的假客户端(测试)就不存原始体 + logger.debug(f"[huya-collab] 积分原始体不入库:{type(exc).__name__}: {exc}") + + metric = HuyaExternalOrderMetric( + order_id=int(order.id), + order_no=order.order_no or "", + uid=uid, + sid=sid, + score=score, + gained_score=gained, + used_score=used, + records_total=len(records), + records_matched=len(matched), + verdict=verdict, + message=_metric_message( + ";".join(piece for piece in (score_msg, message) if piece) + ), + source=source, + raw=raw, + created_by=current.id if current else None, + ) + db.add(metric) + db.flush() + record_audit( + db, + current, + action="huya:collab:activity_query", + target=_audit_target(order), + detail={ + "order_no": order.order_no, + "uid": uid, + "sid": sid, + "source": source, + "score": score, + "records_total": len(records), + "records_matched": len(matched), + "verdict": verdict, + "auto_status": auto_status, + }, + ) + if commit: + db.commit() + db.refresh(order) + db.refresh(metric) + return { + "ok": verdict != VERDICT_UNKNOWN, + "order_id": int(order.id), + "order_no": order.order_no, + "uid": uid, + "sid": sid, + "verdict": verdict, + "verdict_label": VERDICT_LABELS.get(verdict, verdict), + "score": score, + "gained_score": gained, + "used_score": used, + "records_total": len(records), + "records_matched": len(matched), + "message": metric.message, + "auto_status": auto_status, + "auto_status_label": ORDER_STATUS_LABELS.get(auto_status, auto_status), + "status": order.status, + "status_label": ORDER_STATUS_LABELS.get(order.status, order.status), + "metric": serialize_metric(metric), + } + + +def query_activity_batch( + db: Session, + current: User | None, + orders: Sequence[HuyaExternalOrder], + *, + client: Any | None = None, + interval_seconds: float = PROBE_BATCH_INTERVAL_SECONDS, +) -> dict: + """勾选批量查询积分 / 兑换记录:**串行 + 间隔**(防风控),单条失败不影响其它。""" + rows = list(orders) + if not rows: + raise CollabError("请先勾选要查询的行", code="activity_batch_empty") + if len(rows) > PROBE_BATCH_MAX: + raise CollabError( + f"一次最多查 {PROBE_BATCH_MAX} 行,请分批查询", + code="activity_batch_too_large", + ) + + results: list[dict] = [] + for index, order in enumerate(rows): + if index and interval_seconds > 0: + time.sleep(interval_seconds) + try: + with db.begin_nested(): + results.append( + query_order_activity( + db, current, order, client=client, source="batch", commit=False + ) + ) + except CollabError as exc: + results.append( + { + "ok": False, + "order_id": int(order.id), + "order_no": order.order_no, + "verdict": VERDICT_UNKNOWN, + "verdict_label": VERDICT_LABELS[VERDICT_UNKNOWN], + "message": exc.detail, + "auto_status": "", + "status": order.status, + "status_label": ORDER_STATUS_LABELS.get(order.status, order.status), + } + ) + db.commit() + for order in rows: + db.refresh(order) + valid = [item for item in results if item.get("verdict") == VERDICT_SUCCESS] + failed = [item for item in results if item.get("verdict") == VERDICT_FAILED] + unknown = [item for item in results if item.get("verdict") == VERDICT_UNKNOWN] + auto_changed = [item for item in results if item.get("auto_status")] + return { + "total": len(results), + "success": len(valid), + "failed": len(failed), + "unknown": len(unknown), + "auto_changed": len(auto_changed), + "results": results, + } + + def preview_ck( ck_text: Any, *, @@ -1762,14 +2273,16 @@ def serialize_order( *, users: Mapping[int, str] | None = None, buyer_cluster: Mapping[str, Any] | None = None, + last_metric: HuyaExternalOrderMetric | None = None, ) -> dict: """把行对象转成接口输出。 图片存的是 object_key,URL 在这里现拼 —— 库里存 URL 会在换存储/改路径时 全部失效(order_site 踩过「存下来的签名链接过期后到处裂图」)。 - ``buyer_cluster`` = 同一买家的其它单(见 :func:`buyer_clusters`),由调用方 - 传进来(列表是一次查询算出整页的,别在这里按行查)。 + ``buyer_cluster`` = 同一买家的其它单(见 :func:`buyer_clusters`), + ``last_metric`` = 最近一次「积分 / 兑换记录」快照(见 :func:`latest_metrics`), + 都由调用方传进来(列表是一次查询算出整页的,别在这里按行查)。 """ names = users or {} bind_images = images_of(order, "bind_images") @@ -1833,6 +2346,16 @@ def serialize_order( "updated_at": order.updated_at, # 同一买家的其它单(软识别,只读派生;没有就是 null) "buyer_cluster": dict(buyer_cluster) if buyer_cluster else None, + # 最近一次「积分 / 兑换记录」快照(生命周期时间线在详情里,列表只带最新一条) + "last_score": last_metric.score if last_metric else None, + "last_metric_at": last_metric.created_at if last_metric else None, + "last_verdict": (last_metric.verdict or "") if last_metric else "", + "last_verdict_label": ( + VERDICT_LABELS.get(last_metric.verdict, last_metric.verdict or "") + if last_metric + else "" + ), + "last_metric_message": (last_metric.message or "") if last_metric else "", } diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 39e578a..871d0b8 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -32,11 +32,14 @@ import type { HuyaTaskItem, HuyaBrowserExchangeKit, HuyaCollabAuditItem, + HuyaCollabActivityBatchResult, + HuyaCollabActivityItem, HuyaCollabCkPreview, HuyaCollabCkRequest, HuyaCollabCkStatusRequest, HuyaCollabImagesRequest, HuyaCollabImportResult, + HuyaCollabMetric, HuyaCollabOrder, HuyaCollabOrderCreate, HuyaCollabOrderList, @@ -47,6 +50,8 @@ import type { HuyaCollabShopCreate, HuyaCollabShopUpdate, HuyaCollabStatusRequest, + HuyaCollabStatusBatchRequest, + HuyaCollabStatusBatchResult, HuyaCollabSummary, HuyaCollabVerifyResult, HuyaExchangeTargetKey, @@ -252,6 +257,26 @@ export const huyaApi = { /** 操作时间线(直接读 audit_logs) */ collabOrderTimeline: (id: number) => api.get(`/huya/external-orders/${id}/timeline`), + /** 勾选多行一次改业务状态(单个也走这里;回退要带 rollback) */ + batchUpdateCollabOrderStatus: (data: HuyaCollabStatusBatchRequest) => + api.post( + '/huya/external-orders/status/batch', + data, + ), + /** 查这一单的积分 + 兑换记录(写一条生命周期快照,已上脚本的单会按结果自动改状态) */ + queryCollabOrderActivity: (id: number) => + api.post( + `/huya/external-orders/${id}/activity/query`, + ), + /** 勾选多行查询积分 + 兑换记录(串行带间隔) */ + batchQueryCollabOrderActivity: (ids: number[]) => + api.post( + '/huya/external-orders/activity/query', + { ids }, + ), + /** 这一单的「账号全生命周期」时间线(每次查询的快照,新→旧) */ + collabOrderMetrics: (id: number) => + api.get(`/huya/external-orders/${id}/metrics`), /** 删除一行(只给运营:huya:collab:manage) */ deleteCollabOrder: (id: number) => api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>( diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index bf264cc..1881755 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1199,7 +1199,15 @@ export interface AccountExchangeRecords { // ---- 虎牙外部 CK 协作台账(/api/huya/external-orders,见 docs/设计-虎牙外部CK协作台账.md)---- /** 业务状态:一单卡在哪一步(void 是旁路) */ -export type HuyaCollabStatus = 'pending' | 'bound' | 'recharged' | 'scripted' | 'done' | 'void'; +export type HuyaCollabStatus = + | 'pending' + | 'bound' + | 'recharged' + | 'scripted' + | 'done' + /** 旁路终态:抢购后查兑换记录没查到这一单的礼包(人工确认后可作废或回退重试) */ + | 'failed' + | 'void'; /** 凭据状态:CK 本身还有没有效 */ export type HuyaCollabCkStatus = 'unverified' | 'valid' | 'invalid' | 'disabled'; @@ -1264,6 +1272,12 @@ export interface HuyaCollabOrder { * 只有真的 ≥2 单才有值,否则是 null。 */ buyer_cluster: HuyaCollabBuyerCluster | null; + /** 最近一次「查积分 + 兑换记录」的快照(完整时间线走 GET /{id}/metrics) */ + last_score: number | null; + last_metric_at: string | null; + last_verdict: HuyaCollabVerdict | ''; + last_verdict_label: string; + last_metric_message: string; } /** 「同一买家的其它单」里的一条 */ @@ -1288,6 +1302,98 @@ export interface HuyaCollabBuyerCluster { others: HuyaCollabBuyerOrder[]; } +/** 系统与接口的判定结论(人工不写,只读) */ +export type HuyaCollabVerdict = 'success' | 'failed' | 'unknown'; + +/** 「账号全生命周期」里匹配到的兑换记录 */ +export interface HuyaCollabMetricMatched { + prize_name: string; + score: number | null; + status_label: string; + order_id: string; + exchange_time: number | null; +} + +/** 一次「查积分 + 兑换记录」的快照(只追加,不更新) */ +export interface HuyaCollabMetric { + id: number; + order_id: number; + order_no: string; + uid: string; + sid: number | null; + score: number | null; + gained_score: number | null; + used_score: number | null; + records_total: number; + records_matched: number; + verdict: HuyaCollabVerdict; + verdict_label: string; + message: string; + /** manual(人工点)/ batch(勾选批量) */ + source: string; + matched_preview: HuyaCollabMetricMatched[]; + created_by: number | null; + created_by_name: string; + created_at: string | null; +} + +/** 单个订单查询积分/兑换记录的结果 */ +export interface HuyaCollabActivityItem { + ok: boolean; + order_id: number; + order_no: string; + verdict: HuyaCollabVerdict; + verdict_label: string; + score: number | null; + gained_score: number | null; + used_score: number | null; + records_total: number; + records_matched: number; + message: string; + /** 查询后自动改成的状态(空 = 没改:只有「已上脚本」的单会自动改) */ + auto_status: HuyaCollabStatus | ''; + auto_status_label: string; + status: HuyaCollabStatus; + status_label: string; +} + +/** 批量查询汇总 */ +export interface HuyaCollabActivityBatchResult { + total: number; + success: number; + failed: number; + unknown: number; + auto_changed: number; + results: HuyaCollabActivityItem[]; +} + +/** 批量改状态请求(单个也走这里) */ +export interface HuyaCollabStatusBatchRequest { + ids: number[]; + status: HuyaCollabStatus; + reason?: string; + /** 目标不是「向前」时必须显式 true */ + rollback?: boolean; +} + +/** 批量改状态里的一行结论 */ +export interface HuyaCollabStatusBatchItem { + id: number; + order_no: string; + ok: boolean; + message: string; +} + +/** 批量改状态汇总 */ +export interface HuyaCollabStatusBatchResult { + total: number; + updated: number; + failed: number; + target: HuyaCollabStatus; + target_label: string; + results: HuyaCollabStatusBatchItem[]; +} + export interface HuyaCollabOrderList { items: HuyaCollabOrder[]; total: number; diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 8d00bfb..4637c91 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -8,15 +8,15 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, App, AutoComplete, Button, Card, Col, Descriptions, Divider, Drawer, Dropdown, Empty, Form, - Image, Input, Modal, Popconfirm, Popover, Progress, Row, Select, Space, Statistic, Switch, Table, - Tag, Timeline, Tooltip, Typography, Upload, + Alert, App, AutoComplete, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown, + Empty, Form, Image, Input, Modal, Popconfirm, Popover, Progress, Row, Select, Space, Statistic, + Switch, Table, Tag, Timeline, Tooltip, Typography, Upload, } from 'antd'; import type { TableProps } from 'antd'; import { CheckCircleOutlined, CloudUploadOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined, - StopOutlined, SwapOutlined, UndoOutlined, + StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined, } from '@ant-design/icons'; import { fileApi, @@ -25,6 +25,7 @@ import { type HuyaCollabCkPreview, type HuyaCollabGift, type HuyaCollabImageSlot, + type HuyaCollabMetric, type HuyaCollabOrder, type HuyaCollabOrderQuery, type HuyaCollabOrderUpdate, @@ -44,13 +45,39 @@ const { Text, Paragraph } = Typography; const STATUS_META: Record = { pending: { label: '待处理', color: 'default' }, - bound: { label: '已绑定', color: 'blue' }, + // 人工核对过「探针读到的绑定角色」与「主页截图」一致 + bound: { label: '已确认绑定', color: 'blue' }, recharged: { label: '已充值', color: 'geekblue' }, scripted: { label: '已上脚本', color: 'purple' }, done: { label: '已完成', color: 'success' }, + // 旁路终态:抢购后查兑换记录没查到这一单的礼包 + failed: { label: '兑换失败', color: 'red' }, void: { label: '已作废', color: 'error' }, }; +/** 判定结论的配色(生命周期时间线用) */ +const VERDICT_META: Record = { + success: { label: '兑换成功', color: 'success' }, + failed: { label: '兑换失败', color: 'error' }, + unknown: { label: '无法判定', color: 'default' }, +}; + +/** 「改状态」能选的目标(顺序 = 业务流程顺序,回退要显式勾选) */ +const STATUS_ORDER_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [ + { value: 'pending', label: '待处理' }, + { value: 'bound', label: '已确认绑定' }, + { value: 'recharged', label: '已充值' }, + { value: 'scripted', label: '已上脚本' }, + { value: 'done', label: '已完成' }, + { value: 'failed', label: '兑换失败' }, + { value: 'void', label: '已作废' }, +]; + +/** 「回退」能选的目标:只在流程链里选(要进「已作废 / 兑换失败」走「改状态」或「作废」入口) */ +const FLOW_STATUS_OPTIONS = STATUS_ORDER_OPTIONS.filter( + (option) => option.value !== 'failed' && option.value !== 'void', +); + const CK_META: Record = { unverified: { label: '未验证', color: 'default' }, valid: { label: '有效', color: 'success' }, @@ -293,11 +320,21 @@ export default function HuyaCollabPage() { } | null>(null); const [ckTarget, setCkTarget] = useState<{ order: HuyaCollabOrder; text: string } | null>(null); + // 单行状态弹窗:set=人工往前切(可跳步)/ rollback=回退 / void=作废 const [statusAction, setStatusAction] = useState<{ - order: HuyaCollabOrder; mode: 'void' | 'rollback'; target?: HuyaCollabStatus; + order: HuyaCollabOrder; mode: 'void' | 'rollback' | 'set'; target?: HuyaCollabStatus; } | null>(null); const [statusReason, setStatusReason] = useState(''); const [rollbackTarget, setRollbackTarget] = useState('pending'); + // 批量改状态(勾选多行一次切) + const [batchStatusOpen, setBatchStatusOpen] = useState(false); + const [batchStatusTarget, setBatchStatusTarget] = useState('bound'); + const [batchStatusReason, setBatchStatusReason] = useState(''); + const [batchStatusRollback, setBatchStatusRollback] = useState(false); + // 积分 / 兑换记录(「账号全生命周期」) + const [querying, setQuerying] = useState(false); + const [metrics, setMetrics] = useState([]); + const [metricsLoading, setMetricsLoading] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -411,6 +448,106 @@ export default function HuyaCollabPage() { [runCkPreview], ); + /** 勾选多行一次改状态(往前可跳步;回退要在弹窗里勾「回退」) */ + const submitBatchStatus = async () => { + setBusy(true); + try { + const result = await huyaApi.batchUpdateCollabOrderStatus({ + ids: selectedIds, + status: batchStatusTarget, + reason: batchStatusReason, + rollback: batchStatusRollback, + }); + const failures = result.results.filter((item) => !item.ok); + if (failures.length === 0) { + message.success(`已把 ${result.updated} 条改成「${result.target_label}」`); + } else { + modal.info({ + title: `改状态完成:成功 ${result.updated} / 未通过 ${result.failed}(目标「${result.target_label}」)`, + width: 640, + content: ( + + {failures.map((item) => ( + + {item.order_no}:{item.message} + + ))} + + ), + }); + } + setBatchStatusOpen(false); + setSelectedIds([]); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setBusy(false); + } + }; + + /** 查这一单的积分 + 兑换记录(写一条生命周期快照;已上脚本的单会按结果自动改状态) */ + const queryActivityOne = async (row: HuyaCollabOrder) => { + setQuerying(true); + try { + const result = await huyaApi.queryCollabOrderActivity(row.id); + message[result.verdict === 'success' ? 'success' : result.verdict === 'failed' ? 'warning' : 'info']( + `${row.order_no}:${result.verdict_label} · ${result.message}` + + (result.auto_status ? `;状态已自动改为「${result.auto_status_label}」` : ''), + ); + void load(); + if (detail?.id === row.id) { + await openDetail(await huyaApi.getCollabOrder(row.id)); + } + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setQuerying(false); + } + }; + + /** 勾选批量查询(串行带间隔,防风控) */ + const queryActivitySelected = async () => { + if (selectedIds.length === 0) return; + setQuerying(true); + try { + const result = await huyaApi.batchQueryCollabOrderActivity(selectedIds); + modal.info({ + title: `查询完成:成功 ${result.success} / 失败 ${result.failed} / 无法判定 ${result.unknown}` + + (result.auto_changed ? `(${result.auto_changed} 条按结果自动改状态)` : ''), + width: 760, + content: ( + + {result.results.map((item) => ( + + {item.order_no}:{item.verdict_label} · {item.message} + {item.auto_status ? `;状态已改为「${item.auto_status_label}」` : ''} + + ))} + + ), + }); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setQuerying(false); + } + }; + + /** 详情里的「账号全生命周期」时间线 */ + const loadMetrics = async (orderId: number) => { + setMetrics([]); + setMetricsLoading(true); + try { + setMetrics(await huyaApi.collabOrderMetrics(orderId)); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setMetricsLoading(false); + } + }; + const patchOrder = async (row: HuyaCollabOrder, payload: HuyaCollabOrderUpdate) => { try { const updated = await huyaApi.updateCollabOrder(row.id, { ...payload, expected_version: row.version }); @@ -433,6 +570,7 @@ export default function HuyaCollabPage() { }); setTimeline([]); setTimelineLoading(true); + void loadMetrics(row.id); try { setTimeline(await huyaApi.collabOrderTimeline(row.id)); } catch (e: unknown) { @@ -523,22 +661,36 @@ export default function HuyaCollabPage() { } }; + /** + * 单行状态弹窗的提交(三种模式共用): + * - `void`:作废(必须填原因); + * - `rollback`:回退到选定状态(`rollback=true`,会清掉后面阶段的时间戳); + * - `set`:人工往前切,允许跳步(如 待处理 → 已上脚本),不用勾回退。 + */ const submitStatusAction = async () => { if (!statusAction) return; const { order, mode, target } = statusAction; - if (mode === 'void' && !statusReason.trim()) { + const nextStatus: HuyaCollabStatus = mode === 'void' ? 'void' : (target ?? rollbackTarget); + // 服务层对「作废」一律要求原因(不管从哪个入口进来的) + if (nextStatus === 'void' && !statusReason.trim()) { message.warning('作废必须填原因'); return; } setBusy(true); try { applyUpdated(await huyaApi.updateCollabOrderStatus(order.id, { - status: mode === 'void' ? 'void' : (target ?? rollbackTarget), + status: nextStatus, reason: statusReason, rollback: mode === 'rollback', expected_version: order.version, })); - message.success(mode === 'void' ? '已作废' : '已回退'); + message.success( + mode === 'void' + ? '已作废' + : mode === 'rollback' + ? '已回退' + : `已改为「${STATUS_META[nextStatus].label}」`, + ); setStatusAction(null); setStatusReason(''); void load(); @@ -929,6 +1081,24 @@ export default function HuyaCollabPage() { ), }, + { + title: '积分', width: 130, + render: (_, row) => { + if (!row.last_metric_at) { + return 还没查; + } + const verdict = VERDICT_META[row.last_verdict]; + return ( + + + {row.last_score ?? '-'} + {verdict && {verdict.label}} + {formatTime(row.last_metric_at)} + + + ); + }, + }, { title: '最后改动', width: 160, render: (_, row) => ( @@ -948,10 +1118,12 @@ export default function HuyaCollabPage() { menu={{ items: [ { key: 'verify', label: '验证 CK(只读探针)', icon: }, + { key: 'activity', label: '查积分 / 兑换记录', icon: , disabled: !row.has_cookie }, { key: 'ck', label: '贴 / 换 CK', icon: }, { key: 'rebuild', label: '从 App 原始凭据重建', icon: , disabled: !row.has_raw_cookie }, { key: 'disable', label: row.ck_status === 'disabled' ? '恢复这份 CK' : '停用这份 CK', icon: }, { type: 'divider' }, + { key: 'set', label: '改状态…', icon: }, { key: 'rollback', label: '回退状态…', icon: }, { key: 'void', label: '作废…', icon: , danger: true }, ...(canManage @@ -960,9 +1132,15 @@ export default function HuyaCollabPage() { ], onClick: ({ key }) => { if (key === 'verify') void verifyOne(row); + if (key === 'activity') void queryActivityOne(row); if (key === 'ck') setCkTarget({ order: row, text: '' }); if (key === 'rebuild') setCkTarget({ order: row, text: '' }); if (key === 'disable') void toggleCkDisabled(row); + if (key === 'set') { + setRollbackTarget(row.next_status ?? row.status); + setStatusReason(''); + setStatusAction({ order: row, mode: 'set' }); + } if (key === 'rollback') { setRollbackTarget('pending'); setStatusReason(''); @@ -993,32 +1171,40 @@ export default function HuyaCollabPage() { return ( + {/* 卡片用 flex 平分:状态多了一个「兑换失败」,固定 span=4 会换行参差不齐 */} - - + + - + - + - + - + - + + + + + + @@ -1068,6 +1254,35 @@ export default function HuyaCollabPage() { 批量验证 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''} )} + {canEdit && ( + // 勾选多行一次改状态(往前可跳步;回退在弹窗里勾「回退」) + ({ + key: option.value, + label: option.label, + })), + onClick: ({ key }) => { + setBatchStatusTarget(key as HuyaCollabStatus); + setBatchStatusReason(''); + setBatchStatusRollback(false); + setBatchStatusOpen(true); + }, + }} + > + + + )} + {canEdit && ( + + )} {canEdit && ( )} + + )} + + 每次查询都会追加一条快照(只追加、不修改);「已上脚本」的单会按结论自动改成「已完成」或「兑换失败」。 + + + {!detail.has_cookie && ( + 这单还没贴 CK,查不了积分 / 兑换记录。 + )} + {metrics.length === 0 && !metricsLoading && ( + + )} + ({ + color: item.verdict === 'success' ? 'green' : item.verdict === 'failed' ? 'red' : 'gray', + children: ( + + + {item.verdict_label} + 积分 {item.score ?? '-'} + + 获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'} + {' · '}兑换记录 {item.records_total} 条(命中 {item.records_matched}) + + + {item.message && ( + + {item.message} + + )} + {item.matched_preview.length > 0 && ( + + {item.matched_preview.map((matched, index) => ( + + {matched.prize_name || '(无礼包名)'} + {' '} + + {matched.status_label} + {matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''} + {matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''} + + + ))} + + )} + + {formatTime(item.created_at)} · {item.created_by_name || '系统'} + {' · '}{item.source === 'batch' ? '批量查询' : '手动查询'} + + + ), + }))} + /> )} @@ -1576,34 +1862,77 @@ export default function HuyaCollabPage() { setStatusAction(null)} onOk={() => void submitStatusAction()} confirmLoading={busy} okButtonProps={{ danger: statusAction?.mode === 'void' }} - okText={statusAction?.mode === 'void' ? '作废' : '回退'} + okText={statusAction?.mode === 'void' ? '作废' : statusAction?.mode === 'set' ? '改状态' : '回退'} destroyOnHidden > - {statusAction?.mode === 'rollback' && ( + {statusAction && statusAction.mode !== 'void' && ( setBatchStatusTarget(value)} + options={STATUS_ORDER_OPTIONS} + /> + setBatchStatusReason(event.target.value)} + placeholder={batchStatusTarget === 'void' ? '作废原因(必填)' : '改状态说明(可选)'} /> + setBatchStatusRollback(event.target.checked)}> + 回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待处理) + + + 逐行独立提交:某一行前置校验没过(例如没主页截图进不了「已确认绑定」)只跳过那一行, + 结果会在弹窗里逐条列出来。一次最多 50 行。 + + + +