fix(huya): 改状态时自动校验 CK
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user