feat(huya): 协作台账状态机新口径(跳步/已确认绑定/兑换失败)+ 账号全生命周期快照(迁移 0059)

This commit is contained in:
yml2213
2026-10-16 22:00:34 +08:00
parent aac264b639
commit e8abd60734
11 changed files with 1897 additions and 84 deletions
+141 -15
View File
@@ -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` 可统计 |
+369 -15
View File
@@ -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] = []
+1 -1
View File
@@ -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 = (
@@ -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)
+53
View File
@@ -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
)
+115 -2
View File
@@ -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,
+108
View File
@@ -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):
"""列表响应:共享视图,带上总数供分页使用。"""
+549 -26
View File
@@ -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 "",
}
+25
View File
@@ -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<HuyaCollabAuditItem[], HuyaCollabAuditItem[]>(`/huya/external-orders/${id}/timeline`),
/** 勾选多行一次改业务状态(单个也走这里;回退要带 rollback) */
batchUpdateCollabOrderStatus: (data: HuyaCollabStatusBatchRequest) =>
api.post<HuyaCollabStatusBatchResult, HuyaCollabStatusBatchResult>(
'/huya/external-orders/status/batch',
data,
),
/** 查这一单的积分 + 兑换记录(写一条生命周期快照,已上脚本的单会按结果自动改状态) */
queryCollabOrderActivity: (id: number) =>
api.post<HuyaCollabActivityItem, HuyaCollabActivityItem>(
`/huya/external-orders/${id}/activity/query`,
),
/** 勾选多行查询积分 + 兑换记录(串行带间隔) */
batchQueryCollabOrderActivity: (ids: number[]) =>
api.post<HuyaCollabActivityBatchResult, HuyaCollabActivityBatchResult>(
'/huya/external-orders/activity/query',
{ ids },
),
/** 这一单的「账号全生命周期」时间线(每次查询的快照,新→旧) */
collabOrderMetrics: (id: number) =>
api.get<HuyaCollabMetric[], HuyaCollabMetric[]>(`/huya/external-orders/${id}/metrics`),
/** 删除一行(只给运营:huya:collab:manage) */
deleteCollabOrder: (id: number) =>
api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>(
+107 -1
View File
@@ -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;
+353 -24
View File
@@ -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<HuyaCollabStatus, { label: string; color: string }> = {
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<string, { label: string; color: string }> = {
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<string, { label: string; color: string }> = {
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<HuyaCollabStatus>('pending');
// 批量改状态(勾选多行一次切)
const [batchStatusOpen, setBatchStatusOpen] = useState(false);
const [batchStatusTarget, setBatchStatusTarget] = useState<HuyaCollabStatus>('bound');
const [batchStatusReason, setBatchStatusReason] = useState('');
const [batchStatusRollback, setBatchStatusRollback] = useState(false);
// 积分 / 兑换记录(「账号全生命周期」)
const [querying, setQuerying] = useState(false);
const [metrics, setMetrics] = useState<HuyaCollabMetric[]>([]);
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: (
<Space direction="vertical" style={{ width: '100%' }}>
{failures.map((item) => (
<Text key={item.id} type="danger">
{item.order_no}:{item.message}
</Text>
))}
</Space>
),
});
}
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: (
<Space direction="vertical" style={{ width: '100%' }}>
{result.results.map((item) => (
<Text key={item.order_id} type={item.verdict === 'failed' ? 'danger' : undefined}>
{item.order_no}:{item.verdict_label} · {item.message}
{item.auto_status ? `;状态已改为「${item.auto_status_label}」` : ''}
</Text>
))}
</Space>
),
});
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() {
</Space>
),
},
{
title: '积分', width: 130,
render: (_, row) => {
if (!row.last_metric_at) {
return <Text type="secondary" style={{ fontSize: 12 }}>还没查</Text>;
}
const verdict = VERDICT_META[row.last_verdict];
return (
<Tooltip title={`${row.last_metric_message || ''}(${formatTime(row.last_metric_at)})`}>
<Space direction="vertical" size={0}>
<Text strong>{row.last_score ?? '-'}</Text>
{verdict && <Tag color={verdict.color} style={{ marginTop: 2 }}>{verdict.label}</Tag>}
<Text type="secondary" style={{ fontSize: 12 }}>{formatTime(row.last_metric_at)}</Text>
</Space>
</Tooltip>
);
},
},
{
title: '最后改动', width: 160,
render: (_, row) => (
@@ -948,10 +1118,12 @@ export default function HuyaCollabPage() {
menu={{
items: [
{ key: 'verify', label: '验证 CK(只读探针)', icon: <SafetyCertificateOutlined /> },
{ key: 'activity', label: '查积分 / 兑换记录', icon: <SyncOutlined />, disabled: !row.has_cookie },
{ key: 'ck', label: '贴 / 换 CK', icon: <LinkOutlined /> },
{ key: 'rebuild', label: '从 App 原始凭据重建', icon: <ReloadOutlined />, disabled: !row.has_raw_cookie },
{ key: 'disable', label: row.ck_status === 'disabled' ? '恢复这份 CK' : '停用这份 CK', icon: <StopOutlined /> },
{ type: 'divider' },
{ key: 'set', label: '改状态…', icon: <CheckCircleOutlined /> },
{ key: 'rollback', label: '回退状态…', icon: <UndoOutlined /> },
{ key: 'void', label: '作废…', icon: <StopOutlined />, 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 (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{/* 卡片用 flex 平分:状态多了一个「兑换失败」,固定 span=4 会换行参差不齐 */}
<Row gutter={12}>
<Col span={4}><Card size="small"><Statistic title="全部" value={summary?.total ?? 0} /></Card></Col>
<Col span={4}>
<Col flex="1"><Card size="small"><Statistic title="全部" value={summary?.total ?? 0} /></Card></Col>
<Col flex="1">
<Card size="small">
<Statistic title="待处理" value={summary?.by_status.pending ?? 0} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
<Col span={4}>
<Col flex="1">
<Card size="small">
<Statistic title="已绑定" value={summary?.by_status.bound ?? 0} valueStyle={{ color: '#1677ff' }} />
<Statistic title={STATUS_META.bound.label} value={summary?.by_status.bound ?? 0}
valueStyle={{ color: '#1677ff' }} />
</Card>
</Col>
<Col span={4}>
<Col flex="1">
<Card size="small">
<Statistic title="已充值 / 已上脚本"
value={(summary?.by_status.recharged ?? 0) + (summary?.by_status.scripted ?? 0)}
valueStyle={{ color: '#722ed1' }} />
</Card>
</Col>
<Col span={4}>
<Col flex="1">
<Card size="small">
<Statistic title="已完成" value={finished} valueStyle={{ color: '#52c41a' }} />
<Progress percent={summary?.total ? Math.round((finished / summary.total) * 100) : 0} size="small" />
</Card>
</Col>
<Col span={4}>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.failed.label} value={summary?.by_status.failed ?? 0}
valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title="CK 失效" value={summary?.ck_invalid ?? 0} valueStyle={{ color: '#ff4d4f' }} />
</Card>
@@ -1068,6 +1254,35 @@ export default function HuyaCollabPage() {
批量验证 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && (
// 勾选多行一次改状态(往前可跳步;回退在弹窗里勾「回退」)
<Dropdown
disabled={selectedIds.length === 0 || busy}
menu={{
items: STATUS_ORDER_OPTIONS.map((option) => ({
key: option.value,
label: option.label,
})),
onClick: ({ key }) => {
setBatchStatusTarget(key as HuyaCollabStatus);
setBatchStatusReason('');
setBatchStatusRollback(false);
setBatchStatusOpen(true);
},
}}
>
<Button icon={<SwapOutlined />} disabled={selectedIds.length === 0 || busy}>
批量改状态{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
</Dropdown>
)}
{canEdit && (
<Button icon={<SyncOutlined />} disabled={selectedIds.length === 0 || busy}
loading={querying}
onClick={() => void queryActivitySelected()}>
批量查积分/兑换{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && (
<Button icon={<FileExcelOutlined />} onClick={() => { setImportResult(null); setImportOpen(true); }}>
粘贴导入
@@ -1280,13 +1495,22 @@ export default function HuyaCollabPage() {
)}
{canEdit && (
<Space>
<Space wrap>
{detail.next_status && (
<Button icon={<CheckCircleOutlined />} disabled={busy}
onClick={() => void advance(detail)}>
标记为「{STATUS_META[detail.next_status].label}」
</Button>
)}
<Button icon={<SwapOutlined />} disabled={busy}
onClick={() => {
// 默认给「下一步」,终点态(已完成 / 已作废 / 兑换失败)退回当前状态,让人自己挑
setRollbackTarget(detail.next_status ?? detail.status);
setStatusReason('');
setStatusAction({ order: detail, mode: 'set' });
}}>
改状态…
</Button>
<Button icon={<UndoOutlined />} disabled={busy}
onClick={() => { setRollbackTarget('pending'); setStatusReason(''); setStatusAction({ order: detail, mode: 'rollback' }); }}>
回退状态
@@ -1321,6 +1545,68 @@ export default function HuyaCollabPage() {
),
}))}
/>
<Divider style={{ margin: '4px 0' }}>账号全生命周期(积分 / 兑换记录)</Divider>
<Space wrap size={8}>
{canEdit && (
<Button icon={<SyncOutlined />} disabled={busy || querying || !detail.has_cookie}
loading={querying}
onClick={() => void queryActivityOne(detail)}>
查一次积分 / 兑换记录
</Button>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
每次查询都会追加一条快照(只追加、不修改);「已上脚本」的单会按结论自动改成「已完成」或「兑换失败」。
</Text>
</Space>
{!detail.has_cookie && (
<Text type="warning" style={{ fontSize: 12 }}>这单还没贴 CK,查不了积分 / 兑换记录。</Text>
)}
{metrics.length === 0 && !metricsLoading && (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有查询快照" />
)}
<Timeline
pending={metricsLoading ? '查询中…' : undefined}
items={metrics.map((item) => ({
color: item.verdict === 'success' ? 'green' : item.verdict === 'failed' ? 'red' : 'gray',
children: (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space size={6} wrap>
<Tag color={VERDICT_META[item.verdict]?.color}>{item.verdict_label}</Tag>
<Text strong>积分 {item.score ?? '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'}
{' · '}兑换记录 {item.records_total} 条(命中 {item.records_matched})
</Text>
</Space>
{item.message && (
<Text type={item.verdict === 'failed' ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
{item.message}
</Text>
)}
{item.matched_preview.length > 0 && (
<Space direction="vertical" size={0}>
{item.matched_preview.map((matched, index) => (
<Text key={`${matched.order_id}-${index}`} style={{ fontSize: 12 }}>
<Text code style={{ fontSize: 12 }}>{matched.prize_name || '(无礼包名)'}</Text>
{' '}
<Text type="secondary" style={{ fontSize: 12 }}>
{matched.status_label}
{matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''}
{matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''}
</Text>
</Text>
))}
</Space>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(item.created_at)} · {item.created_by_name || '系统'}
{' · '}{item.source === 'batch' ? '批量查询' : '手动查询'}
</Text>
</Space>
),
}))}
/>
</Space>
)}
</Drawer>
@@ -1576,34 +1862,77 @@ export default function HuyaCollabPage() {
<Modal
title={statusAction?.mode === 'void'
? `作废订单 ${statusAction.order.order_no}`
: `回退订单 ${statusAction?.order.order_no ?? ''}`}
: statusAction?.mode === 'set'
? `改状态:订单 ${statusAction.order.order_no}`
: `回退订单 ${statusAction?.order.order_no ?? ''}`}
open={!!statusAction}
onCancel={() => setStatusAction(null)}
onOk={() => void submitStatusAction()}
confirmLoading={busy}
okButtonProps={{ danger: statusAction?.mode === 'void' }}
okText={statusAction?.mode === 'void' ? '作废' : '回退'}
okText={statusAction?.mode === 'void' ? '作废' : statusAction?.mode === 'set' ? '改状态' : '回退'}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{statusAction?.mode === 'rollback' && (
{statusAction && statusAction.mode !== 'void' && (
<Select
style={{ width: '100%' }}
value={rollbackTarget}
onChange={(value: HuyaCollabStatus) => setRollbackTarget(value)}
options={(['pending', 'bound', 'recharged', 'scripted'] as HuyaCollabStatus[]).map((key) => ({
value: key, label: STATUS_META[key].label,
}))}
options={statusAction.mode === 'set' ? STATUS_ORDER_OPTIONS : FLOW_STATUS_OPTIONS}
/>
)}
<Input.TextArea rows={2} value={statusReason} onChange={(event) => setStatusReason(event.target.value)}
placeholder={statusAction?.mode === 'void' ? '作废原因(必填)' : '回退说明(可选)'} />
placeholder={
statusAction?.mode === 'void'
? '作废原因(必填)'
: rollbackTarget === 'void'
? '作废原因(必填)'
: statusAction?.mode === 'rollback'
? '回退说明(可选)'
: '改状态说明(可选)'
} />
{statusAction?.mode === 'set' && (
<Text type="secondary">
人工切换允许往前跳步(比如 待处理 → 已上脚本),不用一步一步点;
切到「已确认绑定」要求先有绑定角色 + 主页截图,切到「已作废」必须填原因。
</Text>
)}
{statusAction?.mode === 'rollback' && (
<Text type="secondary">回退会清掉后面阶段的时间戳,并在审计里留痕。</Text>
)}
</Space>
</Modal>
<Modal
title={`批量改状态(已选 ${selectedIds.length} 条)`}
open={batchStatusOpen}
onCancel={() => setBatchStatusOpen(false)}
onOk={() => void submitBatchStatus()}
confirmLoading={busy}
okText={`改成「${STATUS_META[batchStatusTarget]?.label ?? ''}」`}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Select
style={{ width: '100%' }}
value={batchStatusTarget}
onChange={(value: HuyaCollabStatus) => setBatchStatusTarget(value)}
options={STATUS_ORDER_OPTIONS}
/>
<Input.TextArea rows={2} value={batchStatusReason}
onChange={(event) => setBatchStatusReason(event.target.value)}
placeholder={batchStatusTarget === 'void' ? '作废原因(必填)' : '改状态说明(可选)'} />
<Checkbox checked={batchStatusRollback} onChange={(event) => setBatchStatusRollback(event.target.checked)}>
回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待处理)
</Checkbox>
<Text type="secondary">
逐行独立提交:某一行前置校验没过(例如没主页截图进不了「已确认绑定」)只跳过那一行,
结果会在弹窗里逐条列出来。一次最多 50 行。
</Text>
</Space>
</Modal>
<HuyaCollabShopModal
open={shopModalOpen}
shops={shops}