fix(weibo): 隔离换绑角色的微豆核账

This commit is contained in:
yml
2026-11-13 17:20:44 +08:00
parent d2550d4ab7
commit b89ee4fd18
3 changed files with 267 additions and 99 deletions
+121
View File
@@ -172,6 +172,127 @@ def test_exchange_record_unique_match_and_archive(monkeypatch, db):
assert any(item["action"] == "weibo:collab:archive" for item in timeline) assert any(item["action"] == "weibo:collab:archive" for item in timeline)
def test_exchange_success_promotes_weidou_error_to_done(monkeypatch, db):
user = make_user(db)
account = make_account(db)
order = collab.create_order(
db,
user,
order_no="WB-ERROR-DONE",
shop="店铺A",
account_id=account.id,
goods_name="商品A",
required_weidou=100,
require_verified=False,
)
collab.set_status(
db,
user,
order,
collab.STATUS_WEIDOU_ERROR,
allow_check=True,
)
class FakeClient:
def __init__(self, *_args, **_kwargs):
pass
def list_exchange_records(self, *_args, **_kwargs):
return {
"list": [
{
"goods_name": "商品A",
"game_account": "角色A",
"cost_weidou": 100,
"exchange_time": "2099-01-01 00:00:00",
"reward_status": 2,
}
]
}
monkeypatch.setattr(collab, "WeiboClient", FakeClient)
result = collab.check_exchange_records(db, user, [order])
db.refresh(order)
assert result["success"] == 1
assert order.exchange_verdict == collab.VERDICT_SUCCESS
assert order.status == collab.STATUS_DONE
assert order.done_at is not None
assert collab.summary(db)["by_status"] == {collab.STATUS_DONE: 1}
def test_balance_check_does_not_mix_roles_after_rebind(monkeypatch, db):
user = make_user(db)
account = make_account(db)
first = collab.create_order(
db,
user,
order_no="WB-ROLE-A",
shop="店铺A",
account_id=account.id,
goods_name="商品A",
required_weidou=100,
require_verified=False,
)
collab.set_status(
db,
user,
first,
collab.STATUS_WEIDOU_ERROR,
allow_check=True,
)
account.game_role_name = "角色B"
db.commit()
second = collab.create_order(
db,
user,
order_no="WB-ROLE-B",
shop="店铺A",
account_id=account.id,
goods_name="商品B",
required_weidou=200,
require_verified=False,
)
class FakeClient:
calls = 0
def __init__(self, *_args, **_kwargs):
pass
def list_mall(self, *_args, **_kwargs):
FakeClient.calls += 1
return {"weidou_balance": 200, "list": []}
monkeypatch.setattr(collab, "WeiboClient", FakeClient)
result = collab.check_balance_groups(db, user, [second], threshold=100)
db.refresh(first)
db.refresh(second)
assert result["groups"] == [
{
"account_id": account.id,
"uid": account.uid,
"game_role_name": "角色B",
"status": collab.STATUS_RECHARGED,
"status_label": collab.ORDER_STATUS_LABELS[collab.STATUS_RECHARGED],
"balance": 200,
"required_total": 200,
"surplus": 0,
"threshold": 100,
"reason": "ok",
"message": "核账通过:角色「角色B」,余额 200,订单合计 200,结余 0",
"order_ids": [second.id],
}
]
assert FakeClient.calls == 1
assert first.status == collab.STATUS_WEIDOU_ERROR
assert second.status == collab.STATUS_RECHARGED
def test_export_cks_returns_plaintext_and_audits_without_ck(db): def test_export_cks_returns_plaintext_and_audits_without_ck(db):
user = make_user(db) user = make_user(db)
account = make_account(db) account = make_account(db)
+145 -99
View File
@@ -82,6 +82,14 @@ BALANCE_GROUP_STATUSES = (
STATUS_WEIDOU_ERROR, STATUS_WEIDOU_ERROR,
) )
AUTO_DONE_SOURCE_STATUSES = (
STATUS_TO_RECHARGE,
STATUS_RECHARGED,
STATUS_SCRIPTED,
STATUS_WEIDOU_ERROR,
STATUS_FAILED,
)
VERDICT_SUCCESS = "success" VERDICT_SUCCESS = "success"
VERDICT_FAILED = "failed" VERDICT_FAILED = "failed"
VERDICT_UNKNOWN = "unknown" VERDICT_UNKNOWN = "unknown"
@@ -935,7 +943,8 @@ def check_balance_groups(
if len(rows) > CHECK_BATCH_MAX: if len(rows) > CHECK_BATCH_MAX:
raise CollabError(f"一次最多核账 {CHECK_BATCH_MAX} 条", code="batch_too_large") raise CollabError(f"一次最多核账 {CHECK_BATCH_MAX} 条", code="batch_too_large")
threshold = max(0, min(int(threshold), 1_000_000)) threshold = max(0, min(int(threshold), 1_000_000))
grouped: dict[int, list[WeiboExternalOrder]] = {} selected_keys: list[tuple[int, str]] = []
grouped: dict[tuple[int, str], list[WeiboExternalOrder]] = {}
failures: list[dict[str, Any]] = [] failures: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = [] skipped: list[dict[str, Any]] = []
for order in rows: for order in rows:
@@ -958,28 +967,49 @@ def check_balance_groups(
} }
) )
continue continue
grouped.setdefault(int(order.account_id), []).append(order) key = (
int(order.account_id),
_text(order.game_role_name, max_length=128),
)
if key not in grouped:
selected_keys.append(key)
grouped.setdefault(key, []).append(order)
selected_account_ids = list(dict.fromkeys(key[0] for key in selected_keys))
keys_by_account: dict[int, list[tuple[int, str]]] = {}
for key in selected_keys:
keys_by_account.setdefault(key[0], []).append(key)
selected_account_ids = set(grouped)
grouped = {} grouped = {}
for account_id in selected_account_ids: if selected_account_ids:
grouped[account_id] = ( for row in (
db.query(WeiboExternalOrder) db.query(WeiboExternalOrder)
.filter( .filter(
WeiboExternalOrder.account_id == account_id, WeiboExternalOrder.account_id.in_(selected_account_ids),
WeiboExternalOrder.status.in_(BALANCE_GROUP_STATUSES), WeiboExternalOrder.status.in_(BALANCE_GROUP_STATUSES),
WeiboExternalOrder.archived_at.is_(None), WeiboExternalOrder.archived_at.is_(None),
) )
.order_by(WeiboExternalOrder.id.asc()) .order_by(WeiboExternalOrder.id.asc())
.all() .all()
) ):
if not row.account_id:
continue
key = (
int(row.account_id),
_text(row.game_role_name, max_length=128),
)
grouped.setdefault(key, []).append(row)
groups: list[dict[str, Any]] = [] groups: list[dict[str, Any]] = []
updated = 0 updated = 0
for account_id, group_orders in grouped.items(): for account_id in selected_account_ids:
account_keys = keys_by_account.get(account_id, [])
account_orders = [
order for key in account_keys for order in grouped.get(key, [])
]
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first() account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first()
if account is None: if account is None:
for order in group_orders: for order in account_orders:
failures.append( failures.append(
{ {
"id": order.id, "id": order.id,
@@ -989,7 +1019,7 @@ def check_balance_groups(
) )
continue continue
if not account.is_enabled: if not account.is_enabled:
for order in group_orders: for order in account_orders:
failures.append( failures.append(
{ {
"id": order.id, "id": order.id,
@@ -1001,114 +1031,130 @@ def check_balance_groups(
try: try:
data = WeiboClient(account.cookie or "").list_mall(activity) data = WeiboClient(account.cookie or "").list_mall(activity)
except (WeiboError, ValueError) as exc: except (WeiboError, ValueError) as exc:
for order in group_orders: for order in account_orders:
failures.append( failures.append(
{"id": order.id, "order_no": order.order_no, "message": str(exc)} {"id": order.id, "order_no": order.order_no, "message": str(exc)}
) )
continue continue
balance = _to_int(data.get("weidou_balance")) balance = _to_int(data.get("weidou_balance"))
required_total = sum(int(order.required_weidou or 0) for order in group_orders)
surplus = balance - required_total if balance is not None else None
if surplus is None:
reason = "error"
message = "未拿到微豆余额"
target = None
elif surplus < 0:
reason = "shortage"
message = (
f"微豆不足:余额 {balance},订单合计 {required_total},还差 {-surplus}"
)
target = STATUS_WEIDOU_ERROR
elif surplus > threshold:
reason = "excess"
message = (
f"微豆超出阈值:余额 {balance},订单合计 {required_total},"
f"多出 {surplus}(阈值 {threshold})"
)
target = STATUS_WEIDOU_ERROR
else:
reason = "ok"
message = (
f"核账通过:余额 {balance},订单合计 {required_total},结余 {surplus}"
)
target = STATUS_RECHARGED
if balance is not None: if balance is not None:
account.weidou_balance = balance account.weidou_balance = balance
now = _now() for account_key in account_keys:
for order in group_orders: game_role_name = account_key[1]
order.weidou_balance = balance group_orders = grouped.get(account_key, [])
order.weidou_required_total = required_total if not group_orders:
order.weidou_surplus = surplus continue
order.weidou_threshold = threshold required_total = sum(
order.weidou_checked_at = now int(order.required_weidou or 0) for order in group_orders
order.weidou_check_reason = reason )
order.weidou_check_message = message[:512] surplus = balance - required_total if balance is not None else None
order.weidou_check_detail = { if surplus is None:
"account_id": account.id, reason = "error"
"uid": account.uid, message = "未拿到微豆余额"
"balance": balance, target = None
"required_total": required_total, elif surplus < 0:
"surplus": surplus, reason = "shortage"
"threshold": threshold, message = (
"order_ids": [item.id for item in group_orders], f"微豆不足:角色「{game_role_name or '未绑定'}」,余额 {balance},"
} f"订单合计 {required_total},还差 {-surplus}"
if target is not None: )
set_status( target = STATUS_WEIDOU_ERROR
elif surplus > threshold:
reason = "excess"
message = (
f"微豆超出阈值:角色「{game_role_name or '未绑定'}」,余额 {balance},"
f"订单合计 {required_total},多出 {surplus}(阈值 {threshold})"
)
target = STATUS_WEIDOU_ERROR
else:
reason = "ok"
message = (
f"核账通过:角色「{game_role_name or '未绑定'}」,余额 {balance},"
f"订单合计 {required_total},结余 {surplus}"
)
target = STATUS_RECHARGED
now = _now()
for order in group_orders:
order.weidou_balance = balance
order.weidou_required_total = required_total
order.weidou_surplus = surplus
order.weidou_threshold = threshold
order.weidou_checked_at = now
order.weidou_check_reason = reason
order.weidou_check_message = message[:512]
order.weidou_check_detail = {
"account_id": account.id,
"uid": account.uid,
"role": game_role_name,
"balance": balance,
"required_total": required_total,
"surplus": surplus,
"threshold": threshold,
"order_ids": [item.id for item in group_orders],
}
if target is not None:
set_status(
db,
current,
order,
target,
reason=message,
allow_check=True,
commit=False,
)
order.recharged_at = now
updated += 1
_metric(
db, db,
current, current,
order, order,
target, balance=balance,
reason=message, required_total=required_total,
allow_check=True, surplus=surplus,
commit=False, records_total=0,
records_matched=0,
verdict=VERDICT_UNKNOWN if target is None else "",
message=message,
source="balance_check",
raw={
"reason": reason,
"role": game_role_name,
"order_ids": [item.id for item in group_orders],
},
) )
order.recharged_at = now _audit(
updated += 1 db,
_metric( current,
db, order,
current, "weibo:collab:balance_check",
order, {
balance=balance, "account_id": account.id,
required_total=required_total, "role": game_role_name,
surplus=surplus, "balance": balance,
records_total=0, "required_total": required_total,
records_matched=0, "surplus": surplus,
verdict=VERDICT_UNKNOWN if target is None else "", "threshold": threshold,
message=message, "reason": reason,
source="balance_check", },
raw={"reason": reason, "order_ids": [item.id for item in group_orders]}, )
) groups.append(
_audit(
db,
current,
order,
"weibo:collab:balance_check",
{ {
"account_id": account.id, "account_id": account.id,
"uid": account.uid,
"game_role_name": game_role_name,
"status": target or "",
"status_label": ORDER_STATUS_LABELS.get(target or "", ""),
"balance": balance, "balance": balance,
"required_total": required_total, "required_total": required_total,
"surplus": surplus, "surplus": surplus,
"threshold": threshold, "threshold": threshold,
"reason": reason, "reason": reason,
}, "message": message,
"order_ids": [item.id for item in group_orders],
}
) )
groups.append(
{
"account_id": account.id,
"uid": account.uid,
"status": target or "",
"status_label": ORDER_STATUS_LABELS.get(target or "", ""),
"balance": balance,
"required_total": required_total,
"surplus": surplus,
"threshold": threshold,
"reason": reason,
"message": message,
"order_ids": [item.id for item in group_orders],
}
)
db.commit() db.commit()
return { return {
"total": len(rows), "total": len(rows),
@@ -1231,7 +1277,7 @@ def check_exchange_records(
order.exchange_record_snapshot = _json_safe(record) order.exchange_record_snapshot = _json_safe(record)
order.exchange_verdict = VERDICT_SUCCESS order.exchange_verdict = VERDICT_SUCCESS
order.exchange_message = "兑换记录唯一命中" order.exchange_message = "兑换记录唯一命中"
if order.status in (STATUS_RECHARGED, STATUS_SCRIPTED): if order.status in AUTO_DONE_SOURCE_STATUSES:
set_status( set_status(
db, db,
current, current,
+1
View File
@@ -248,6 +248,7 @@ export interface WeiboCollabBalanceCheckResult {
groups: Array<{ groups: Array<{
account_id: number; account_id: number;
uid: string; uid: string;
game_role_name: string;
status: string; status: string;
status_label: string; status_label: string;
balance: number | null; balance: number | null;