From 76b344eb83a12c9647f001cd1452831caee1bf23 Mon Sep 17 00:00:00 2001 From: yml Date: Fri, 13 Nov 2026 21:07:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E5=8C=BA=E5=88=86=E5=85=85?= =?UTF-8?q?=E5=80=BC=E8=B5=84=E9=87=91=E6=9D=A5=E6=BA=90=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=8D=8F=E4=BD=9C=E5=8D=95=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_migrations.py | 2 +- tests/test_weibo_accounts.py | 13 +- tests/test_weibo_collab.py | 59 +++++- .../20261113_0082_weibo_recharge_records.py | 187 ++++++++++++++++++ web/backend/models.py | 54 +++++ web/backend/routers/weibo.py | 9 + web/backend/schemas.py | 23 +++ web/backend/services/weibo_collab_service.py | 128 +++++++++++- web/backend/services/weibo_service.py | 72 ++++++- web/frontend/src/api/weiboCollab.ts | 23 +++ web/frontend/src/pages/WeiboCollabPage.tsx | 180 ++++++++++++++++- 11 files changed, 740 insertions(+), 10 deletions(-) create mode 100644 web/backend/migrations/versions/20261113_0082_weibo_recharge_records.py diff --git a/tests/test_migrations.py b/tests/test_migrations.py index da69ff0..df58050 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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 = ( diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index 9c26727..a3ac8eb 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -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): diff --git a/tests/test_weibo_collab.py b/tests/test_weibo_collab.py index fdea71e..4eaa77c 100644 --- a/tests/test_weibo_collab.py +++ b/tests/test_weibo_collab.py @@ -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) diff --git a/web/backend/migrations/versions/20261113_0082_weibo_recharge_records.py b/web/backend/migrations/versions/20261113_0082_weibo_recharge_records.py new file mode 100644 index 0000000..0167366 --- /dev/null +++ b/web/backend/migrations/versions/20261113_0082_weibo_recharge_records.py @@ -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") diff --git a/web/backend/models.py b/web/backend/models.py index d111ed1..b1c3b7e 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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): """虎牙自动注册批次(持久化元数据)""" diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index 4fedadc..f9c4a83 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -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, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index aba5bfe..3cb7604 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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): diff --git a/web/backend/services/weibo_collab_service.py b/web/backend/services/weibo_collab_service.py index bc6f48c..d33a5bd 100644 --- a/web/backend/services/weibo_collab_service.py +++ b/web/backend/services/weibo_collab_service.py @@ -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, ) diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py index 29572a1..0f35bfd 100644 --- a/web/backend/services/weibo_service.py +++ b/web/backend/services/weibo_service.py @@ -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, diff --git a/web/frontend/src/api/weiboCollab.ts b/web/frontend/src/api/weiboCollab.ts index bfe71fe..92ff080 100644 --- a/web/frontend/src/api/weiboCollab.ts +++ b/web/frontend/src/api/weiboCollab.ts @@ -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 { diff --git a/web/frontend/src/pages/WeiboCollabPage.tsx b/web/frontend/src/pages/WeiboCollabPage.tsx index 1e9a801..3bafac4 100644 --- a/web/frontend/src/pages/WeiboCollabPage.tsx +++ b/web/frontend/src/pages/WeiboCollabPage.tsx @@ -71,7 +71,7 @@ const { TextArea } = Input; const STATUS_META: Record = { 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([]); const [statusSaving, setStatusSaving] = useState(false); + const [noteTarget, setNoteTarget] = useState(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) => ( - - {row.status_label || value} - {row.exchange_verdict === 'unknown' && 待确认} + + + {row.status_label || value} + {row.exchange_verdict === 'unknown' && 待确认} + {row.funding_source_label && ( + + {row.funding_source_label} + + )} + +
{ + 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 || + 备注} +
), }, @@ -1569,6 +1635,29 @@ export default function WeiboCollabPage() { + void saveNote()} + onCancel={() => { + setNoteTarget(null); + setNoteValue(''); + }} + destroyOnHidden + > +