fix(huya): 修复外部单积分复核与抢购结果误判
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user