feat(huya): 协作台账状态机新口径(跳步/已确认绑定/兑换失败)+ 账号全生命周期快照(迁移 0059)
This commit is contained in:
+141
-15
@@ -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` 可统计 |
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""列表响应:共享视图,带上总数供分页使用。"""
|
||||
|
||||
@@ -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 "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user