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
+1 -1
View File
@@ -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 = (
+12 -1
View File
@@ -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):
+58 -1
View File
@@ -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")
+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,
+23
View File
@@ -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 {
+175 -5
View File
@@ -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 && (
<>