feat(weibo): 区分充值资金来源并完善协作单记录

This commit is contained in:
yml
2026-11-13 21:07:50 +08:00
parent f0abca3d58
commit 76b344eb83
11 changed files with 740 additions and 10 deletions
@@ -0,0 +1,187 @@
"""持久化微博 Web 充值记录并区分订单资金来源。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261113_0082"
down_revision: str | None = "20261113_0081"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
ORDERS = "weibo_external_orders"
CHARGES = "weibo_recharge_records"
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if inspector.has_table(ORDERS):
columns = {column["name"] for column in inspector.get_columns(ORDERS)}
if "funding_source" not in columns:
op.add_column(
ORDERS,
sa.Column(
"funding_source",
sa.String(length=32),
nullable=False,
server_default="",
),
)
op.create_index(
"ix_weibo_external_orders_funding_source",
ORDERS,
["funding_source"],
)
if "funding_source_detail" not in columns:
op.add_column(
ORDERS,
sa.Column(
"funding_source_detail",
sa.String(length=512),
nullable=False,
server_default="",
),
)
if "funding_recharge_record_id" not in columns:
op.add_column(
ORDERS,
sa.Column("funding_recharge_record_id", sa.Integer(), nullable=True),
)
op.create_index(
"ix_weibo_external_orders_funding_recharge_record_id",
ORDERS,
["funding_recharge_record_id"],
)
if not inspector.has_table(CHARGES):
op.create_table(
CHARGES,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"account_id",
sa.Integer(),
sa.ForeignKey("weibo_accounts.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("order_id", sa.String(length=64), nullable=False),
sa.Column(
"pay_id",
sa.String(length=128),
nullable=False,
server_default="",
),
sa.Column("amount_yuan", sa.Integer(), nullable=False),
sa.Column("single_reward", sa.Integer(), nullable=False),
sa.Column(
"status",
sa.String(length=24),
nullable=False,
server_default="created",
),
sa.Column(
"sub_status",
sa.String(length=64),
nullable=False,
server_default="",
),
sa.Column("paid_at", sa.DateTime(), nullable=True),
sa.Column("status_checked_at", sa.DateTime(), nullable=True),
sa.Column(
"created_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint("order_id", name="uq_weibo_recharge_records_order_id"),
)
op.create_index(
"ix_weibo_recharge_records_account_id",
CHARGES,
["account_id"],
)
op.create_index(
"ix_weibo_recharge_records_order_id",
CHARGES,
["order_id"],
)
op.create_index(
"ix_weibo_recharge_records_pay_id",
CHARGES,
["pay_id"],
)
op.create_index(
"ix_weibo_recharge_records_status",
CHARGES,
["status"],
)
op.create_index(
"ix_weibo_recharge_records_created_at",
CHARGES,
["created_at"],
)
if inspector.has_table(ORDERS):
has_audit = inspector.has_table("audit_logs")
audit_exists = (
"""
EXISTS (
SELECT 1
FROM audit_logs a
WHERE a.action = 'weibo:recharge:create_order'
AND JSON_EXTRACT(a.detail, '$.account_id') = o.account_id
AND a.created_at >= o.created_at
AND a.created_at <= o.recharged_at
)
"""
if has_audit
else "FALSE"
)
bind.execute(
sa.text(
f"""
UPDATE {ORDERS} o
SET funding_source = CASE
WHEN {audit_exists} THEN 'legacy_web_recharge'
ELSE 'account_balance'
END,
funding_source_detail = CASE
WHEN {audit_exists}
THEN '历史回填:核账前存在 Web 充值下单审计,但历史支付结果未持久化'
ELSE '历史回填:核账前未发现该账号的 Web 充值下单记录'
END
WHERE o.recharged_at IS NOT NULL
AND o.funding_source = ''
"""
)
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table(CHARGES):
op.drop_table(CHARGES)
if inspector.has_table(ORDERS):
columns = {column["name"] for column in inspector.get_columns(ORDERS)}
if "funding_recharge_record_id" in columns:
op.drop_column(ORDERS, "funding_recharge_record_id")
if "funding_source_detail" in columns:
op.drop_column(ORDERS, "funding_source_detail")
if "funding_source" in columns:
op.drop_column(ORDERS, "funding_source")
+54
View File
@@ -1115,6 +1115,16 @@ class WeiboExternalOrder(Base):
status: Mapped[str] = mapped_column(
String(20), default="incomplete", server_default="incomplete", index=True
)
# 余额资金来源:web_recharge / account_balance / legacy_web_recharge / unknown
funding_source: Mapped[str] = mapped_column(
String(32), default="", server_default="", index=True
)
funding_source_detail: Mapped[str] = mapped_column(
String(512), default="", server_default=""
)
funding_recharge_record_id: Mapped[int | None] = mapped_column(
Integer, nullable=True, index=True
)
recharged_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
scripted_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
exchange_started_at: Mapped[datetime | None] = mapped_column(
@@ -1208,6 +1218,50 @@ class WeiboExternalOrderMetric(Base):
)
class WeiboRechargeRecord(Base):
"""微博 Web 充值下单与支付状态记录。"""
__tablename__ = "weibo_recharge_records"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
account_id: Mapped[int] = mapped_column(
ForeignKey("weibo_accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
order_id: Mapped[str] = mapped_column(
String(64), nullable=False, unique=True, index=True
)
pay_id: Mapped[str] = mapped_column(
String(128), nullable=False, default="", server_default="", index=True
)
amount_yuan: Mapped[int] = mapped_column(Integer, nullable=False)
single_reward: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(
String(24),
nullable=False,
default="created",
server_default="created",
index=True,
)
sub_status: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
paid_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
status_checked_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, index=True
)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class HuyaRegisterBatch(Base):
"""虎牙自动注册批次(持久化元数据)"""
+9
View File
@@ -59,6 +59,8 @@ from ..services.weibo_service import (
generate_weibo_bind_qrcode,
generate_weibo_bind_schemes,
import_weibo_cookies,
persist_weibo_payment_status,
persist_weibo_recharge_order,
query_weibo_exchange_records,
query_weibo_mall,
query_weibo_payment_status,
@@ -1600,6 +1602,12 @@ def create_recharge_order(
)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
persist_weibo_recharge_order(
db,
account,
order,
created_by=current.id,
)
_record_audit_safely(
db,
current,
@@ -1643,6 +1651,7 @@ def get_recharge_status(
status = query_weibo_payment_status(db, account, pay_id=req.pay_id)
except (WeiboError, ValueError) as exc:
raise _manual_http_error(exc) from exc
persist_weibo_payment_status(db, account, status)
return WeiboPaymentStatusOut(
pay_id=status.pay_id,
out_pay_id=status.out_pay_id,
+23
View File
@@ -911,6 +911,24 @@ class WeiboCollabArchiveRequest(BaseModel):
reason: str = Field("", max_length=256)
class WeiboRechargeRecordOut(BaseModel):
id: int
order_id: str
pay_id: str = ""
amount_yuan: int = 0
single_reward: int = 0
status: str = "created"
status_label: str = ""
sub_status: str = ""
is_paid: bool = False
paid_at: datetime | None = None
status_checked_at: datetime | None = None
created_by: int | None = None
created_by_name: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
class WeiboCollabOrderOut(BaseModel):
id: int
order_no: str
@@ -935,6 +953,10 @@ class WeiboCollabOrderOut(BaseModel):
remark: str = ""
status: str = "incomplete"
status_label: str = ""
funding_source: str = ""
funding_source_label: str = ""
funding_source_detail: str = ""
funding_recharge_record_id: int | None = None
next_status: str | None = None
missing_fields: list[str] = Field(default_factory=list)
weidou_balance: int | None = None
@@ -970,6 +992,7 @@ class WeiboCollabOrderOut(BaseModel):
account_cluster: dict[str, Any] | None = None
last_metric_at: datetime | None = None
last_metric_message: str = ""
recharge_records: list[WeiboRechargeRecordOut] = Field(default_factory=list)
class WeiboCollabOrderListOut(BaseModel):
+127 -1
View File
@@ -26,6 +26,7 @@ from ..models import (
WeiboCollabShop,
WeiboExternalOrder,
WeiboExternalOrderMetric,
WeiboRechargeRecord,
)
from .audit_service import record_audit
from .collab_common import apply_order_visibility, count_archived
@@ -58,7 +59,7 @@ ORDER_STATUSES = (
ORDER_STATUS_LABELS = {
STATUS_INCOMPLETE: "待补资料",
STATUS_TO_RECHARGE: "待充值",
STATUS_RECHARGED: "已充值",
STATUS_RECHARGED: "余额已核账",
STATUS_SCRIPTED: "已上脚本",
STATUS_DONE: "兑换成功",
STATUS_WEIDOU_ERROR: "微豆异常",
@@ -729,6 +730,9 @@ def set_status(
now = _now()
if target == STATUS_TO_RECHARGE:
order.recharged_at = None
order.funding_source = ""
order.funding_source_detail = ""
order.funding_recharge_record_id = None
order.scripted_at = None
order.exchange_started_at = None
order.done_at = None
@@ -933,6 +937,60 @@ def _metric(
return metric
FUNDING_SOURCE_WEB_RECHARGE = "web_recharge"
FUNDING_SOURCE_ACCOUNT_BALANCE = "account_balance"
FUNDING_SOURCE_LEGACY_WEB_RECHARGE = "legacy_web_recharge"
FUNDING_SOURCE_UNKNOWN = "unknown"
FUNDING_SOURCE_LABELS = {
FUNDING_SOURCE_WEB_RECHARGE: "系统 Web 充值",
FUNDING_SOURCE_ACCOUNT_BALANCE: "账号原有/外部充值",
FUNDING_SOURCE_LEGACY_WEB_RECHARGE: "历史 Web 充值下单",
FUNDING_SOURCE_UNKNOWN: "来源未知",
}
RECHARGE_STATUS_LABELS = {
"created": "已创建",
"pending": "等待支付",
"paid": "支付成功",
"failed": "支付失败",
"closed": "订单关闭",
"unknown": "状态未知",
}
def _classify_balance_funding(
db: Session,
account: WeiboAccount,
group_orders: Sequence[WeiboExternalOrder],
) -> tuple[str, str, int | None]:
"""按账号维度和订单创建时间识别本次核账余额来源。"""
earliest = min(
(order.created_at for order in group_orders if order.created_at),
default=None,
)
query = db.query(WeiboRechargeRecord).filter(
WeiboRechargeRecord.account_id == account.id,
WeiboRechargeRecord.status == "paid",
WeiboRechargeRecord.paid_at.isnot(None),
)
if earliest is not None:
query = query.filter(WeiboRechargeRecord.paid_at >= earliest)
paid = query.order_by(WeiboRechargeRecord.paid_at.desc()).first()
if paid is not None:
return (
FUNDING_SOURCE_WEB_RECHARGE,
(
f"核账前存在已支付的 Web 充值:{paid.amount_yuan} 元,"
f"赠送 {paid.single_reward} 微豆,订单号 {paid.order_id}"
),
int(paid.id),
)
return (
FUNDING_SOURCE_ACCOUNT_BALANCE,
"核账通过,但核账前未发现已支付的系统 Web 充值记录",
None,
)
def check_balance_groups(
db: Session,
current: User,
@@ -1080,6 +1138,14 @@ def check_balance_groups(
target = STATUS_RECHARGED
now = _now()
if balance is None:
funding_source = FUNDING_SOURCE_UNKNOWN
funding_detail = "未拿到微豆余额,无法识别资金来源"
funding_record_id = None
else:
funding_source, funding_detail, funding_record_id = (
_classify_balance_funding(db, account, group_orders)
)
for order in group_orders:
order.weidou_balance = balance
order.weidou_required_total = required_total
@@ -1097,7 +1163,13 @@ def check_balance_groups(
"surplus": surplus,
"threshold": threshold,
"order_ids": [item.id for item in group_orders],
"funding_source": funding_source,
"funding_detail": funding_detail,
"funding_recharge_record_id": funding_record_id,
}
order.funding_source = funding_source
order.funding_source_detail = funding_detail[:512]
order.funding_recharge_record_id = funding_record_id
if target is not None:
set_status(
db,
@@ -1126,6 +1198,9 @@ def check_balance_groups(
"reason": reason,
"role": game_role_name,
"order_ids": [item.id for item in group_orders],
"funding_source": funding_source,
"funding_detail": funding_detail,
"funding_recharge_record_id": funding_record_id,
},
)
_audit(
@@ -1141,6 +1216,9 @@ def check_balance_groups(
"surplus": surplus,
"threshold": threshold,
"reason": reason,
"funding_source": funding_source,
"funding_detail": funding_detail,
"funding_recharge_record_id": funding_record_id,
},
)
groups.append(
@@ -1540,6 +1618,7 @@ def serialize_order(
accounts: Mapping[int, WeiboAccount] | None = None,
cluster: Mapping[str, Any] | None = None,
last_metric: WeiboExternalOrderMetric | None = None,
recharge_records: Sequence[Mapping[str, Any]] | None = None,
) -> dict[str, Any]:
names = users or {}
account_map = accounts or {}
@@ -1568,6 +1647,12 @@ def serialize_order(
"remark": order.remark or "",
"status": order.status,
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
"funding_source": order.funding_source or "",
"funding_source_label": FUNDING_SOURCE_LABELS.get(
order.funding_source or "", ""
),
"funding_source_detail": order.funding_source_detail or "",
"funding_recharge_record_id": order.funding_recharge_record_id,
"next_status": NEXT_STATUS.get(order.status),
"missing_fields": _missing_for_recharge(order),
"weidou_balance": order.weidou_balance,
@@ -1609,6 +1694,7 @@ def serialize_order(
"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 "",
"recharge_records": list(recharge_records or []),
}
@@ -1732,6 +1818,44 @@ def list_orders(
}
def _recharge_records_out(db: Session, account_id: int | None) -> list[dict[str, Any]]:
if not account_id:
return []
rows = (
db.query(WeiboRechargeRecord)
.filter(WeiboRechargeRecord.account_id == account_id)
.order_by(WeiboRechargeRecord.created_at.desc(), WeiboRechargeRecord.id.desc())
.limit(50)
.all()
)
users = _users_by_id(
db,
{int(row.created_by) for row in rows if row.created_by},
)
return [
{
"id": int(row.id),
"order_id": row.order_id,
"pay_id": row.pay_id or "",
"amount_yuan": int(row.amount_yuan or 0),
"single_reward": int(row.single_reward or 0),
"status": row.status,
"status_label": RECHARGE_STATUS_LABELS.get(row.status, row.status),
"sub_status": row.sub_status or "",
"is_paid": row.status == "paid",
"paid_at": row.paid_at,
"status_checked_at": row.status_checked_at,
"created_by": row.created_by,
"created_by_name": users.get(int(row.created_by), "")
if row.created_by
else "",
"created_at": row.created_at,
"updated_at": row.updated_at,
}
for row in rows
]
def serialize_one(db: Session, order: WeiboExternalOrder) -> dict[str, Any]:
users = _users_by_id(
db,
@@ -1751,12 +1875,14 @@ def serialize_one(db: Session, order: WeiboExternalOrder) -> dict[str, Any]:
)
cluster = _account_clusters(db, [order]).get(order.id)
metric = _latest_metrics(db, [order.id]).get(order.id)
recharge_records = _recharge_records_out(db, order.account_id)
return serialize_order(
order,
users=users,
accounts=accounts,
cluster=cluster,
last_metric=metric,
recharge_records=recharge_records,
)
+71 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import or_
from sqlalchemy.orm import Session
from core.weibo import (
@@ -18,7 +19,7 @@ from core.weibo import (
normalize_weibo_cookie,
)
from ..models import WeiboAccount, WeiboWorkbenchAccount
from ..models import WeiboAccount, WeiboRechargeRecord, WeiboWorkbenchAccount
from .weibo_image_service import embed_weibo_goods_images
@@ -468,6 +469,75 @@ def create_weibo_recharge_order(
raise
def persist_weibo_recharge_order(
db: Session,
account: WeiboAccount,
order: WeiboRechargeOrder,
*,
created_by: int | None,
) -> WeiboRechargeRecord:
"""持久化 Web 充值下单结果。"""
row = (
db.query(WeiboRechargeRecord)
.filter(WeiboRechargeRecord.order_id == order.order_id)
.first()
)
if row is None:
row = WeiboRechargeRecord(
account_id=account.id,
order_id=order.order_id,
amount_yuan=order.amount_yuan,
single_reward=order.single_reward,
created_by=created_by,
)
db.add(row)
row.account_id = account.id
row.pay_id = order.pay_id
row.amount_yuan = order.amount_yuan
row.single_reward = order.single_reward
row.status = "created"
db.commit()
db.refresh(row)
return row
def persist_weibo_payment_status(
db: Session,
account: WeiboAccount,
status: WeiboPaymentStatus,
) -> WeiboRechargeRecord | None:
"""把充值支付查询结果写回对应记录。"""
row = (
db.query(WeiboRechargeRecord)
.filter(
WeiboRechargeRecord.account_id == account.id,
or_(
WeiboRechargeRecord.pay_id == status.pay_id,
WeiboRechargeRecord.order_id == status.out_pay_id,
),
)
.order_by(WeiboRechargeRecord.id.desc())
.first()
)
if row is None:
return None
normalized = {
"PAY_STATUS_SUCCESS": "paid",
"PAY_STATUS_PENDING": "pending",
"PAY_STATUS_FAILED": "failed",
"PAY_STATUS_CLOSED": "closed",
}.get(status.status, "unknown")
now = _now()
row.status = normalized
row.sub_status = status.sub_status
row.status_checked_at = now
if normalized == "paid" and row.paid_at is None:
row.paid_at = now
db.commit()
db.refresh(row)
return row
def query_weibo_payment_status(
db: Session,
account: WeiboAccount,