1858 lines
58 KiB
Python
1858 lines
58 KiB
Python
"""微博外部单协作台账服务。
|
||
|
||
一行 = 一单,引用现有 ``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()
|