Files
live-hub-py/web/backend/services/weibo_collab_service.py
T

1858 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""微博外部单协作台账服务。
一行 = 一单,引用现有 ``weibo_accounts`` 中的外部 CK 账号,不复制凭据。
账号可以同时服务多单;余额核账按账号分组。
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from core.weibo import WeiboClient, WeiboError
from ..models import (
AuditLog,
User,
WeiboAccount,
WeiboCollabGoods,
WeiboCollabShop,
WeiboExternalOrder,
WeiboExternalOrderMetric,
)
from .audit_service import record_audit
from .weibo_service import query_weibo_mall, refresh_weibo_account
STATUS_INCOMPLETE = "incomplete"
STATUS_TO_RECHARGE = "to_recharge"
STATUS_RECHARGED = "recharged"
STATUS_EXCHANGE_PENDING = "exchange_pending"
STATUS_DONE = "done"
STATUS_WEIDOU_ERROR = "weidou_error"
STATUS_FAILED = "failed"
STATUS_VOID = "void"
ORDER_STATUSES = (
STATUS_INCOMPLETE,
STATUS_TO_RECHARGE,
STATUS_RECHARGED,
STATUS_EXCHANGE_PENDING,
STATUS_DONE,
STATUS_WEIDOU_ERROR,
STATUS_FAILED,
STATUS_VOID,
)
ORDER_STATUS_LABELS = {
STATUS_INCOMPLETE: "待补资料",
STATUS_TO_RECHARGE: "待充值",
STATUS_RECHARGED: "已充值",
STATUS_EXCHANGE_PENDING: "兑换待确认",
STATUS_DONE: "兑换成功",
STATUS_WEIDOU_ERROR: "微豆异常",
STATUS_FAILED: "兑换失败",
STATUS_VOID: "已作废",
}
STATUS_FLOW = (
STATUS_INCOMPLETE,
STATUS_TO_RECHARGE,
STATUS_RECHARGED,
STATUS_EXCHANGE_PENDING,
STATUS_DONE,
)
STAGE_INDEX = {status: index for index, status in enumerate(STATUS_FLOW)}
NEXT_STATUS = {
STATUS_INCOMPLETE: STATUS_TO_RECHARGE,
STATUS_TO_RECHARGE: STATUS_RECHARGED,
STATUS_RECHARGED: STATUS_EXCHANGE_PENDING,
STATUS_EXCHANGE_PENDING: STATUS_DONE,
}
BALANCE_GROUP_STATUSES = (
STATUS_TO_RECHARGE,
STATUS_RECHARGED,
STATUS_WEIDOU_ERROR,
)
VERDICT_SUCCESS = "success"
VERDICT_FAILED = "failed"
VERDICT_UNKNOWN = "unknown"
VERDICT_LABELS = {
VERDICT_SUCCESS: "兑换成功",
VERDICT_FAILED: "兑换失败",
VERDICT_UNKNOWN: "无法判定",
}
ARCHIVE_SCOPES = ("active", "archived", "all")
ARCHIVABLE_STATUSES = (STATUS_DONE,)
MAX_IMAGES = 20
CHECK_BATCH_MAX = 50
ARCHIVE_BATCH_MAX = 5000
DEFAULT_WEIDOU_THRESHOLD = 100
class CollabError(RuntimeError):
def __init__(self, detail: str, *, status_code: int = 400, code: str = ""):
super().__init__(detail)
self.detail = detail
self.status_code = status_code
self.code = code
def _now() -> datetime:
return datetime.now(UTC)
def _to_int(value: Any) -> int | None:
if value is None or value == "":
return None
try:
return int(float(value))
except (TypeError, ValueError):
return None
def _text(value: Any, *, max_length: int = 512) -> str:
return str(value or "").strip()[:max_length]
def _json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def normalize_images(value: Any) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise CollabError("图片必须是 key 数组", code="images_invalid")
result: list[str] = []
for item in value:
key = _text(item, max_length=512)
if key and key not in result:
result.append(key)
if len(result) > MAX_IMAGES:
raise CollabError(f"最多 {MAX_IMAGES} 张图片", code="images_too_many")
return result
def _users_by_id(db: Session, ids: set[int]) -> dict[int, str]:
clean = {int(item) for item in ids if item}
if not clean:
return {}
return {
int(user_id): str(username)
for user_id, username in db.query(User.id, User.username)
.filter(User.id.in_(clean))
.all()
}
def _accounts_by_id(db: Session, ids: set[int]) -> dict[int, WeiboAccount]:
clean = {int(item) for item in ids if item}
if not clean:
return {}
return {
int(account.id): account
for account in db.query(WeiboAccount).filter(WeiboAccount.id.in_(clean)).all()
}
def _touch(order: WeiboExternalOrder, current: User | None) -> None:
order.updated_by = current.id if current else None
order.version = int(order.version or 0) + 1
def _audit_target(order: WeiboExternalOrder) -> str:
return f"weibo_external_order:{order.id}"
def _audit(
db: Session,
current: User | None,
order: WeiboExternalOrder,
action: str,
detail: Mapping[str, Any],
) -> None:
record_audit(
db,
current,
action=action,
target=_audit_target(order),
detail=detail,
)
def get_order(db: Session, order_id: int) -> WeiboExternalOrder:
order = (
db.query(WeiboExternalOrder).filter(WeiboExternalOrder.id == order_id).first()
)
if order is None:
raise CollabError("协作单不存在", status_code=404, code="order_not_found")
return order
def list_orders_by_ids(db: Session, ids: Sequence[int]) -> list[WeiboExternalOrder]:
clean = [int(item) for item in ids if int(item) > 0]
if not clean:
return []
return (
db.query(WeiboExternalOrder)
.filter(WeiboExternalOrder.id.in_(clean))
.order_by(WeiboExternalOrder.id.asc())
.all()
)
def _missing_for_recharge(order: WeiboExternalOrder) -> list[str]:
missing: list[str] = []
if not order.shop:
missing.append("店铺")
if not order.account_id:
missing.append("微博账号")
if not order.game_role_name:
missing.append("绑定角色(先校验账号)")
if not order.goods_name:
missing.append("商品")
if not order.required_weidou:
missing.append("所需微豆")
return missing
def _maybe_advance_to_recharge(
db: Session,
current: User,
order: WeiboExternalOrder,
) -> None:
if order.status != STATUS_INCOMPLETE or _missing_for_recharge(order):
return
old = order.status
order.status = STATUS_TO_RECHARGE
_touch(order, current)
_audit(
db,
current,
order,
"weibo:collab:status_auto",
{"from": old, "to": order.status, "reason": "资料齐全"},
)
def _account_for_order(db: Session, order: WeiboExternalOrder) -> WeiboAccount:
account = (
db.query(WeiboAccount).filter(WeiboAccount.id == order.account_id).first()
if order.account_id
else None
)
if account is None:
raise CollabError("订单关联的微博账号不存在", code="account_not_found")
if not account.is_enabled:
raise CollabError("订单关联的微博账号已停用", code="account_disabled")
return account
def _enabled_account(db: Session, account_id: int) -> WeiboAccount:
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first()
if account is None:
raise CollabError("微博账号不存在", code="account_not_found")
if not account.is_enabled:
raise CollabError("微博账号已停用", code="account_disabled")
return account
def _apply_account_snapshot(
order: WeiboExternalOrder,
account: WeiboAccount,
*,
strict_role: bool,
) -> None:
role = _text(account.game_role_name, max_length=128)
if strict_role and order.game_role_name and role != order.game_role_name:
raise CollabError(
f"账号绑定角色已从「{order.game_role_name}」变为「{role or '未绑定'}」,请人工确认后更新订单",
code="role_changed",
)
order.account_id = account.id
order.account_uid = _text(account.uid, max_length=32)
order.account_nickname = _text(account.nickname, max_length=128)
order.game_role_name = role
order.bind_snapshot_at = _now()
def verify_order_account(
db: Session,
current: User,
order: WeiboExternalOrder,
*,
activity: str = "",
commit: bool = True,
) -> WeiboAccount:
if order.archived_at is not None:
raise CollabError("已归档订单不能校验账号", code="order_archived")
account = _account_for_order(db, order)
snapshot = refresh_weibo_account(db, account, activity=activity, commit=False)
_apply_account_snapshot(order, account, strict_role=True)
if not snapshot.is_bound:
raise CollabError("微博账号尚未绑定游戏角色", code="role_unbound")
_touch(order, current)
_audit(
db,
current,
order,
"weibo:collab:verify",
{
"account_id": account.id,
"uid": account.uid,
"ck_status": account.ck_check_status,
"role": account.game_role_name,
"balance": account.weidou_balance,
},
)
_maybe_advance_to_recharge(db, current, order)
if commit:
db.commit()
db.refresh(order)
return account
def _resolve_goods(
db: Session,
*,
goods_id: int | None,
goods_name: str,
goods_image: str,
required_weidou: int | None,
) -> tuple[int | None, str, str, int | None]:
name = _text(goods_name, max_length=128)
image = _text(goods_image, max_length=512)
price = _to_int(required_weidou)
catalog = None
if name:
catalog = (
db.query(WeiboCollabGoods).filter(WeiboCollabGoods.name == name).first()
)
elif goods_id:
catalog = (
db.query(WeiboCollabGoods)
.filter(WeiboCollabGoods.goods_id == goods_id)
.first()
)
if catalog is not None:
name = name or catalog.name
image = image or catalog.goods_image
goods_id = goods_id or catalog.goods_id
if price is None:
price = int(catalog.price or 0)
return goods_id, name, image, price
def create_order(
db: Session,
current: User,
*,
order_no: str,
shop: str = "",
owner_id: int | None = None,
account_id: int | None = None,
goods_id: int | None = None,
goods_name: str = "",
goods_image: str = "",
required_weidou: int | None = None,
home_images: Any = None,
remark: str = "",
draft: bool = False,
require_verified: bool = True,
activity: str = "",
) -> WeiboExternalOrder:
order_text = _text(order_no, max_length=64)
if not order_text:
raise CollabError("订单号不能为空", code="order_no_required")
if (
db.query(WeiboExternalOrder)
.filter(WeiboExternalOrder.order_no == order_text)
.first()
):
raise CollabError("订单号已存在", status_code=409, code="order_no_exists")
goods_id, goods_name, goods_image, required_weidou = _resolve_goods(
db,
goods_id=goods_id,
goods_name=goods_name,
goods_image=goods_image,
required_weidou=required_weidou,
)
order = WeiboExternalOrder(
order_no=order_text,
shop=_text(shop, max_length=64),
owner_id=int(owner_id) if owner_id is not None else int(current.id),
goods_id=goods_id,
goods_name=goods_name,
goods_image=goods_image,
required_weidou=required_weidou,
home_images=normalize_images(home_images),
remark=_text(remark, max_length=512),
status=STATUS_INCOMPLETE,
weidou_threshold=DEFAULT_WEIDOU_THRESHOLD,
created_by=current.id,
updated_by=current.id,
version=0,
)
db.add(order)
db.flush()
if account_id:
account = _enabled_account(db, account_id)
_apply_account_snapshot(order, account, strict_role=False)
if order.shop:
ensure_shops_registered(db, current, [order.shop])
_audit(
db,
current,
order,
"weibo:collab:create",
{
"order_no": order.order_no,
"shop": order.shop,
"account_id": order.account_id,
"uid": order.account_uid,
"goods_name": order.goods_name,
"required_weidou": order.required_weidou,
"draft": draft,
},
)
if not draft and require_verified:
missing = _missing_for_recharge(order)
if missing:
raise CollabError(
f"下面这几项必须填:{'、'.join(missing)}",
code="create_field_required",
)
verify_order_account(db, current, order, activity=activity, commit=False)
_maybe_advance_to_recharge(db, current, order)
db.commit()
db.refresh(order)
return order
def update_order(
db: Session,
current: User,
order: WeiboExternalOrder,
payload: Mapping[str, Any],
*,
expected_version: int | None = None,
) -> dict[str, Any]:
_assert_mutable(order)
_assert_version(order, expected_version)
changes: dict[str, dict[str, Any]] = {}
editable = {
"order_no",
"shop",
"owner_id",
"goods_id",
"goods_name",
"goods_image",
"required_weidou",
"remark",
}
for field in editable:
if field not in payload:
continue
old = getattr(order, field)
value = payload[field]
if field == "owner_id":
new = int(value) if value is not None else None
elif field in ("goods_id", "required_weidou"):
new = _to_int(value)
elif field == "remark" or field == "goods_image":
new = _text(value, max_length=512)
elif field == "goods_name":
new = _text(value, max_length=128)
elif field == "order_no":
new = _text(value, max_length=64)
else:
new = _text(value, max_length=64)
if new != old:
changes[field] = {"old": old, "new": new}
setattr(order, field, new)
if "home_images" in payload:
images = normalize_images(payload.get("home_images"))
if images != list(order.home_images or []):
changes["home_images"] = {
"old": len(order.home_images or []),
"new": len(images),
}
order.home_images = images
if order.order_no != _text(order.order_no, max_length=64):
raise CollabError("订单号格式不正确", code="order_no_invalid")
duplicate = (
db.query(WeiboExternalOrder.id)
.filter(
WeiboExternalOrder.order_no == order.order_no,
WeiboExternalOrder.id != order.id,
)
.first()
)
if duplicate:
raise CollabError("订单号已存在", status_code=409, code="order_no_exists")
if changes:
if "shop" in changes and order.shop:
ensure_shops_registered(db, current, [order.shop])
_touch(order, current)
_audit(db, current, order, "weibo:collab:update", changes)
_maybe_advance_to_recharge(db, current, order)
db.commit()
db.refresh(order)
return changes
def set_order_account(
db: Session,
current: User,
order: WeiboExternalOrder,
account_id: int,
*,
expected_version: int | None = None,
activity: str = "",
) -> WeiboAccount:
_assert_mutable(order)
_assert_version(order, expected_version)
account = _enabled_account(db, account_id)
order.account_id = account.id
order.game_role_name = ""
verify_order_account(db, current, order, activity=activity, commit=False)
db.commit()
db.refresh(order)
return account
def _assert_version(order: WeiboExternalOrder, expected_version: int | None) -> None:
if expected_version is None:
return
if int(expected_version) != int(order.version or 0):
raise CollabError(
"订单已被其他人修改,请刷新后重试",
status_code=409,
code="version_conflict",
)
def _assert_mutable(order: WeiboExternalOrder) -> None:
if order.archived_at is not None:
raise CollabError("已归档订单只读,请先取消归档", code="order_archived")
def set_status(
db: Session,
current: User,
order: WeiboExternalOrder,
target: str,
*,
reason: str = "",
rollback: bool = False,
allow_check: bool = False,
commit: bool = True,
) -> dict[str, Any]:
_assert_mutable(order)
if target not in ORDER_STATUSES:
raise CollabError("非法状态", code="status_invalid")
if target in (STATUS_RECHARGED, STATUS_WEIDOU_ERROR) and not allow_check:
raise CollabError(
"「已充值 / 微豆异常」必须通过余额核账写入",
code="balance_check_required",
)
old = order.status
if old == target:
return {"from": old, "to": target}
if old == STATUS_VOID:
raise CollabError("已作废订单不能直接改回流程内", code="order_void")
if target == STATUS_VOID:
clean_reason = _text(reason, max_length=256)
if not clean_reason:
raise CollabError("作废必须填写原因", code="void_reason_required")
order.status = target
order.void_reason = clean_reason
_touch(order, current)
_audit(
db,
current,
order,
"weibo:collab:void",
{"from": old, "reason": clean_reason},
)
if commit:
db.commit()
db.refresh(order)
return {"from": old, "to": target}
if (
target in STAGE_INDEX
and old in STAGE_INDEX
and STAGE_INDEX[target] < STAGE_INDEX[old]
and not rollback
):
raise CollabError("回退状态必须显式勾选「回退」", code="status_not_forward")
if target == STATUS_TO_RECHARGE:
missing = _missing_for_recharge(order)
if missing:
raise CollabError(
f"进入待充值前还缺:{'、'.join(missing)}",
code="recharge_precondition",
)
now = _now()
if target == STATUS_TO_RECHARGE:
order.recharged_at = None
order.exchange_started_at = None
order.done_at = None
elif target in (STATUS_RECHARGED, STATUS_WEIDOU_ERROR):
if order.recharged_at is None:
order.recharged_at = now
if rollback:
order.exchange_started_at = None
order.done_at = None
elif target == STATUS_EXCHANGE_PENDING and not order.recharged_at:
raise CollabError("请先完成余额核账", code="recharge_check_required")
elif target == STATUS_EXCHANGE_PENDING:
if order.exchange_started_at is None:
order.exchange_started_at = now
order.done_at = None
elif target == STATUS_DONE:
if order.exchange_started_at is None:
order.exchange_started_at = now
order.done_at = now
elif target == STATUS_FAILED:
order.done_at = None
order.status = target
if target != STATUS_VOID:
order.void_reason = ""
_touch(order, current)
_audit(
db,
current,
order,
"weibo:collab:status",
{"from": old, "to": target, "rollback": rollback, "reason": reason},
)
if commit:
db.commit()
db.refresh(order)
return {"from": old, "to": target}
def set_status_batch(
db: Session,
current: User,
orders: Sequence[WeiboExternalOrder],
target: str,
*,
reason: str = "",
rollback: bool = False,
) -> dict[str, Any]:
rows = list(orders)
if not rows:
raise CollabError("请先勾选订单", code="status_batch_empty")
if len(rows) > CHECK_BATCH_MAX:
raise CollabError(f"一次最多操作 {CHECK_BATCH_MAX} 条", code="batch_too_large")
results: list[dict[str, Any]] = []
updated = 0
for order in rows:
try:
with db.begin_nested():
set_status(
db,
current,
order,
target,
reason=reason,
rollback=rollback,
allow_check=False,
commit=False,
)
updated += 1
results.append(
{
"id": order.id,
"order_no": order.order_no,
"ok": True,
"message": "",
}
)
except CollabError as exc:
results.append(
{
"id": order.id,
"order_no": order.order_no,
"ok": False,
"message": exc.detail,
}
)
db.commit()
return {
"total": len(rows),
"updated": updated,
"failed": len(rows) - updated,
"target": target,
"target_label": ORDER_STATUS_LABELS.get(target, target),
"results": results,
}
def lookup_account(
db: Session,
account_id: int,
*,
with_goods: bool,
activity: str = "",
) -> dict[str, Any]:
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first()
if account is None:
raise CollabError("微博账号不存在", code="account_not_found")
goods: list[dict[str, Any]] = []
ok = account.is_enabled
message = "" if ok else "账号已停用"
if ok and with_goods:
try:
data = query_weibo_mall(db, account, activity=activity)
for item in data.get("list") or []:
if not isinstance(item, dict):
continue
goods_id = _to_int(item.get("id"))
if not goods_id:
continue
goods.append(
{
"id": goods_id,
"goods_name": _text(item.get("goods_name"), max_length=128),
"goods_image": _text(item.get("goods_image"), max_length=512),
"price": _to_int(item.get("price")) or 0,
"remain_count": _to_int(item.get("remain_count")),
"is_sold_out": bool(item.get("is_sold_out")),
}
)
except (WeiboError, ValueError) as exc:
ok = False
message = str(exc)
orders_using = (
db.query(WeiboExternalOrder)
.filter(
WeiboExternalOrder.account_id == account.id,
WeiboExternalOrder.archived_at.is_(None),
WeiboExternalOrder.status != STATUS_VOID,
)
.order_by(WeiboExternalOrder.id.asc())
.all()
)
return {
"ok": ok,
"message": message,
"account_id": account.id,
"uid": account.uid,
"nickname": account.nickname,
"is_enabled": account.is_enabled,
"ck_check_status": account.ck_check_status,
"ck_check_message": account.ck_check_message,
"is_bound": account.is_bound,
"game_role_name": account.game_role_name,
"weidou_balance": account.weidou_balance,
"goods": goods,
"orders_using_account": [
{
"id": item.id,
"order_no": item.order_no,
"status": item.status,
"status_label": ORDER_STATUS_LABELS.get(item.status, item.status),
}
for item in orders_using
],
}
def _metric(
db: Session,
current: User,
order: WeiboExternalOrder,
*,
balance: int | None,
required_total: int | None,
surplus: int | None,
records_total: int,
records_matched: int,
verdict: str,
message: str,
source: str,
raw: Mapping[str, Any],
) -> WeiboExternalOrderMetric:
metric = WeiboExternalOrderMetric(
order_id=order.id,
order_no=order.order_no,
account_id=order.account_id,
account_uid=order.account_uid,
weidou_balance=balance,
weidou_required_total=required_total,
weidou_surplus=surplus,
records_total=records_total,
records_matched=records_matched,
verdict=verdict,
message=message[:512],
source=source,
raw=_json_safe(dict(raw)),
created_by=current.id,
)
db.add(metric)
return metric
def check_balance_groups(
db: Session,
current: User,
orders: Sequence[WeiboExternalOrder],
*,
threshold: int,
activity: str = "",
) -> dict[str, Any]:
rows = list(orders)
if not rows:
raise CollabError("请先勾选订单", code="balance_check_empty")
if len(rows) > CHECK_BATCH_MAX:
raise CollabError(f"一次最多核账 {CHECK_BATCH_MAX} 条", code="batch_too_large")
threshold = max(0, min(int(threshold), 1_000_000))
grouped: dict[int, list[WeiboExternalOrder]] = {}
failures: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
for order in rows:
if order.archived_at is not None:
skipped.append(
{"id": order.id, "order_no": order.order_no, "message": "已归档"}
)
continue
if not order.account_id:
failures.append(
{"id": order.id, "order_no": order.order_no, "message": "未关联账号"}
)
continue
if order.status not in BALANCE_GROUP_STATUSES:
skipped.append(
{
"id": order.id,
"order_no": order.order_no,
"message": f"状态「{ORDER_STATUS_LABELS.get(order.status, order.status)}」不参与核账",
}
)
continue
grouped.setdefault(int(order.account_id), []).append(order)
selected_account_ids = set(grouped)
grouped = {}
for account_id in selected_account_ids:
grouped[account_id] = (
db.query(WeiboExternalOrder)
.filter(
WeiboExternalOrder.account_id == account_id,
WeiboExternalOrder.status.in_(BALANCE_GROUP_STATUSES),
WeiboExternalOrder.archived_at.is_(None),
)
.order_by(WeiboExternalOrder.id.asc())
.all()
)
groups: list[dict[str, Any]] = []
updated = 0
for account_id, group_orders in grouped.items():
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first()
if account is None:
for order in group_orders:
failures.append(
{
"id": order.id,
"order_no": order.order_no,
"message": "账号不存在",
}
)
continue
if not account.is_enabled:
for order in group_orders:
failures.append(
{
"id": order.id,
"order_no": order.order_no,
"message": "账号已停用",
}
)
continue
try:
data = WeiboClient(account.cookie or "").list_mall(activity)
except (WeiboError, ValueError) as exc:
for order in group_orders:
failures.append(
{"id": order.id, "order_no": order.order_no, "message": str(exc)}
)
continue
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:
account.weidou_balance = balance
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,
"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,
current,
order,
balance=balance,
required_total=required_total,
surplus=surplus,
records_total=0,
records_matched=0,
verdict=VERDICT_UNKNOWN if target is None else "",
message=message,
source="balance_check",
raw={"reason": reason, "order_ids": [item.id for item in group_orders]},
)
_audit(
db,
current,
order,
"weibo:collab:balance_check",
{
"account_id": account.id,
"balance": balance,
"required_total": required_total,
"surplus": surplus,
"threshold": threshold,
"reason": reason,
},
)
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()
return {
"total": len(rows),
"updated": updated,
"threshold": threshold,
"groups": groups,
"failures": failures,
"skipped": skipped,
}
def _record_key(record: Mapping[str, Any], index: int) -> str:
raw = json.dumps(
{"index": index, "record": _json_safe(dict(record))},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(raw.encode()).hexdigest()
def _record_matches(order: WeiboExternalOrder, record: Mapping[str, Any]) -> bool:
if _text(record.get("game_account")).strip() != _text(order.game_role_name).strip():
return False
if _text(record.get("goods_name")).strip() != _text(order.goods_name).strip():
return False
if _to_int(record.get("cost_weidou")) != _to_int(order.required_weidou):
return False
if order.exchange_started_at is not None:
started = order.exchange_started_at.astimezone(
ZoneInfo("Asia/Shanghai")
).strftime("%Y-%m-%d %H:%M:%S")
if _text(record.get("exchange_time")) < started:
return False
return True
def check_exchange_records(
db: Session,
current: User,
orders: Sequence[WeiboExternalOrder],
*,
activity: str = "",
) -> dict[str, Any]:
rows = list(orders)
if not rows:
raise CollabError("请先勾选订单", code="exchange_check_empty")
if len(rows) > CHECK_BATCH_MAX:
raise CollabError(f"一次最多查询 {CHECK_BATCH_MAX} 条", code="batch_too_large")
grouped: dict[int, list[WeiboExternalOrder]] = {}
failures: list[dict[str, Any]] = []
for order in rows:
if order.archived_at is not None or not order.account_id:
failures.append(
{
"id": order.id,
"order_no": order.order_no,
"message": "订单已归档或未关联账号",
}
)
continue
grouped.setdefault(int(order.account_id), []).append(order)
results: list[dict[str, Any]] = []
success = failed = unknown = 0
for account_id, group_orders in grouped.items():
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first()
if account is None or not account.is_enabled:
for order in group_orders:
failures.append(
{
"id": order.id,
"order_no": order.order_no,
"message": "账号不存在或已停用",
}
)
continue
try:
data = WeiboClient(account.cookie or "").list_exchange_records(
activity,
page=1,
page_size=100,
)
records = [
item for item in (data.get("list") or []) if isinstance(item, dict)
]
except (WeiboError, ValueError) as exc:
for order in group_orders:
failures.append(
{"id": order.id, "order_no": order.order_no, "message": str(exc)}
)
continue
used_keys = {
str(item.exchange_record_key or "")
for item in db.query(WeiboExternalOrder)
.filter(
WeiboExternalOrder.account_id == account_id,
WeiboExternalOrder.exchange_record_key != "",
WeiboExternalOrder.id.notin_([order.id for order in group_orders]),
)
.all()
if item.exchange_record_key
}
indexed = [
(_record_key(record, index), record) for index, record in enumerate(records)
]
for order in group_orders:
candidates = [
(key, record)
for key, record in indexed
if key not in used_keys and _record_matches(order, record)
]
now = _now()
order.exchange_checked_at = now
if len(candidates) == 1:
key, record = candidates[0]
used_keys.add(key)
order.exchange_record_key = key[:128]
order.exchange_record_snapshot = _json_safe(record)
order.exchange_verdict = VERDICT_SUCCESS
order.exchange_message = "兑换记录唯一命中"
if order.status == STATUS_EXCHANGE_PENDING:
set_status(
db,
current,
order,
STATUS_DONE,
reason="兑换记录唯一命中",
allow_check=True,
commit=False,
)
verdict = VERDICT_SUCCESS
message = "兑换记录唯一命中"
success += 1
elif len(candidates) > 1:
order.exchange_verdict = VERDICT_UNKNOWN
order.exchange_message = (
f"找到 {len(candidates)} 条候选记录,需人工确认"
)
verdict = VERDICT_UNKNOWN
message = order.exchange_message
unknown += 1
else:
order.exchange_verdict = VERDICT_UNKNOWN
order.exchange_message = "未找到匹配记录,需人工核对后再判定失败"
verdict = VERDICT_UNKNOWN
message = order.exchange_message
unknown += 1
metric = _metric(
db,
current,
order,
balance=account.weidou_balance,
required_total=order.required_weidou,
surplus=None,
records_total=len(records),
records_matched=len(candidates),
verdict=verdict,
message=message,
source="exchange_records",
raw={
"candidates": [record for _, record in candidates[:5]],
"role": order.game_role_name,
"goods_name": order.goods_name,
},
)
db.flush()
_audit(
db,
current,
order,
"weibo:collab:exchange_check",
{
"verdict": verdict,
"message": message,
"records_total": len(records),
"candidates": len(candidates),
},
)
results.append(
{
"id": order.id,
"order_no": order.order_no,
"ok": True,
"verdict": verdict,
"verdict_label": VERDICT_LABELS.get(verdict, verdict),
"message": message,
"records_total": len(records),
"records_matched": len(candidates),
"status": order.status,
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
"metric_id": metric.id,
}
)
db.commit()
return {
"total": len(rows),
"success": success,
"failed": failed,
"unknown": unknown,
"results": results,
"failures": failures,
}
def archive_orders(
db: Session,
current: User,
ids: Sequence[int],
*,
reason: str = "",
) -> dict[str, Any]:
rows = list_orders_by_ids(db, ids)
if not rows:
raise CollabError("请先勾选订单", code="archive_empty")
if len(rows) > ARCHIVE_BATCH_MAX:
raise CollabError(
f"一次最多归档 {ARCHIVE_BATCH_MAX} 条", code="batch_too_large"
)
results: list[dict[str, Any]] = []
archived = 0
for order in rows:
if order.archived_at is not None:
results.append(
{
"id": order.id,
"order_no": order.order_no,
"ok": False,
"message": "已归档",
}
)
continue
if order.status not in ARCHIVABLE_STATUSES:
results.append(
{
"id": order.id,
"order_no": order.order_no,
"ok": False,
"message": "只有兑换成功的订单可以归档",
}
)
continue
order.archived_at = _now()
order.archived_by = current.id
order.archive_reason = _text(reason, max_length=256)
_touch(order, current)
_audit(
db,
current,
order,
"weibo:collab:archive",
{"reason": order.archive_reason},
)
archived += 1
results.append(
{"id": order.id, "order_no": order.order_no, "ok": True, "message": ""}
)
db.commit()
return {
"total": len(rows),
"archived": archived,
"skipped": len(rows) - archived,
"results": results,
}
def unarchive_orders(
db: Session,
current: User,
ids: Sequence[int],
) -> dict[str, Any]:
rows = list_orders_by_ids(db, ids)
if not rows:
raise CollabError("请先勾选订单", code="archive_empty")
results: list[dict[str, Any]] = []
unarchived = 0
for order in rows:
if order.archived_at is None:
results.append(
{
"id": order.id,
"order_no": order.order_no,
"ok": False,
"message": "未归档",
}
)
continue
previous = order.archive_reason
order.archived_at = None
order.archived_by = None
order.archive_reason = ""
_touch(order, current)
_audit(
db,
current,
order,
"weibo:collab:unarchive",
{"previous_reason": previous},
)
unarchived += 1
results.append(
{"id": order.id, "order_no": order.order_no, "ok": True, "message": ""}
)
db.commit()
return {
"total": len(rows),
"unarchived": unarchived,
"skipped": len(rows) - unarchived,
"results": results,
}
def _account_clusters(
db: Session, orders: Sequence[WeiboExternalOrder]
) -> dict[int, dict[str, Any]]:
account_ids = {int(order.account_id) for order in orders if order.account_id}
if not account_ids:
return {}
siblings = (
db.query(WeiboExternalOrder)
.filter(
WeiboExternalOrder.account_id.in_(account_ids),
WeiboExternalOrder.archived_at.is_(None),
WeiboExternalOrder.status != STATUS_VOID,
)
.order_by(WeiboExternalOrder.id.asc())
.all()
)
grouped: dict[int, list[WeiboExternalOrder]] = {}
for row in siblings:
if row.account_id:
grouped.setdefault(int(row.account_id), []).append(row)
result: dict[int, dict[str, Any]] = {}
for order in orders:
rows = grouped.get(int(order.account_id or 0), [])
if len(rows) <= 1:
continue
result[int(order.id)] = {
"total": len(rows),
"orders": [
{
"id": item.id,
"order_no": item.order_no,
"status": item.status,
"status_label": ORDER_STATUS_LABELS.get(item.status, item.status),
"goods_name": item.goods_name,
}
for item in rows
if item.id != order.id
],
}
return result
def _latest_metrics(
db: Session, order_ids: Sequence[int]
) -> dict[int, WeiboExternalOrderMetric]:
if not order_ids:
return {}
rows = (
db.query(WeiboExternalOrderMetric)
.filter(WeiboExternalOrderMetric.order_id.in_(order_ids))
.order_by(WeiboExternalOrderMetric.id.desc())
.all()
)
result: dict[int, WeiboExternalOrderMetric] = {}
for row in rows:
result.setdefault(int(row.order_id), row)
return result
def serialize_order(
order: WeiboExternalOrder,
*,
users: Mapping[int, str] | None = None,
accounts: Mapping[int, WeiboAccount] | None = None,
cluster: Mapping[str, Any] | None = None,
last_metric: WeiboExternalOrderMetric | None = None,
) -> dict[str, Any]:
names = users or {}
account_map = accounts or {}
account = account_map.get(int(order.account_id)) if order.account_id else None
return {
"id": order.id,
"order_no": order.order_no,
"shop": order.shop or "",
"owner_id": order.owner_id,
"owner_name": names.get(int(order.owner_id), "") if order.owner_id else "",
"account_id": order.account_id,
"account_uid": order.account_uid or "",
"account_nickname": order.account_nickname or "",
"account_exists": account is not None,
"account_enabled": bool(account and account.is_enabled),
"account_ck_status": account.ck_check_status if account else "",
"account_ck_message": account.ck_check_message if account else "",
"account_is_bound": account.is_bound if account else None,
"game_role_name": order.game_role_name or "",
"bind_snapshot_at": order.bind_snapshot_at,
"goods_id": order.goods_id,
"goods_name": order.goods_name or "",
"goods_image": order.goods_image or "",
"required_weidou": order.required_weidou,
"home_images": list(order.home_images or []),
"remark": order.remark or "",
"status": order.status,
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
"next_status": NEXT_STATUS.get(order.status),
"missing_fields": _missing_for_recharge(order),
"weidou_balance": order.weidou_balance,
"weidou_required_total": order.weidou_required_total,
"weidou_surplus": order.weidou_surplus,
"weidou_threshold": order.weidou_threshold,
"weidou_checked_at": order.weidou_checked_at,
"weidou_check_reason": order.weidou_check_reason or "",
"weidou_check_message": order.weidou_check_message or "",
"weidou_check_detail": order.weidou_check_detail,
"exchange_started_at": order.exchange_started_at,
"exchange_checked_at": order.exchange_checked_at,
"exchange_record_key": order.exchange_record_key or "",
"exchange_record_snapshot": order.exchange_record_snapshot,
"exchange_verdict": order.exchange_verdict or "",
"exchange_message": order.exchange_message or "",
"recharged_at": order.recharged_at,
"done_at": order.done_at,
"void_reason": order.void_reason or "",
"is_archived": order.archived_at is not None,
"archived_at": order.archived_at,
"archived_by": order.archived_by,
"archived_by_name": names.get(int(order.archived_by), "")
if order.archived_by
else "",
"archive_reason": order.archive_reason or "",
"created_by": order.created_by,
"created_by_name": names.get(int(order.created_by), "")
if order.created_by
else "",
"updated_by": order.updated_by,
"updated_by_name": names.get(int(order.updated_by), "")
if order.updated_by
else "",
"version": int(order.version or 0),
"created_at": order.created_at,
"updated_at": order.updated_at,
"account_cluster": dict(cluster) if cluster else None,
"last_metric_at": last_metric.created_at if last_metric else None,
"last_metric_message": last_metric.message if last_metric else "",
}
def _base_query(db: Session):
return db.query(WeiboExternalOrder)
def _apply_filters(
query,
*,
archive_scope: str = "active",
status: str = "",
shop: str = "",
owner_id: int | None = None,
goods_name: str = "",
keyword: str = "",
include_void: bool = False,
):
scope = archive_scope if archive_scope in ARCHIVE_SCOPES else "active"
if scope == "active":
query = query.filter(WeiboExternalOrder.archived_at.is_(None))
elif scope == "archived":
query = query.filter(WeiboExternalOrder.archived_at.isnot(None))
if not include_void:
query = query.filter(WeiboExternalOrder.status != STATUS_VOID)
if _text(status):
query = query.filter(WeiboExternalOrder.status == status)
if _text(shop):
query = query.filter(WeiboExternalOrder.shop == shop)
if owner_id is not None:
query = query.filter(WeiboExternalOrder.owner_id == int(owner_id))
if _text(goods_name):
query = query.filter(WeiboExternalOrder.goods_name == goods_name)
search = _text(keyword)
if search:
pattern = f"%{search}%"
query = query.filter(
or_(
WeiboExternalOrder.order_no.ilike(pattern),
WeiboExternalOrder.account_uid.ilike(pattern),
WeiboExternalOrder.account_nickname.ilike(pattern),
WeiboExternalOrder.game_role_name.ilike(pattern),
WeiboExternalOrder.shop.ilike(pattern),
WeiboExternalOrder.goods_name.ilike(pattern),
WeiboExternalOrder.remark.ilike(pattern),
)
)
return query
def list_orders(
db: Session,
*,
page: int,
page_size: int,
archive_scope: str = "active",
status: str = "",
shop: str = "",
owner_id: int | None = None,
goods_name: str = "",
keyword: str = "",
include_void: bool = False,
) -> dict[str, Any]:
query = _apply_filters(
_base_query(db),
archive_scope=archive_scope,
status=status,
shop=shop,
owner_id=owner_id,
goods_name=goods_name,
keyword=keyword,
include_void=include_void,
)
total = query.count()
rows = (
query.order_by(WeiboExternalOrder.id.desc())
.offset((max(1, page) - 1) * page_size)
.limit(page_size)
.all()
)
users = _users_by_id(
db,
{
int(item)
for order in rows
for item in (
order.owner_id,
order.created_by,
order.updated_by,
order.archived_by,
)
if item
},
)
accounts = _accounts_by_id(
db, {int(order.account_id) for order in rows if order.account_id}
)
clusters = _account_clusters(db, rows)
metrics = _latest_metrics(db, [order.id for order in rows])
return {
"items": [
serialize_order(
order,
users=users,
accounts=accounts,
cluster=clusters.get(order.id),
last_metric=metrics.get(order.id),
)
for order in rows
],
"total": total,
"page": page,
"page_size": page_size,
}
def serialize_one(db: Session, order: WeiboExternalOrder) -> dict[str, Any]:
users = _users_by_id(
db,
{
int(item)
for item in (
order.owner_id,
order.created_by,
order.updated_by,
order.archived_by,
)
if item
},
)
accounts = _accounts_by_id(
db, {int(order.account_id)} if order.account_id else set()
)
cluster = _account_clusters(db, [order]).get(order.id)
metric = _latest_metrics(db, [order.id]).get(order.id)
return serialize_order(
order,
users=users,
accounts=accounts,
cluster=cluster,
last_metric=metric,
)
def summary(
db: Session, *, include_void: bool = False, archive_scope: str = "active"
) -> dict[str, Any]:
query = _apply_filters(
_base_query(db),
archive_scope=archive_scope,
include_void=include_void,
)
by_status = {
str(status): int(count)
for status, count in query.with_entities(
WeiboExternalOrder.status, func.count(WeiboExternalOrder.id)
)
.group_by(WeiboExternalOrder.status)
.all()
}
account_disabled = (
query.join(WeiboAccount, WeiboAccount.id == WeiboExternalOrder.account_id)
.filter(WeiboAccount.is_enabled.is_(False))
.with_entities(func.count(WeiboExternalOrder.id))
.scalar()
or 0
)
ck_invalid = (
query.join(WeiboAccount, WeiboAccount.id == WeiboExternalOrder.account_id)
.filter(WeiboAccount.ck_check_status.in_(("invalid", "error")))
.with_entities(func.count(WeiboExternalOrder.id))
.scalar()
or 0
)
return {
"total": sum(by_status.values()),
"by_status": by_status,
"account_missing": (
db.query(func.count(WeiboExternalOrder.id))
.filter(
WeiboExternalOrder.account_id.is_(None),
WeiboExternalOrder.archived_at.is_(None),
)
.scalar()
or 0
),
"account_disabled": int(account_disabled),
"ck_invalid": int(ck_invalid),
"archived_total": (
db.query(func.count(WeiboExternalOrder.id))
.filter(WeiboExternalOrder.archived_at.isnot(None))
.scalar()
or 0
),
}
def serialize_metric(metric: WeiboExternalOrderMetric) -> dict[str, Any]:
return {
"id": metric.id,
"order_id": metric.order_id,
"order_no": metric.order_no,
"account_uid": metric.account_uid or "",
"weidou_balance": metric.weidou_balance,
"weidou_required_total": metric.weidou_required_total,
"weidou_surplus": metric.weidou_surplus,
"records_total": metric.records_total,
"records_matched": metric.records_matched,
"verdict": metric.verdict or "",
"verdict_label": VERDICT_LABELS.get(metric.verdict, metric.verdict or ""),
"message": metric.message or "",
"source": metric.source or "",
"created_at": metric.created_at,
}
def order_metrics(db: Session, order_id: int) -> list[dict[str, Any]]:
rows = (
db.query(WeiboExternalOrderMetric)
.filter(WeiboExternalOrderMetric.order_id == order_id)
.order_by(WeiboExternalOrderMetric.id.desc())
.all()
)
return [serialize_metric(row) for row in rows]
def order_timeline(
db: Session, order_id: int, *, limit: int = 100
) -> list[dict[str, Any]]:
rows = (
db.query(AuditLog)
.filter(AuditLog.target == f"weibo_external_order:{order_id}")
.order_by(AuditLog.id.desc())
.limit(limit)
.all()
)
return [
{
"id": row.id,
"action": row.action,
"username": row.username,
"detail": row.detail,
"success": row.success,
"created_at": row.created_at,
}
for row in rows
]
def list_shops(db: Session, *, active_only: bool = True) -> list[WeiboCollabShop]:
query = db.query(WeiboCollabShop)
if active_only:
query = query.filter(WeiboCollabShop.is_active.is_(True))
return query.order_by(WeiboCollabShop.sort_order, WeiboCollabShop.id).all()
def ensure_shops_registered(db: Session, current: User, names: Sequence[str]) -> None:
existing = {item.name for item in db.query(WeiboCollabShop.name).all() if item.name}
for raw in names:
name = _text(raw, max_length=64)
if name and name not in existing:
db.add(
WeiboCollabShop(name=name, created_by=current.id, updated_by=current.id)
)
existing.add(name)
def create_shop(
db: Session, current: User, *, name: str, remark: str = ""
) -> WeiboCollabShop:
clean = _text(name, max_length=64)
if not clean:
raise CollabError("店铺名不能为空", code="shop_name_required")
if db.query(WeiboCollabShop).filter(WeiboCollabShop.name == clean).first():
raise CollabError("店铺已存在", status_code=409, code="shop_exists")
shop = WeiboCollabShop(
name=clean,
remark=_text(remark, max_length=255),
created_by=current.id,
updated_by=current.id,
)
db.add(shop)
db.commit()
db.refresh(shop)
return shop
def update_shop(
db: Session, current: User, shop: WeiboCollabShop, payload: Mapping[str, Any]
) -> None:
old_name = shop.name
for field in ("name", "sort_order", "is_active", "remark"):
if field not in payload:
continue
value = payload[field]
if field == "sort_order":
value = _to_int(value) or 0
elif field == "is_active":
value = bool(value)
elif field == "remark":
value = _text(value, max_length=255)
else:
value = _text(value, max_length=64)
setattr(shop, field, value)
if shop.name != old_name:
db.query(WeiboExternalOrder).filter(WeiboExternalOrder.shop == old_name).update(
{"shop": shop.name}
)
shop.updated_by = current.id
db.commit()
def delete_shop(db: Session, shop: WeiboCollabShop) -> None:
if (
db.query(WeiboExternalOrder)
.filter(WeiboExternalOrder.shop == shop.name)
.first()
):
raise CollabError("仍有订单使用该店铺,请改为停用", code="shop_in_use")
db.delete(shop)
db.commit()
def list_goods(db: Session, *, active_only: bool = True) -> list[WeiboCollabGoods]:
query = db.query(WeiboCollabGoods)
if active_only:
query = query.filter(WeiboCollabGoods.is_active.is_(True))
return query.order_by(WeiboCollabGoods.sort_order, WeiboCollabGoods.id).all()
def create_goods(
db: Session,
current: User,
*,
name: str,
price: int,
goods_id: int | None = None,
goods_image: str = "",
remark: str = "",
) -> WeiboCollabGoods:
clean = _text(name, max_length=128)
if not clean:
raise CollabError("商品名不能为空", code="goods_name_required")
if db.query(WeiboCollabGoods).filter(WeiboCollabGoods.name == clean).first():
raise CollabError("商品已存在", status_code=409, code="goods_exists")
goods = WeiboCollabGoods(
name=clean,
price=max(0, int(price)),
goods_id=goods_id,
goods_image=_text(goods_image, max_length=512),
remark=_text(remark, max_length=255),
created_by=current.id,
updated_by=current.id,
)
db.add(goods)
db.commit()
db.refresh(goods)
return goods
def update_goods(
db: Session,
current: User,
goods: WeiboCollabGoods,
payload: Mapping[str, Any],
) -> None:
old_name = goods.name
for field in (
"name",
"price",
"goods_id",
"goods_image",
"sort_order",
"is_active",
"remark",
):
if field not in payload:
continue
value = payload[field]
if field in ("price", "goods_id", "sort_order"):
value = _to_int(value)
elif field == "is_active":
value = bool(value)
elif field == "goods_image":
value = _text(value, max_length=512)
elif field == "remark":
value = _text(value, max_length=255)
else:
value = _text(value, max_length=128)
setattr(goods, field, value)
if goods.name != old_name:
db.query(WeiboExternalOrder).filter(
WeiboExternalOrder.goods_name == old_name
).update({"goods_name": goods.name})
goods.updated_by = current.id
db.commit()
def delete_goods(db: Session, goods: WeiboCollabGoods) -> None:
if (
db.query(WeiboExternalOrder)
.filter(WeiboExternalOrder.goods_name == goods.name)
.first()
):
raise CollabError("仍有订单使用该商品,请改为停用", code="goods_in_use")
db.delete(goods)
db.commit()