feat(weibo): 区分充值资金来源并完善协作单记录
This commit is contained in:
@@ -35,7 +35,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = TESTS_DIR.parent
|
||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||
|
||||
HEAD_REVISION = "20261113_0081"
|
||||
HEAD_REVISION = "20261113_0082"
|
||||
|
||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||
EXTRA_INDEXES = (
|
||||
|
||||
@@ -19,7 +19,13 @@ from core.weibo import (
|
||||
WeiboRechargeError,
|
||||
WeiboRechargeOrder,
|
||||
)
|
||||
from web.backend.models import AuditLog, User, WeiboAccount, WeiboWorkbenchAccount
|
||||
from web.backend.models import (
|
||||
AuditLog,
|
||||
User,
|
||||
WeiboAccount,
|
||||
WeiboRechargeRecord,
|
||||
WeiboWorkbenchAccount,
|
||||
)
|
||||
from web.backend.permissions import ROLE_PERMISSIONS
|
||||
from web.backend.routers import weibo as weibo_router
|
||||
from web.backend.routers.weibo import (
|
||||
@@ -1587,6 +1593,11 @@ def test_manual_recharge_order_and_status_routes_serialize(
|
||||
assert order["single_reward"] == 100
|
||||
assert order["alipay_qr_code"] == "https://qr.alipay.com/upx-test"
|
||||
assert status["status"] == "PAY_STATUS_PENDING"
|
||||
record = db.query(WeiboRechargeRecord).filter_by(order_id="order-1").one()
|
||||
assert record.pay_id == "pay-1"
|
||||
assert record.amount_yuan == 1
|
||||
assert record.status == "pending"
|
||||
assert record.status_checked_at is not None
|
||||
|
||||
|
||||
def test_recharge_failure_keeps_valid_ck_status(monkeypatch, db, current_user):
|
||||
|
||||
@@ -2,10 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.weibo import WeiboAuthError
|
||||
from web.backend.models import AuditLog, User, WeiboAccount, WeiboExternalOrderMetric
|
||||
from web.backend.models import (
|
||||
AuditLog,
|
||||
User,
|
||||
WeiboAccount,
|
||||
WeiboExternalOrderMetric,
|
||||
WeiboRechargeRecord,
|
||||
)
|
||||
from web.backend.routers import weibo_collab as weibo_collab_router
|
||||
from web.backend.schemas import WeiboCollabCkExportOut
|
||||
from web.backend.services import weibo_collab_service as collab
|
||||
@@ -98,9 +106,58 @@ def test_create_orders_groups_and_balance_check(monkeypatch, db):
|
||||
assert first.status == collab.STATUS_RECHARGED
|
||||
assert second.status == collab.STATUS_RECHARGED
|
||||
assert first.recharged_at is not None
|
||||
assert first.funding_source == collab.FUNDING_SOURCE_ACCOUNT_BALANCE
|
||||
assert second.funding_source == collab.FUNDING_SOURCE_ACCOUNT_BALANCE
|
||||
assert db.query(WeiboExternalOrderMetric).count() == 2
|
||||
|
||||
|
||||
def test_balance_check_recognizes_paid_web_recharge(monkeypatch, db):
|
||||
user = make_user(db)
|
||||
account = make_account(db)
|
||||
order = collab.create_order(
|
||||
db,
|
||||
user,
|
||||
order_no="WB-WEB-RECHARGE",
|
||||
shop="店铺A",
|
||||
account_id=account.id,
|
||||
goods_name="商品A",
|
||||
required_weidou=100,
|
||||
require_verified=False,
|
||||
)
|
||||
db.add(
|
||||
WeiboRechargeRecord(
|
||||
account_id=account.id,
|
||||
order_id="recharge-order-1",
|
||||
pay_id="pay-1",
|
||||
amount_yuan=1,
|
||||
single_reward=100,
|
||||
status="paid",
|
||||
paid_at=datetime.now(UTC),
|
||||
created_by=user.id,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def list_mall(self, *_args, **_kwargs):
|
||||
return {"weidou_balance": 100, "list": []}
|
||||
|
||||
monkeypatch.setattr(weibo_service, "WeiboClient", FakeClient)
|
||||
|
||||
collab.check_balance_groups(db, user, [order], threshold=100)
|
||||
|
||||
db.refresh(order)
|
||||
assert order.funding_source == collab.FUNDING_SOURCE_WEB_RECHARGE
|
||||
assert order.funding_recharge_record_id is not None
|
||||
detail = collab.serialize_one(db, order)
|
||||
assert detail["funding_source_label"] == "系统 Web 充值"
|
||||
assert detail["recharge_records"][0]["order_id"] == "recharge-order-1"
|
||||
assert detail["recharge_records"][0]["is_paid"] is True
|
||||
|
||||
|
||||
def test_exchange_record_unique_match_and_archive(monkeypatch, db):
|
||||
user = make_user(db)
|
||||
account = make_account(db)
|
||||
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
"""虎牙自动注册批次(持久化元数据)"""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -79,6 +79,24 @@ export interface WeiboCollabAccountOption {
|
||||
ck_check_status: string;
|
||||
}
|
||||
|
||||
export interface WeiboRechargeRecord {
|
||||
id: number;
|
||||
order_id: string;
|
||||
pay_id: string;
|
||||
amount_yuan: number;
|
||||
single_reward: number;
|
||||
status: string;
|
||||
status_label: string;
|
||||
sub_status: string;
|
||||
is_paid: boolean;
|
||||
paid_at: string | null;
|
||||
status_checked_at: string | null;
|
||||
created_by: number | null;
|
||||
created_by_name: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface WeiboCollabOrder {
|
||||
id: number;
|
||||
order_no: string;
|
||||
@@ -103,6 +121,10 @@ export interface WeiboCollabOrder {
|
||||
remark: string;
|
||||
status: WeiboCollabStatus;
|
||||
status_label: string;
|
||||
funding_source: string;
|
||||
funding_source_label: string;
|
||||
funding_source_detail: string;
|
||||
funding_recharge_record_id: number | null;
|
||||
next_status: WeiboCollabStatus | null;
|
||||
missing_fields: string[];
|
||||
weidou_balance: number | null;
|
||||
@@ -147,6 +169,7 @@ export interface WeiboCollabOrder {
|
||||
} | null;
|
||||
last_metric_at: string | null;
|
||||
last_metric_message: string;
|
||||
recharge_records: WeiboRechargeRecord[];
|
||||
}
|
||||
|
||||
export interface WeiboCollabOrderList {
|
||||
|
||||
@@ -71,7 +71,7 @@ const { TextArea } = Input;
|
||||
const STATUS_META: Record<WeiboCollabStatus, { label: string; color: string }> = {
|
||||
incomplete: { label: '待补资料', color: 'default' },
|
||||
to_recharge: { label: '待充值', color: 'blue' },
|
||||
recharged: { label: '已充值', color: 'cyan' },
|
||||
recharged: { label: '余额已核账', color: 'cyan' },
|
||||
scripted: { label: '已上脚本', color: 'purple' },
|
||||
done: { label: '兑换成功', color: 'success' },
|
||||
weidou_error: { label: '微豆异常', color: 'orange' },
|
||||
@@ -241,6 +241,13 @@ function balanceMeta(reason: string) {
|
||||
return { label: '未核账', color: 'default' };
|
||||
}
|
||||
|
||||
function fundingSourceColor(source: string) {
|
||||
if (source === 'web_recharge') return 'success';
|
||||
if (source === 'legacy_web_recharge') return 'warning';
|
||||
if (source === 'unknown') return 'default';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
interface OrderFormValues {
|
||||
order_no: string;
|
||||
shop?: string;
|
||||
@@ -307,6 +314,9 @@ export default function WeiboCollabPage() {
|
||||
const [statusRollback, setStatusRollback] = useState(false);
|
||||
const [statusIds, setStatusIds] = useState<number[]>([]);
|
||||
const [statusSaving, setStatusSaving] = useState(false);
|
||||
const [noteTarget, setNoteTarget] = useState<WeiboCollabOrder | null>(null);
|
||||
const [noteValue, setNoteValue] = useState('');
|
||||
const [noteSaving, setNoteSaving] = useState(false);
|
||||
|
||||
const [shopOpen, setShopOpen] = useState(false);
|
||||
const [goodsOpen, setGoodsOpen] = useState(false);
|
||||
@@ -534,6 +544,33 @@ export default function WeiboCollabPage() {
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openNote = (order: WeiboCollabOrder) => {
|
||||
setNoteTarget(order);
|
||||
setNoteValue(order.remark || '');
|
||||
};
|
||||
|
||||
const saveNote = async () => {
|
||||
if (!noteTarget || !canEdit) return;
|
||||
setNoteSaving(true);
|
||||
try {
|
||||
const saved = await weiboCollabApi.updateOrder(noteTarget.id, {
|
||||
remark: noteValue.trim(),
|
||||
expected_version: noteTarget.version,
|
||||
});
|
||||
setOrders((current) => current.map((row) => (
|
||||
row.id === saved.id ? saved : row
|
||||
)));
|
||||
setDetail((current) => (current?.id === saved.id ? saved : current));
|
||||
setNoteTarget(null);
|
||||
setNoteValue('');
|
||||
message.success(noteValue.trim() ? '备注已保存' : '备注已清空');
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setNoteSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoodsChange = (value: string) => {
|
||||
const selected = goodsOptions.find((item) => item.value === value);
|
||||
if (!selected) return;
|
||||
@@ -980,11 +1017,40 @@ export default function WeiboCollabPage() {
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 130,
|
||||
width: 190,
|
||||
render: (value: WeiboCollabStatus, row) => (
|
||||
<Space size={4} wrap>
|
||||
<Tag color={STATUS_META[value]?.color}>{row.status_label || value}</Tag>
|
||||
{row.exchange_verdict === 'unknown' && <Tag color="warning">待确认</Tag>}
|
||||
<Space orientation="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Space size={4} wrap>
|
||||
<Tag color={STATUS_META[value]?.color}>{row.status_label || value}</Tag>
|
||||
{row.exchange_verdict === 'unknown' && <Tag color="warning">待确认</Tag>}
|
||||
{row.funding_source_label && (
|
||||
<Tag color={fundingSourceColor(row.funding_source)}>
|
||||
{row.funding_source_label}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
<div
|
||||
role={canEdit && !row.is_archived ? 'button' : undefined}
|
||||
tabIndex={canEdit && !row.is_archived ? 0 : undefined}
|
||||
title={row.remark || (canEdit && !row.is_archived ? '点击添加备注' : undefined)}
|
||||
onClick={() => {
|
||||
if (canEdit && !row.is_archived) openNote(row);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (canEdit && !row.is_archived && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
openNote(row);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: canEdit && !row.is_archived ? 'text' : 'default',
|
||||
minHeight: 22,
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{row.remark || <Text type="secondary">+ 备注</Text>}
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -1569,6 +1635,29 @@ export default function WeiboCollabPage() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="订单备注"
|
||||
open={noteTarget !== null}
|
||||
confirmLoading={noteSaving}
|
||||
okText="保存备注"
|
||||
cancelText="取消"
|
||||
onOk={() => void saveNote()}
|
||||
onCancel={() => {
|
||||
setNoteTarget(null);
|
||||
setNoteValue('');
|
||||
}}
|
||||
destroyOnHidden
|
||||
>
|
||||
<TextArea
|
||||
rows={5}
|
||||
maxLength={512}
|
||||
showCount
|
||||
value={noteValue}
|
||||
onChange={(event) => setNoteValue(event.target.value)}
|
||||
placeholder="填写订单备注,保存后会记录到审计日志"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`修改状态${statusIds.length > 1 ? `(${statusIds.length} 单)` : ''}`}
|
||||
open={statusOpen}
|
||||
@@ -1751,6 +1840,23 @@ export default function WeiboCollabPage() {
|
||||
label: '最近核账',
|
||||
children: formatTime(detail.weidou_checked_at),
|
||||
},
|
||||
{
|
||||
key: 'funding_source',
|
||||
label: '余额来源',
|
||||
children: detail.funding_source_label ? (
|
||||
<Tag color={fundingSourceColor(detail.funding_source)}>
|
||||
{detail.funding_source_label}
|
||||
</Tag>
|
||||
) : (
|
||||
'未识别'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'funding_detail',
|
||||
label: '来源依据',
|
||||
span: 2,
|
||||
children: detail.funding_source_detail || '暂无来源依据',
|
||||
},
|
||||
{
|
||||
key: 'scripted',
|
||||
label: '上脚本时间',
|
||||
@@ -1809,6 +1915,70 @@ export default function WeiboCollabPage() {
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
<Title level={5}>Web 充值记录</Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
locale={{ emptyText: '该账号暂无系统 Web 充值下单记录' }}
|
||||
dataSource={detail.recharge_records}
|
||||
rowClassName={(row) => (
|
||||
row.id === detail.funding_recharge_record_id ? 'ant-table-row-selected' : ''
|
||||
)}
|
||||
columns={[
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: formatTime,
|
||||
},
|
||||
{
|
||||
title: '充值单号',
|
||||
dataIndex: 'order_id',
|
||||
render: (value: string) => (
|
||||
<Text code copyable={{ text: value }}>
|
||||
{value}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount_yuan',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (value: number) => `${value} 元`,
|
||||
},
|
||||
{
|
||||
title: '赠送微豆',
|
||||
dataIndex: 'single_reward',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (value: number) => value.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '支付状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (value: string, row) => (
|
||||
<Tag color={value === 'paid' ? 'success' : value === 'failed' ? 'error' : 'default'}>
|
||||
{row.status_label || value}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'paid_at',
|
||||
width: 170,
|
||||
render: formatTime,
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'created_by_name',
|
||||
width: 110,
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Space wrap>
|
||||
{canEdit && !detail.is_archived && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user