fix(huya): 改状态时自动校验 CK

This commit is contained in:
yml
2026-11-14 10:22:18 +08:00
parent 5b117e7641
commit b4801336c5
4 changed files with 245 additions and 14 deletions
+92 -2
View File
@@ -39,6 +39,7 @@ from web.backend.schemas import (
HuyaExternalOrderOut,
HuyaExternalOrderRechargeOut,
HuyaExternalOrderStatusBatchRequest,
HuyaExternalOrderStatusRequest,
HuyaExternalOrderUpdate,
)
from web.backend.services import huya_collab_service as collab
@@ -371,6 +372,61 @@ class TestCollabStatusMachine(TestCollabBase):
)
assert order.status == collab.STATUS_TO_RECHARGE
def test_status_change_auto_verifies_ck_for_to_recharge(self):
"""工作台改状态时不再要求用户先手工点「验证 CK」。"""
order = self._create(
order_no="AUTO-STATUS-CK",
gift_name="不朽伊人大礼包",
required_points=8000,
home_images=["collab/2026/10/16/home01.png"],
)
collab.set_ck(self.db, self.user, order, fake_ck("1199647001234"))
assert order.ck_status == "unverified"
assert order.bind_game_role == ""
collab.set_status(
self.db,
self.user,
order,
collab.STATUS_TO_RECHARGE,
auto_verify_ck=True,
client=FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")),
)
assert order.status == collab.STATUS_TO_RECHARGE
assert order.ck_status == collab.CK_VALID
assert order.bind_game_account == "微信用户"
assert order.bind_game_role == "吃鸡199706"
def test_status_route_auto_verifies_unverified_ck(self, monkeypatch):
order = self._create(
order_no="AUTO-STATUS-ROUTE",
gift_name="不朽伊人大礼包",
required_points=8000,
home_images=["collab/2026/10/16/home01.png"],
)
collab.set_ck(self.db, self.user, order, fake_ck("1199647005678"))
monkeypatch.setattr(
collab,
"_build_probe_client",
lambda: FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")),
)
out = collab_api.update_external_order_status(
order.id,
HuyaExternalOrderStatusRequest(
status=collab.STATUS_TO_RECHARGE,
expected_version=order.version,
),
db=self.db,
current=self.user,
)
self.db.refresh(order)
assert out["status"] == collab.STATUS_TO_RECHARGE
assert order.ck_status == collab.CK_VALID
assert order.bind_game_role == "吃鸡199706"
def test_forward_flow_sets_timestamps(self):
order = self._bind_ready(self._create())
assert order.status == collab.STATUS_TO_RECHARGE
@@ -2459,6 +2515,40 @@ class TestCollabRecharge(TestCollabBase):
row.action for row in self._audits(first)
]
def test_recharge_auto_refreshes_changed_binding_and_requires_retry(self):
"""核账前自动验 CK;换绑时保存新快照并中止,不把旧单充到新角色。"""
order = self._ready("RC-AUTO-1", "1015", gift_name="礼包A", required_points=200)
assert order.bind_game_role == self.ROLE
changed = self._check(
[order],
FakeRechargeClient(
FakeBindResp(nick=self.BIND, role="角色B"),
score=self._score(200),
),
)
assert changed["updated"] == 0
assert changed["failed"] == 1
assert changed["groups"] == []
assert "CK 已自动刷新" in changed["failures"][0]["message"]
assert "角色B" in changed["failures"][0]["message"]
assert order.status == collab.STATUS_TO_RECHARGE
assert order.bind_game_role == "角色B"
assert order.points_checked_at is None
retried = self._check(
[order],
FakeRechargeClient(
FakeBindResp(nick=self.BIND, role="角色B"),
score=self._score(200),
),
)
assert retried["updated"] == 1
assert retried["failed"] == 0
assert order.status == collab.STATUS_RECHARGED
def test_threshold_boundary_is_allowed(self):
order = self._ready("RC-3", "1002", gift_name="礼包A", required_points=500)
client = FakeRechargeClient(
@@ -2724,8 +2814,8 @@ class TestCollabRecharge(TestCollabBase):
result = self._check([order], client)
assert result["failed"] == 1
assert result["groups"][0]["status"] == ""
assert "CK 验证失败" in result["groups"][0]["message"]
assert result["groups"] == []
assert "CK 验证失败" in result["failures"][0]["message"]
assert order.status == collab.STATUS_TO_RECHARGE
assert order.points_checked_at is None
+5 -2
View File
@@ -468,7 +468,7 @@ def recharge_external_orders(
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""充值核账:按绑定账号整组校验 CK + 积分,整组落「已充值 / 积分异常」。"""
"""充值核账:自动刷新 CK/绑定快照后,按绑定账号整组校验积分并落状态。"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
result = collab.check_recharge_groups(
@@ -494,6 +494,7 @@ def batch_update_external_order_status(
"""勾选多行一次改业务状态(单个也走这里)。
人工切换允许**往前跳步**(待补资料 → 已上脚本),回退要带 ``rollback=true``;
进入「待充值」时若 CK 尚未验证,服务层自动跑一次只读探针;
「已充值 / 积分异常」不走这里,必须用 ``POST /external-orders/recharge`` 整组核账。
"""
try:
@@ -506,6 +507,7 @@ def batch_update_external_order_status(
reason=req.reason,
rollback=req.rollback,
force=req.force,
auto_verify_ck=req.status == collab.STATUS_TO_RECHARGE,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@@ -760,7 +762,7 @@ def update_external_order_status(
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""推进/回退业务状态。回退必须显式带 ``rollback=true``。"""
"""推进/回退业务状态;进入待充值会自动补验未验证的 CK。"""
try:
order = collab.get_order(db, order_id)
collab.assert_version(order, req.expected_version)
@@ -772,6 +774,7 @@ def update_external_order_status(
reason=req.reason,
rollback=req.rollback,
force=req.force,
auto_verify_ck=req.status == collab.STATUS_TO_RECHARGE,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
+145 -7
View File
@@ -637,6 +637,8 @@ def set_status(
rollback: bool = False,
force: bool = False,
allow_recharge: bool = False,
auto_verify_ck: bool = False,
client: Any | None = None,
commit: bool = True,
) -> dict:
"""推进/回退业务状态(本模块唯一的 ``status`` 写入口)。
@@ -644,6 +646,9 @@ def set_status(
``recharged`` / ``points_error`` 只能由充值核账流程写入(``allow_recharge=True``);
普通人工状态接口不允许绕过核账。
``auto_verify_ck=True`` 时,进入「待充值」若 CK 尚未有效,会先自动跑一次只读探针,
用户不需要再单独点「验证 CK」。
``force=True`` 仅用于超级管理员把 ``void`` 恢复到流程内:必须填写原因,
只跳过“作废不可逆”限制,不绕过资料完整性与核账校验。
"""
@@ -694,6 +699,15 @@ def set_status(
code="status_from_void",
)
if target == STATUS_TO_RECHARGE and auto_verify_ck and order.ck_status != CK_VALID:
# 工作台改状态时自动跑一次只读探针:用户不必先手工点「验证 CK」。
probe = verify_ck(db, current, order, client=client, commit=False)
if not probe.get("ok"):
raise CollabError(
f"CK 验证失败:{probe.get('message') or '未知原因'}",
code="ck_invalid",
)
if from_status == STATUS_INCOMPLETE and target not in (
STATUS_VOID,
STATUS_FAILED,
@@ -756,11 +770,14 @@ def set_status_batch(
reason: str = "",
rollback: bool = False,
force: bool = False,
auto_verify_ck: bool = False,
client: Any | None = None,
) -> dict:
"""批量改状态:勾选多行一次切到同一个状态(部分失败不影响其它行)。
逐行用 SAVEPOINT 隔离(同 :func:`import_orders` 的做法):某一行因为前置校验没过
(例如资料不全进不了「待充值」)只记在这一条的 ``message`` 里,整体不整体失败。
``auto_verify_ck=True`` 会透传给单行状态机,在进入「待充值」前自动补做 CK 探针。
上限与批量验证一致(``PROBE_BATCH_MAX``)—— 一次别点太多,人会看不过来。
"""
rows = list(orders)
@@ -790,6 +807,8 @@ def set_status_batch(
reason=reason,
rollback=rollback,
force=force,
auto_verify_ck=auto_verify_ck,
client=client,
commit=False,
)
except CollabError as exc:
@@ -864,6 +883,103 @@ def _recharge_group_label(group_key: RechargeGroupKey) -> str:
return f"{group_key[0]} / {group_key[1]}"
def _preflight_recharge_binding(
db: Session,
current: User | None,
orders: Sequence[HuyaExternalOrder],
*,
client: Any | None,
) -> tuple[set[int], set[int], list[dict[str, Any]]]:
"""核账前自动刷新绑定快照,并拦下真正发生换绑的订单。
返回 ``(verified_uids, failed_ids, failures)``。快照更新保留在本次事务里;
即使随后因换绑中止核账,也会在调用方统一提交,用户下次核账不必再手工验证。
"""
by_uid: dict[str, list[HuyaExternalOrder]] = {}
for order in orders:
uid = str(order.uid or "").strip()
if uid:
by_uid.setdefault(uid, []).append(order)
verified_uids: set[int] = set()
failed_ids: set[int] = set()
failures: list[dict[str, Any]] = []
for uid_text, rows in by_uid.items():
representative = rows[0]
if not str(representative.cookie or "").strip():
continue
before_by_id = {
int(order.id): (
str(order.bind_game_account or ""),
str(order.bind_game_role or ""),
)
for order in rows
}
try:
probe = verify_ck(
db,
current,
representative,
client=client,
commit=False,
)
except CollabError as exc:
for order in rows:
failures.append(
{
"id": int(order.id),
"order_no": order.order_no,
"message": exc.detail,
}
)
failed_ids.add(int(order.id))
continue
if not probe.get("ok"):
message = f"CK 验证失败:{probe.get('message') or '未知原因'}"
for order in rows:
failures.append(
{
"id": int(order.id),
"order_no": order.order_no,
"message": message,
}
)
failed_ids.add(int(order.id))
continue
binding = probe.get("binding") or {}
after_identity = (
str(binding.get("bind_game_account") or ""),
str(binding.get("bind_game_role") or ""),
)
changed = [
order for order in rows if before_by_id[int(order.id)] != after_identity
]
if changed:
before_label = " / ".join(
str(part or "-") for part in before_by_id[int(changed[0].id)]
)
after_label = " / ".join(str(part or "-") for part in after_identity)
for order in rows:
failures.append(
{
"id": int(order.id),
"order_no": order.order_no,
"message": (
"CK 已自动刷新,绑定由 "
f"「{before_label}」变为「{after_label}」;"
"本次未核账,请确认角色后重试"
),
}
)
failed_ids.add(int(order.id))
continue
verified_uids.add(int(uid_text))
return verified_uids, failed_ids, failures
def _recharge_group_orders(
db: Session, group_key: RechargeGroupKey
) -> list[HuyaExternalOrder]:
@@ -1058,6 +1174,7 @@ def _check_recharge_group(
confirm_points_error: bool = True,
confirmation: Mapping[str, Any] | None = None,
score_source: str = "recharge_check",
verified_uids: set[int] | None = None,
) -> dict:
group_label = _recharge_group_label(group_key)
group_orders = _recharge_group_orders(db, group_key)
@@ -1096,6 +1213,8 @@ def _check_recharge_group(
)
for uid, order in representatives.items():
if verified_uids is not None and int(uid) in verified_uids:
continue
result = verify_ck(db, current, order, client=client, commit=False)
if not result.get("ok"):
raise CollabError(
@@ -1318,6 +1437,7 @@ def check_recharge_groups(
通用昵称,只按账号会把不同买家并成一组(生产 8 单被合成 48000 分);
- 只统计同组 ``to_recharge / recharged / points_error`` 的订单;
- 组内不同 uid 的 CK 各查一次积分,合计为“账号积分”;
- 核账前统一自动刷新绑定快照;如角色发生换绑,只保存新快照并中止,待人工确认;
- ``结余 = 账号积分合计 - 礼包积分合计``;
- ``结余 < 0`` 或 ``结余 > 阈值`` → 整组 ``points_error``;
- 其余 → 整组 ``recharged``;
@@ -1335,7 +1455,7 @@ def check_recharge_groups(
group_keys: list[RechargeGroupKey] = []
failures: list[dict] = []
failed_order_count = 0
eligible_rows: list[HuyaExternalOrder] = []
for order in rows:
key = _recharge_group_key(order)
if key is None:
@@ -1347,7 +1467,23 @@ def check_recharge_groups(
"message": "缺少绑定账号或绑定角色,已保持/退回「待补资料」",
}
)
failed_order_count += 1
continue
eligible_rows.append(order)
verified_uids, preflight_failed_ids, preflight_failures = (
_preflight_recharge_binding(
db,
current,
eligible_rows,
client=client,
)
)
failures.extend(preflight_failures)
for order in eligible_rows:
if int(order.id) in preflight_failed_ids:
continue
key = _recharge_group_key(order)
if key is None:
continue
if key not in group_keys:
group_keys.append(key)
@@ -1364,6 +1500,7 @@ def check_recharge_groups(
threshold=threshold_value,
client=client,
confirm_points_error=False,
verified_uids=verified_uids,
)
if group.get("_needs_confirmation"):
pending_confirmation[key] = group
@@ -1371,7 +1508,6 @@ def check_recharge_groups(
group_results[key] = group
except CollabError as exc:
group_rows = _recharge_group_orders(db, key)
failed_order_count += max(1, len(group_rows))
group_results[key] = _failed_recharge_group_result(
key,
group_rows,
@@ -1381,7 +1517,6 @@ def check_recharge_groups(
except Exception as exc: # noqa: BLE001 单个账号组失败不影响其它组
logger.debug(f"[huya-collab] 充值核账单组失败:{type(exc).__name__}: {exc}")
group_rows = _recharge_group_orders(db, key)
failed_order_count += max(1, len(group_rows))
group_results[key] = _failed_recharge_group_result(
key,
group_rows,
@@ -1415,11 +1550,11 @@ def check_recharge_groups(
"first_reason": first.get("reason"),
},
score_source="recharge_retry",
verified_uids=verified_uids,
)
group_results[key] = group
except CollabError as exc:
group_rows = _recharge_group_orders(db, key)
failed_order_count += max(1, len(group_rows))
group_results[key] = _failed_recharge_group_result(
key,
group_rows,
@@ -1431,7 +1566,6 @@ def check_recharge_groups(
f"[huya-collab] 充值核账二次确认失败:{type(exc).__name__}: {exc}"
)
group_rows = _recharge_group_orders(db, key)
failed_order_count += max(1, len(group_rows))
group_results[key] = _failed_recharge_group_result(
key,
group_rows,
@@ -1444,6 +1578,10 @@ def check_recharge_groups(
updated = sum(
len(group.get("order_ids") or []) for group in groups if group.get("status")
)
failed_ids = {int(item["id"]) for item in failures if item.get("id") is not None}
for group in groups:
if not group.get("status"):
failed_ids.update(int(item) for item in group.get("order_ids") or [])
if commit:
db.commit()
for order in rows:
@@ -1451,7 +1589,7 @@ def check_recharge_groups(
return {
"total": len(rows),
"updated": updated,
"failed": failed_order_count,
"failed": len(failed_ids),
"threshold": threshold_value,
"confirmed_groups": len(pending_confirmation),
"confirmation_delay_seconds": delay,
+3 -3
View File
@@ -1308,7 +1308,7 @@ export default function HuyaCollabPage() {
? `${group.group_key}:${group.message}`
: `${group.group_key}:${group.message}`,
}));
if (lines.length === 0) {
if (lines.length === 0 && result.failures.length === 0) {
message.warning('没有可核账的订单');
} else {
modal.info({
@@ -2625,7 +2625,7 @@ export default function HuyaCollabPage() {
</Dropdown>
)}
{canEdit && !isArchiveView && (
<Tooltip title="按「绑定账号 + 绑定角色」整组校验 CK 和积分,整组落到「已充值 / 积分异常」">
<Tooltip title="自动刷新 CK 与绑定快照,再按「绑定账号 + 绑定角色」整组核账">
<Button icon={<AuditOutlined />} disabled={selectedIds.length === 0 || busy}
onClick={() => openRecharge(selectedIds)}>
充值核账{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
@@ -3566,7 +3566,7 @@ export default function HuyaCollabPage() {
type="info"
showIcon
message="按「绑定账号 + 绑定角色」整组核账"
description="同一绑定账号和角色下的待充值/已充值/积分异常订单会一起核账;CK 失效或积分查不到时整组保持原状态。"
description="提交时自动刷新 CK 和绑定快照。若角色已在核账前换绑,会保存新快照并提示确认后重试;CK 失效或积分查不到时整组保持原状态。"
/>
<Space size={8} align="center">
<Text>允许结余阈值</Text>