fix(huya): 修复外部单积分复核与抢购结果误判

This commit is contained in:
yml
2026-11-11 18:41:16 +08:00
parent 26809dd116
commit c9814ab4d2
6 changed files with 372 additions and 50 deletions
+13 -6
View File
@@ -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` 可统计 |
+156 -3
View File
@@ -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="不朽伊人大礼包")
+2
View File
@@ -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)
+194 -40
View File
@@ -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:
+2
View File
@@ -1733,6 +1733,8 @@ export interface HuyaCollabRechargeResult {
updated: number;
failed: number;
threshold: number;
confirmed_groups: number;
confirmation_delay_seconds: number;
groups: HuyaCollabRechargeGroup[];
failures: HuyaCollabRechargeFailure[];
}
+5 -1
View File
@@ -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%' }}>