diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index 38f1b15..955816a 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -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` 可统计 | diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 57e4283..7f9e8a6 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -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="不朽伊人大礼包") diff --git a/web/backend/schemas.py b/web/backend/schemas.py index b42d19a..f7e6421 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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) diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index ca50227..0379b00 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -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: diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 6437752..2e16a1b 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1733,6 +1733,8 @@ export interface HuyaCollabRechargeResult { updated: number; failed: number; threshold: number; + confirmed_groups: number; + confirmation_delay_seconds: number; groups: HuyaCollabRechargeGroup[]; failures: HuyaCollabRechargeFailure[]; } diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index c13e833..8c1d200 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -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: (