fix(huya): 修复外部单积分复核与抢购结果误判
This commit is contained in:
+13
-6
@@ -272,6 +272,10 @@ incomplete ─资料齐全且 CK 有效─▶ to_recharge ─充值核账─▶
|
||||
- `必充积分 = Σ required_points`;`结余 = 账号积分合计 - 必充积分`;
|
||||
- `结余 < 0` → 整组 `points_error`(`reason=shortage`);
|
||||
`结余 > 阈值` → 整组 `points_error`(`reason=excess`);其余 → 整组 `recharged`;
|
||||
- 首次算出异常**不立即落库**:同批异常组统一等待 30 秒后再查一次积分,
|
||||
第二次结果才决定 `recharged / points_error`。这用于兜住积分接口缓存或到账延迟,
|
||||
避免瞬时 `0` 分被误判;两轮积分都会写生命周期快照(第二轮 `source=recharge_retry`),
|
||||
`points_check_detail.confirmation` 保留第一轮余额、结余和实际等待时间;
|
||||
- 阈值默认 `100`,前端可改并记忆;范围 `0..1_000_000`;
|
||||
- CK 失效 / 积分查询失败 / 缺少 `required_points` 时**整组保持原状态**并逐组报错;
|
||||
- 每个组内订单都写同一份快照:`points_checked_at / points_balance /
|
||||
@@ -463,8 +467,9 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
|
||||
|
||||
| 情况 | verdict | 业务状态 |
|
||||
| --- | --- | --- |
|
||||
| 兑换记录里有这一单的礼包(名字互相包含即算命中,`gift_matches_records()`) | `success` | 「已上脚本」→ 自动「抢购成功」`done` |
|
||||
| 接口通了,但记录里没有这个礼包 | `failed` | 「已上脚本」→ 自动「抢购失败」`failed` |
|
||||
| 兑换记录里有这一单的礼包(名字互相包含、且兑换时间不早于上脚本时间) | `success` | 「已上脚本 / 抢购失败」→ 自动「抢购成功」`done` |
|
||||
| 接口通了,但记录里没有这个礼包,且已超过上脚本后的 10 分钟确认窗口 | `failed` | 「已上脚本」→ 自动「抢购失败」`failed` |
|
||||
| 接口通了,但仍在确认窗口内没查到 | `unknown` | **不动**;官方记录有落库延迟,暂不作为失败结论 |
|
||||
| 接口没通 / 这单没填礼包名 | `unknown` | **不动**(数据不够,不许瞎判) |
|
||||
|
||||
**四条边界**:
|
||||
@@ -472,9 +477,9 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
|
||||
1. **只手动触发**:单行「查积分 / 兑换记录」+ 勾选批量查询(串行 + `PROBE_BATCH_INTERVAL_SECONDS`
|
||||
间隔,上限 `PROBE_BATCH_MAX=50`);**不做定时轮询** —— 虎牙风控(§12),
|
||||
而且"什么时候去查"本身是运营的判断(抢购是人工触发的);
|
||||
2. **只有 `scripted` 的单会自动改状态**:抢购发生在上脚本之后,
|
||||
别的阶段查到什么都说明不了问题;这种情况 metric 照写,结论里写明
|
||||
"只有「已上脚本」的单才会按查询结果自动改状态",由人决定要不要手动切;
|
||||
2. **自动判失败只作用于 `scripted`**,且必须过了上脚本后的 10 分钟确认窗口;
|
||||
没查到时窗口内一律 `unknown`。已经落到 `failed` 的单后续如果查到匹配记录,
|
||||
允许自动纠正为 `done`(审计按回退恢复留痕);其它阶段 metric 照写但不自动改;
|
||||
3. **接口失败也写快照**(verdict=`unknown`):失败本身也是"这一刻的状态",
|
||||
下次查到成功就能在时间线上看出"积分从 X 涨到 Y",这正是要的证据;
|
||||
4. **`void` 的单不查**、没 CK 的单不查(`void_order` / `no_cookie`,400),
|
||||
@@ -483,6 +488,8 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
|
||||
|
||||
礼包匹配用**互相包含**(`prize_name` 与 `order.gift_name` 双向包含即命中),
|
||||
但名字短于 2 个字(`_MIN_GIFT_MATCH_LENGTH`)不参与 —— 免得"礼包"两个字匹配一堆记录。
|
||||
记录带 `exchange_time` 时还必须不早于 `scripted_at`(秒级比较),防止同一买家重复买同款时
|
||||
把上一次兑换误当成这一次成功。
|
||||
|
||||
---
|
||||
|
||||
@@ -1107,7 +1114,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
|
||||
| 探针接口被风控 | 只做人工触发、串行、带间隔;不做定时轮询 |
|
||||
| CK 被导出后外传(导出到剪贴板 = 凭据出门) | 单行复制与批量导出统一走 `huya:collab:ck_export`(默认运营和客服可用,权限页可覆盖)+ 逐行审计(不含 CK 本体)+ 界面常驻警告;明文只在弹窗 state 里,关掉即清(§11.8) |
|
||||
| 积分 / 兑换记录查询被风控 | 同上:只手动触发(单行 + 勾选批量,串行带间隔),不做定时轮询(§5.7) |
|
||||
| 自动改状态误判(接口抖动 → 判成"抢购失败") | 三态判定:取不到数据一律 `unknown` 且不动状态;只有 `scripted` 的单才自动改;每次查询留快照,人工可回退(`rollback`) |
|
||||
| 自动改状态误判(接口抖动 / 兑换记录延迟 → 判成"抢购失败") | 三态判定:取不到或在 10 分钟确认窗口内没查到的都保持 `unknown`;只有过了窗口的 `scripted` 单才自动判失败;之后查到匹配记录可自动纠正为 `done`,每次查询留快照 |
|
||||
| 积分中心 `queryAllocation` 拉不到 / 返回空 → 礼包列表空白 | 只影响「拉礼包」,不影响 CK 有效性;提示用户稍后点「强制刷新礼包」。旧 `getActPrizeList` 的 SID 过期问题只保留给精英宝典工作台(§11.4) |
|
||||
| 自由文本的"绑定斗鱼号"没法校验 | 先按文本存(Excel 里就是文本);能拿到结构化 uid 时补 `bind_douyu_uid` |
|
||||
| 图片越攒越多 | §6.4 孤儿清理脚本 + `file_assets` 台账已有 `size_bytes` 可统计 |
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -36,6 +37,7 @@ from web.backend.schemas import (
|
||||
HuyaExternalOrderCreate,
|
||||
HuyaExternalOrderListOut,
|
||||
HuyaExternalOrderOut,
|
||||
HuyaExternalOrderRechargeOut,
|
||||
HuyaExternalOrderStatusBatchRequest,
|
||||
HuyaExternalOrderUpdate,
|
||||
)
|
||||
@@ -2373,7 +2375,8 @@ class FakeRecordsResp:
|
||||
"score": 200,
|
||||
"status_label": "已发放",
|
||||
"order_id": "H-ORDER-1",
|
||||
"exchange_time": 1792000000,
|
||||
# 测试接口拿不到微秒;加 1 秒,避免同一秒内被 not_before 边界误伤。
|
||||
"exchange_time": int(datetime.now(UTC).timestamp()) + 1,
|
||||
}
|
||||
]
|
||||
)
|
||||
@@ -2483,6 +2486,7 @@ class TestCollabRecharge(TestCollabBase):
|
||||
orders,
|
||||
threshold=threshold,
|
||||
client=client,
|
||||
confirm_delay_seconds=0,
|
||||
)
|
||||
|
||||
def test_success_recharges_whole_group_and_snapshots_every_order(self):
|
||||
@@ -2537,6 +2541,7 @@ class TestCollabRecharge(TestCollabBase):
|
||||
first = self._ready("RC-4", "1003", gift_name="礼包A", required_points=200)
|
||||
second = self._ready("RC-5", "1003", gift_name="礼包B", required_points=300)
|
||||
client = FakeRechargeClient(
|
||||
FakeBindResp(nick=self.BIND, role=self.ROLE),
|
||||
FakeBindResp(nick=self.BIND, role=self.ROLE),
|
||||
score=self._score(400),
|
||||
)
|
||||
@@ -2552,6 +2557,7 @@ class TestCollabRecharge(TestCollabBase):
|
||||
def test_excess_marks_whole_group_points_error(self):
|
||||
order = self._ready("RC-6", "1004", gift_name="礼包A", required_points=500)
|
||||
client = FakeRechargeClient(
|
||||
FakeBindResp(nick=self.BIND, role=self.ROLE),
|
||||
FakeBindResp(nick=self.BIND, role=self.ROLE),
|
||||
score=self._score(650),
|
||||
)
|
||||
@@ -2563,6 +2569,93 @@ class TestCollabRecharge(TestCollabBase):
|
||||
assert order.status == collab.STATUS_POINTS_ERROR
|
||||
assert "多出 150 分" in order.points_check_message
|
||||
|
||||
def test_zero_balance_is_rechecked_before_marking_points_error(self):
|
||||
"""首次 0 分可能尚未同步,二次确认到账后应直接通过。"""
|
||||
order = self._ready(
|
||||
"RC-RETRY-1", "1015", gift_name="礼包A", required_points=988
|
||||
)
|
||||
client = FakeRechargeClient(
|
||||
FakeBindResp(nick=self.BIND, role=self.ROLE),
|
||||
FakeBindResp(nick=self.BIND, role=self.ROLE),
|
||||
scores=[self._score(0), self._score(988)],
|
||||
)
|
||||
|
||||
result = self._check([order], client)
|
||||
|
||||
group = result["groups"][0]
|
||||
assert group["status"] == collab.STATUS_RECHARGED
|
||||
assert group["balance"] == 988
|
||||
assert order.status == collab.STATUS_RECHARGED
|
||||
assert len(client.score_calls) == 2
|
||||
confirmation = order.points_check_detail["confirmation"]
|
||||
assert confirmation["attempts"] == 2
|
||||
assert confirmation["first_balance"] == 0
|
||||
assert confirmation["first_reason"] == "shortage"
|
||||
assert result["confirmed_groups"] == 1
|
||||
dumped = HuyaExternalOrderRechargeOut(**result).model_dump()
|
||||
assert dumped["confirmed_groups"] == 1
|
||||
assert dumped["confirmation_delay_seconds"] == 0
|
||||
assert dumped["groups"][0]["status"] == collab.STATUS_RECHARGED
|
||||
|
||||
def test_points_error_is_only_written_after_two_failed_reads(self):
|
||||
order = self._ready(
|
||||
"RC-RETRY-2", "1016", gift_name="礼包A", required_points=988
|
||||
)
|
||||
client = FakeRechargeClient(
|
||||
FakeBindResp(nick=self.BIND, role=self.ROLE),
|
||||
FakeBindResp(nick=self.BIND, role=self.ROLE),
|
||||
scores=[self._score(0), self._score(0)],
|
||||
)
|
||||
|
||||
result = self._check([order], client)
|
||||
|
||||
assert result["confirmed_groups"] == 1
|
||||
assert len(client.score_calls) == 2
|
||||
assert order.status == collab.STATUS_POINTS_ERROR
|
||||
assert order.points_check_reason == "shortage"
|
||||
assert order.points_check_detail["confirmation"]["first_balance"] == 0
|
||||
|
||||
def test_confirmation_waits_once_for_whole_batch(self, monkeypatch):
|
||||
first = self._ready(
|
||||
"RC-RETRY-3",
|
||||
"1017",
|
||||
gift_name="礼包A",
|
||||
required_points=988,
|
||||
role="角色A",
|
||||
)
|
||||
second = self._ready(
|
||||
"RC-RETRY-4",
|
||||
"1018",
|
||||
gift_name="礼包B",
|
||||
required_points=988,
|
||||
role="角色B",
|
||||
)
|
||||
client = FakeRechargeClient(
|
||||
FakeBindResp(nick=self.BIND, role="角色A"),
|
||||
FakeBindResp(nick=self.BIND, role="角色B"),
|
||||
FakeBindResp(nick=self.BIND, role="角色A"),
|
||||
FakeBindResp(nick=self.BIND, role="角色B"),
|
||||
scores=[self._score(0), self._score(0), self._score(0), self._score(0)],
|
||||
)
|
||||
sleeps: list[float] = []
|
||||
monkeypatch.setattr(collab.time, "sleep", sleeps.append)
|
||||
|
||||
result = collab.check_recharge_groups(
|
||||
self.db,
|
||||
self.user,
|
||||
[first, second],
|
||||
threshold=100,
|
||||
client=client,
|
||||
confirm_delay_seconds=30,
|
||||
)
|
||||
|
||||
assert sleeps == [30]
|
||||
assert result["confirmed_groups"] == 2
|
||||
assert [group["status"] for group in result["groups"]] == [
|
||||
collab.STATUS_POINTS_ERROR,
|
||||
collab.STATUS_POINTS_ERROR,
|
||||
]
|
||||
|
||||
def test_multiple_uids_are_summed_and_each_queried_once(self):
|
||||
first = self._ready("RC-7", "1005", gift_name="礼包A", required_points=300)
|
||||
second = self._ready("RC-8", "1006", gift_name="礼包B", required_points=300)
|
||||
@@ -2991,7 +3084,8 @@ class TestCollabActivityLifecycle(TestCollabBase):
|
||||
row.action for row in self._audits(order)
|
||||
]
|
||||
|
||||
def test_missing_gift_marks_failed_without_clearing_scripted_time(self):
|
||||
def test_missing_gift_in_settle_window_stays_unknown(self):
|
||||
"""生产回归:官方记录会晚于脚本查询落库,窗口内不能自动判失败。"""
|
||||
order = self._scripted_order("ACT-2")
|
||||
client = FakeActivityClient(
|
||||
records=FakeRecordsResp(
|
||||
@@ -3001,9 +3095,30 @@ class TestCollabActivityLifecycle(TestCollabBase):
|
||||
|
||||
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 == collab.STATUS_SCRIPTED
|
||||
assert order.scripted_at is not None
|
||||
assert order.done_at is None
|
||||
assert "抢购确认窗口" in result["message"]
|
||||
|
||||
def test_missing_gift_after_settle_window_marks_failed(self):
|
||||
order = self._scripted_order("ACT-2-OLD")
|
||||
order.scripted_at = datetime.now(UTC) - timedelta(
|
||||
seconds=collab.ACTIVITY_RESULT_SETTLE_SECONDS + 1
|
||||
)
|
||||
self.db.commit()
|
||||
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"
|
||||
assert order.status == collab.STATUS_FAILED
|
||||
# failed 是旁路终态:上脚本的时间戳必须留着(不然不知道什么时候上的)
|
||||
assert order.scripted_at is not None
|
||||
assert order.done_at is None
|
||||
@@ -3015,6 +3130,44 @@ class TestCollabActivityLifecycle(TestCollabBase):
|
||||
collab.set_status(self.db, self.user, order, "scripted", rollback=True)
|
||||
assert order.status == "scripted"
|
||||
|
||||
def test_late_matching_record_promotes_failed_to_done(self):
|
||||
order = self._scripted_order("ACT-2-LATE")
|
||||
order.status = collab.STATUS_FAILED
|
||||
order.scripted_at = datetime.now(UTC) - timedelta(minutes=15)
|
||||
self.db.commit()
|
||||
|
||||
result = collab.query_order_activity(
|
||||
self.db, self.user, order, client=FakeActivityClient()
|
||||
)
|
||||
|
||||
assert result["verdict"] == collab.VERDICT_SUCCESS
|
||||
assert result["auto_status"] == collab.STATUS_DONE
|
||||
assert order.status == collab.STATUS_DONE
|
||||
assert order.done_at is not None
|
||||
|
||||
def test_matching_record_before_script_does_not_count_as_current_success(self):
|
||||
order = self._scripted_order("ACT-2-OLD-MATCH")
|
||||
old_record = {
|
||||
"prize_name": "不朽伊人大礼包",
|
||||
"score": 200,
|
||||
"status_label": "已发放",
|
||||
"order_id": "H-OLD",
|
||||
"exchange_time": int(
|
||||
(datetime.now(UTC) - timedelta(minutes=20)).timestamp()
|
||||
),
|
||||
}
|
||||
|
||||
result = collab.query_order_activity(
|
||||
self.db,
|
||||
self.user,
|
||||
order,
|
||||
client=FakeActivityClient(records=FakeRecordsResp(records=[old_record])),
|
||||
)
|
||||
|
||||
assert result["verdict"] == collab.VERDICT_UNKNOWN
|
||||
assert result["records_matched"] == 0
|
||||
assert order.status == collab.STATUS_SCRIPTED
|
||||
|
||||
def test_status_untouched_when_order_is_not_scripted(self):
|
||||
order = self._bind_ready(
|
||||
self._create(order_no="ACT-3", gift_name="不朽伊人大礼包")
|
||||
|
||||
@@ -1963,6 +1963,8 @@ class HuyaExternalOrderRechargeOut(BaseModel):
|
||||
updated: int
|
||||
failed: int
|
||||
threshold: int = 100
|
||||
confirmed_groups: int = 0
|
||||
confirmation_delay_seconds: float = 0
|
||||
groups: list[HuyaExternalOrderRechargeGroupOut] = Field(default_factory=list)
|
||||
failures: list[HuyaExternalOrderRechargeFailureOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import hashlib
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -142,6 +142,9 @@ ARCHIVE_BATCH_MAX = 5000
|
||||
# 充值核账阈值:账号积分 - 礼包积分合计 的允许结余上限(默认 100)。
|
||||
RECHARGE_POINTS_THRESHOLD_DEFAULT = 100
|
||||
RECHARGE_POINTS_THRESHOLD_MAX = 1_000_000
|
||||
# 积分到账/接口缓存可能晚于充值动作。首次算出异常后,统一等待一次再复核,
|
||||
# 避免把瞬时 0 分直接落成 points_error;同批只等待一次,不按账号组累加。
|
||||
RECHARGE_POINTS_CONFIRM_DELAY_SECONDS = 30
|
||||
|
||||
|
||||
def normalize_points_threshold(value: Any) -> int:
|
||||
@@ -926,6 +929,9 @@ def _check_recharge_group(
|
||||
*,
|
||||
threshold: int,
|
||||
client: Any | None = None,
|
||||
confirm_points_error: bool = True,
|
||||
confirmation: Mapping[str, Any] | None = None,
|
||||
score_source: str = "recharge_check",
|
||||
) -> dict:
|
||||
group_label = _recharge_group_label(group_key)
|
||||
group_orders = _recharge_group_orders(db, group_key)
|
||||
@@ -998,7 +1004,7 @@ def _check_recharge_group(
|
||||
order,
|
||||
scope=ACTIVITY_SCOPE_SCORE,
|
||||
client=client,
|
||||
source="recharge_check",
|
||||
source=score_source,
|
||||
commit=False,
|
||||
)
|
||||
score = result.get("score")
|
||||
@@ -1031,6 +1037,21 @@ def _check_recharge_group(
|
||||
f"结余 {surplus} 分(阈值 {threshold})"
|
||||
)
|
||||
|
||||
if result_status == STATUS_POINTS_ERROR and not confirm_points_error:
|
||||
return {
|
||||
"_needs_confirmation": True,
|
||||
"group_key": group_label,
|
||||
"bind_game_account": group_key[0],
|
||||
"bind_game_role": group_key[1],
|
||||
"threshold": threshold,
|
||||
"balance": balance,
|
||||
"required_total": required_total,
|
||||
"surplus": surplus,
|
||||
"reason": reason,
|
||||
"message": message,
|
||||
"order_ids": [int(order.id) for order in group_orders],
|
||||
}
|
||||
|
||||
now = _now()
|
||||
detail = {
|
||||
"group_key": group_label,
|
||||
@@ -1063,6 +1084,8 @@ def _check_recharge_group(
|
||||
for order in group_orders
|
||||
],
|
||||
}
|
||||
if confirmation:
|
||||
detail["confirmation"] = dict(confirmation)
|
||||
|
||||
for order in group_orders:
|
||||
set_status(
|
||||
@@ -1125,6 +1148,28 @@ def _check_recharge_group(
|
||||
}
|
||||
|
||||
|
||||
def _failed_recharge_group_result(
|
||||
group_key: RechargeGroupKey,
|
||||
group_orders: Sequence[HuyaExternalOrder],
|
||||
*,
|
||||
threshold: int,
|
||||
message: str,
|
||||
) -> dict:
|
||||
return {
|
||||
"group_key": _recharge_group_label(group_key),
|
||||
"status": "",
|
||||
"status_label": "",
|
||||
"balance": None,
|
||||
"required_total": None,
|
||||
"surplus": None,
|
||||
"threshold": threshold,
|
||||
"reason": "error",
|
||||
"message": message,
|
||||
"order_ids": [int(order.id) for order in group_orders],
|
||||
"orders": [],
|
||||
}
|
||||
|
||||
|
||||
def check_recharge_groups(
|
||||
db: Session,
|
||||
current: User | None,
|
||||
@@ -1132,6 +1177,7 @@ def check_recharge_groups(
|
||||
*,
|
||||
threshold: Any = RECHARGE_POINTS_THRESHOLD_DEFAULT,
|
||||
client: Any | None = None,
|
||||
confirm_delay_seconds: float = RECHARGE_POINTS_CONFIRM_DELAY_SECONDS,
|
||||
commit: bool = True,
|
||||
) -> dict:
|
||||
"""按「``bind_game_account`` + ``bind_game_role``」整组核账,并整组落到「已充值 / 积分异常」。
|
||||
@@ -1176,9 +1222,9 @@ def check_recharge_groups(
|
||||
if key not in group_keys:
|
||||
group_keys.append(key)
|
||||
|
||||
groups: list[dict] = []
|
||||
group_results: dict[RechargeGroupKey, dict] = {}
|
||||
pending_confirmation: dict[RechargeGroupKey, dict] = {}
|
||||
for key in group_keys:
|
||||
group_label = _recharge_group_label(key)
|
||||
try:
|
||||
with db.begin_nested():
|
||||
group = _check_recharge_group(
|
||||
@@ -1187,46 +1233,84 @@ def check_recharge_groups(
|
||||
key,
|
||||
threshold=threshold_value,
|
||||
client=client,
|
||||
confirm_points_error=False,
|
||||
)
|
||||
groups.append(group)
|
||||
if group.get("_needs_confirmation"):
|
||||
pending_confirmation[key] = group
|
||||
else:
|
||||
group_results[key] = group
|
||||
except CollabError as exc:
|
||||
group_rows = _recharge_group_orders(db, key)
|
||||
failed_order_count += max(1, len(group_rows))
|
||||
groups.append(
|
||||
{
|
||||
"group_key": group_label,
|
||||
"status": "",
|
||||
"status_label": "",
|
||||
"balance": None,
|
||||
"required_total": None,
|
||||
"surplus": None,
|
||||
"threshold": threshold_value,
|
||||
"reason": "error",
|
||||
"message": exc.detail,
|
||||
"order_ids": [int(order.id) for order in group_rows],
|
||||
"orders": [],
|
||||
}
|
||||
group_results[key] = _failed_recharge_group_result(
|
||||
key,
|
||||
group_rows,
|
||||
threshold=threshold_value,
|
||||
message=exc.detail,
|
||||
)
|
||||
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))
|
||||
groups.append(
|
||||
{
|
||||
"group_key": group_label,
|
||||
"status": "",
|
||||
"status_label": "",
|
||||
"balance": None,
|
||||
"required_total": None,
|
||||
"surplus": None,
|
||||
"threshold": threshold_value,
|
||||
"reason": "error",
|
||||
"message": f"{type(exc).__name__}: {exc}",
|
||||
"order_ids": [int(order.id) for order in group_rows],
|
||||
"orders": [],
|
||||
}
|
||||
group_results[key] = _failed_recharge_group_result(
|
||||
key,
|
||||
group_rows,
|
||||
threshold=threshold_value,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
delay = max(0.0, float(confirm_delay_seconds or 0))
|
||||
if pending_confirmation and delay:
|
||||
# 已通过的组先落库,避免等待二次确认时把它们的行锁持有整整 30 秒。
|
||||
if commit:
|
||||
db.commit()
|
||||
time.sleep(delay)
|
||||
|
||||
for key, first in pending_confirmation.items():
|
||||
try:
|
||||
with db.begin_nested():
|
||||
group = _check_recharge_group(
|
||||
db,
|
||||
current,
|
||||
key,
|
||||
threshold=threshold_value,
|
||||
client=client,
|
||||
confirm_points_error=True,
|
||||
confirmation={
|
||||
"attempts": 2,
|
||||
"delay_seconds": delay,
|
||||
"first_balance": first.get("balance"),
|
||||
"first_required_total": first.get("required_total"),
|
||||
"first_surplus": first.get("surplus"),
|
||||
"first_reason": first.get("reason"),
|
||||
},
|
||||
score_source="recharge_retry",
|
||||
)
|
||||
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,
|
||||
threshold=threshold_value,
|
||||
message=exc.detail,
|
||||
)
|
||||
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,
|
||||
threshold=threshold_value,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
groups = [group_results[key] for key in group_keys if key in group_results]
|
||||
|
||||
updated = sum(
|
||||
len(group.get("order_ids") or []) for group in groups if group.get("status")
|
||||
)
|
||||
@@ -1239,6 +1323,8 @@ def check_recharge_groups(
|
||||
"updated": updated,
|
||||
"failed": failed_order_count,
|
||||
"threshold": threshold_value,
|
||||
"confirmed_groups": len(pending_confirmation),
|
||||
"confirmation_delay_seconds": delay,
|
||||
"groups": groups,
|
||||
"failures": failures,
|
||||
}
|
||||
@@ -2009,6 +2095,10 @@ ACTIVITY_SCOPE_LABELS = {
|
||||
ACTIVITY_SCOPE_RECORDS: "兑换记录",
|
||||
}
|
||||
|
||||
# 官方兑换记录存在落库延迟:生产实测三单在查询后 20~54 秒才出现记录。
|
||||
# 因此脚本开始后的观察窗口内,「没查到」只能算暂时无法判定,不能自动判失败。
|
||||
ACTIVITY_RESULT_SETTLE_SECONDS = 10 * 60
|
||||
|
||||
# metric.raw 里最多留多少条兑换记录(避免 JSON 列被几百条记录撑爆)
|
||||
_METRIC_RECORD_LIMIT = 50
|
||||
# 兑换记录里跟订单礼包名比对时,只取有意义的长度(太短的名字容易误匹配)
|
||||
@@ -2021,26 +2111,57 @@ def _metric_message(text: Any, limit: int = 480) -> str:
|
||||
|
||||
|
||||
def gift_matches_records(
|
||||
records: Sequence[Mapping[str, Any]], gift_name: str
|
||||
records: Sequence[Mapping[str, Any]],
|
||||
gift_name: str,
|
||||
*,
|
||||
not_before: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""在兑换记录里找这一单的礼包(名字完全相等或互相包含都算)。
|
||||
|
||||
虎牙记录里的 ``prize_name`` 与活动列表里的名字基本一字不差,但偶尔带后缀/前缀,
|
||||
所以用「互相包含」宽松匹配;太短的名字(<2 字)不参与,免得 "礼包" 两个字匹配一堆。
|
||||
传入 ``not_before`` 时还会排除上脚本之前的旧记录,避免同一买家重复购买同款时,
|
||||
把上次兑换误判成这次成功。
|
||||
"""
|
||||
wanted = str(gift_name or "").strip()
|
||||
if len(wanted) < _MIN_GIFT_MATCH_LENGTH:
|
||||
return []
|
||||
threshold = not_before
|
||||
if threshold is not None and threshold.tzinfo is None:
|
||||
threshold = threshold.replace(tzinfo=UTC)
|
||||
if threshold is not None:
|
||||
# exchange_time 只有秒精度,上脚本时间可能带微秒;同秒记录不能误排掉。
|
||||
threshold = threshold.replace(microsecond=0)
|
||||
|
||||
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))
|
||||
if not (name == wanted or wanted in name or name in wanted):
|
||||
continue
|
||||
raw_exchange_time = record.get("exchange_time")
|
||||
if threshold is not None and raw_exchange_time not in (None, ""):
|
||||
try:
|
||||
exchange_at = datetime.fromtimestamp(float(raw_exchange_time), tz=UTC)
|
||||
except (TypeError, ValueError, OSError):
|
||||
exchange_at = None
|
||||
if exchange_at is not None and exchange_at < threshold:
|
||||
continue
|
||||
matched.append(dict(record))
|
||||
return matched
|
||||
|
||||
|
||||
def _activity_result_settle_at(order: HuyaExternalOrder) -> datetime | None:
|
||||
"""返回脚本后的最早自动判失败时间;没有上脚本时间时不自动判失败。"""
|
||||
scripted_at = order.scripted_at
|
||||
if scripted_at is None:
|
||||
return None
|
||||
if scripted_at.tzinfo is None:
|
||||
scripted_at = scripted_at.replace(tzinfo=UTC)
|
||||
return scripted_at + timedelta(seconds=ACTIVITY_RESULT_SETTLE_SECONDS)
|
||||
|
||||
|
||||
def _extract_score(resp: Any) -> tuple[int | None, int | None, int | None, str]:
|
||||
"""从 ``getUserScore`` 响应里取「可用 / 累计获得 / 已消耗」+ 可读结论。"""
|
||||
if resp is None:
|
||||
@@ -2294,7 +2415,13 @@ def query_order_activity(
|
||||
_extract_score(score_resp) if want_score else (None, None, None, "")
|
||||
)
|
||||
records, records_msg = _extract_records(records_resp) if want_records else ([], "")
|
||||
matched = gift_matches_records(records, order.gift_name)
|
||||
matched = gift_matches_records(
|
||||
records,
|
||||
order.gift_name,
|
||||
not_before=order.scripted_at,
|
||||
)
|
||||
now = _now()
|
||||
settle_at = _activity_result_settle_at(order)
|
||||
|
||||
if not want_records:
|
||||
# 只查积分:兑换记录一个字节都没查 —— 不许按"记录是空的"判成「兑换失败」,
|
||||
@@ -2324,6 +2451,19 @@ def query_order_activity(
|
||||
f"(最近一条:{latest.get('status_label') or '状态未知'}"
|
||||
f"{(',单号 ' + str(latest.get('order_id'))) if latest.get('order_id') else ''})"
|
||||
)
|
||||
elif order.status == STATUS_SCRIPTED and (settle_at is None or now < settle_at):
|
||||
verdict = VERDICT_UNKNOWN
|
||||
if settle_at is None:
|
||||
message = (
|
||||
f"兑换记录里暂未查到「{order.gift_name}」;"
|
||||
"这单没有上脚本时间,不能自动判定抢购失败"
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"兑换记录里暂未查到「{order.gift_name}」"
|
||||
f"(当前共 {len(records)} 条记录);处于上脚本后的 10 分钟抢购确认窗口,"
|
||||
"暂不自动判失败"
|
||||
)
|
||||
else:
|
||||
verdict = VERDICT_FAILED
|
||||
message = (
|
||||
@@ -2338,13 +2478,26 @@ def query_order_activity(
|
||||
target = STATUS_FAILED
|
||||
else:
|
||||
target = ""
|
||||
if target and order.status == STATUS_SCRIPTED:
|
||||
set_status(db, current, order, target, commit=False)
|
||||
can_promote_success = target == STATUS_DONE and order.status in (
|
||||
STATUS_SCRIPTED,
|
||||
STATUS_FAILED,
|
||||
)
|
||||
can_promote_failure = target == STATUS_FAILED and order.status == STATUS_SCRIPTED
|
||||
if can_promote_success or can_promote_failure:
|
||||
set_status(
|
||||
db,
|
||||
current,
|
||||
order,
|
||||
target,
|
||||
rollback=order.status == STATUS_FAILED,
|
||||
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] = {
|
||||
@@ -2355,6 +2508,7 @@ def query_order_activity(
|
||||
"score_used": used,
|
||||
"records_count": len(records),
|
||||
"matched": matched[:5],
|
||||
"result_settle_at": settle_at.isoformat() if settle_at else None,
|
||||
"records": records[:_METRIC_RECORD_LIMIT],
|
||||
}
|
||||
if score_resp is not None:
|
||||
|
||||
@@ -1733,6 +1733,8 @@ export interface HuyaCollabRechargeResult {
|
||||
updated: number;
|
||||
failed: number;
|
||||
threshold: number;
|
||||
confirmed_groups: number;
|
||||
confirmation_delay_seconds: number;
|
||||
groups: HuyaCollabRechargeGroup[];
|
||||
failures: HuyaCollabRechargeFailure[];
|
||||
}
|
||||
|
||||
@@ -1310,7 +1310,11 @@ export default function HuyaCollabPage() {
|
||||
message.warning('没有可核账的订单');
|
||||
} else {
|
||||
modal.info({
|
||||
title: `核账完成:更新 ${result.updated} 条,失败 ${result.failed} 条`,
|
||||
title: `核账完成:更新 ${result.updated} 条,失败 ${result.failed} 条${
|
||||
result.confirmed_groups
|
||||
? `;二次确认 ${result.confirmed_groups} 组(等待 ${result.confirmation_delay_seconds} 秒)`
|
||||
: ''
|
||||
}`,
|
||||
width: 680,
|
||||
content: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
|
||||
Reference in New Issue
Block a user