From 9cf4f65a3d2f6a727b0cd89247886f25fb3f35f4 Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 12 Nov 2026 22:27:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E4=BC=98=E5=8C=96=E5=8D=8F?= =?UTF-8?q?=E4=BD=9C=E5=8D=95=E8=AF=A6=E6=83=85=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=B7=B2=E4=B8=8A=E8=84=9A=E6=9C=AC=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_migrations.py | 72 ++- tests/test_weibo_collab.py | 2 + .../20261112_0080_weibo_order_scripted_at.py | 45 ++ web/backend/models.py | 1 + web/backend/schemas.py | 1 + web/backend/services/weibo_collab_service.py | 20 +- web/frontend/src/api/weiboCollab.ts | 2 + web/frontend/src/pages/WeiboCollabPage.tsx | 419 ++++++++++++++++-- 8 files changed, 524 insertions(+), 38 deletions(-) create mode 100644 web/backend/migrations/versions/20261112_0080_weibo_order_scripted_at.py diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 14f4a33..3acf135 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -29,14 +29,13 @@ from web.backend.models import ( HuyaExternalOrder, User, WeiboAccount, - WeiboExternalOrder, ) TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261112_0079" +HEAD_REVISION = "20261112_0080" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( @@ -441,14 +440,16 @@ class TestMigrationSmoke: cfg.set_main_option("sqlalchemy.url", mig_url) command.upgrade(cfg, "20261112_0078") - with Session(mig_engine) as session: - order = WeiboExternalOrder( - order_no="MIG-0079-PENDING", - status="exchange_pending", - exchange_started_at=datetime(2026, 11, 12, 8, 30, tzinfo=UTC), + with mig_engine.begin() as conn: + conn.execute( + text( + "INSERT INTO weibo_external_orders " + "(order_no, status, exchange_started_at, created_at, updated_at) " + "VALUES ('MIG-0079-PENDING', 'exchange_pending', " + "'2026-11-12 08:30:00', " + "'2026-11-12 08:00:00', '2026-11-12 08:30:00')" + ) ) - session.add(order) - session.commit() command.upgrade(cfg, "20261112_0079") @@ -467,3 +468,56 @@ class TestMigrationSmoke: mig_engine.dispose() with admin_engine.begin() as conn: conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`;")) + + def test_0080_backfills_scripted_at_for_done_orders(self): + creds = get_mysql_credentials() + mig_db = f"douyu_login_mig_{secrets.token_hex(4)}" + mig_url = ( + f"mysql+pymysql://{quote_plus(creds['user'])}:" + f"{quote_plus(creds['password'])}@{creds['host']}:{creds['port']}/" + f"{mig_db}?charset=utf8mb4" + ) + + admin_engine = get_test_engine() + with admin_engine.begin() as conn: + conn.execute( + text( + f"CREATE DATABASE `{mig_db}` " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;" + ) + ) + + original_url = database_module.DATABASE_URL + mig_engine = create_engine(mig_url) + try: + database_module.DATABASE_URL = mig_url + cfg = _migration_config() + cfg.set_main_option("sqlalchemy.url", mig_url) + command.upgrade(cfg, "20261112_0079") + with mig_engine.begin() as conn: + conn.execute( + text( + "INSERT INTO weibo_external_orders " + "(order_no, status, exchange_started_at, done_at, " + " created_at, updated_at) " + "VALUES ('MIG-0080-DONE', 'done', " + "'2026-11-12 08:00:00', '2026-11-12 08:10:00', " + "'2026-11-12 07:00:00', '2026-11-12 08:10:00')" + ) + ) + + command.upgrade(cfg, "20261112_0080") + + with mig_engine.connect() as conn: + row = conn.execute( + text( + "SELECT scripted_at FROM weibo_external_orders " + "WHERE order_no = 'MIG-0080-DONE'" + ) + ).one() + assert row.scripted_at is not None + finally: + database_module.DATABASE_URL = original_url + mig_engine.dispose() + with admin_engine.begin() as conn: + conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`;")) diff --git a/tests/test_weibo_collab.py b/tests/test_weibo_collab.py index e1bf37b..6968c52 100644 --- a/tests/test_weibo_collab.py +++ b/tests/test_weibo_collab.py @@ -146,7 +146,9 @@ def test_exchange_record_unique_match_and_archive(monkeypatch, db): monkeypatch.setattr(collab, "WeiboClient", FakeClient) collab.set_status(db, user, first, collab.STATUS_RECHARGED) + collab.set_status(db, user, first, collab.STATUS_SCRIPTED) collab.set_status(db, user, second, collab.STATUS_RECHARGED) + assert first.scripted_at is not None result = collab.check_exchange_records(db, user, [first, second]) diff --git a/web/backend/migrations/versions/20261112_0080_weibo_order_scripted_at.py b/web/backend/migrations/versions/20261112_0080_weibo_order_scripted_at.py new file mode 100644 index 0000000..6debf3d --- /dev/null +++ b/web/backend/migrations/versions/20261112_0080_weibo_order_scripted_at.py @@ -0,0 +1,45 @@ +"""微博外部单协作增加上脚本时间,并回填已完成订单。""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261112_0080" +down_revision: str | None = "20261112_0079" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "weibo_external_orders" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table(TABLE): + return + columns = {column["name"] for column in inspector.get_columns(TABLE)} + if "scripted_at" not in columns: + op.add_column(TABLE, sa.Column("scripted_at", sa.DateTime(), nullable=True)) + bind.execute( + sa.text( + f""" + UPDATE {TABLE} + SET scripted_at = COALESCE(scripted_at, exchange_started_at, done_at, updated_at) + WHERE status IN ('scripted', 'done') + AND scripted_at IS NULL + """ + ) + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table(TABLE): + return + columns = {column["name"] for column in inspector.get_columns(TABLE)} + if "scripted_at" in columns: + op.drop_column(TABLE, "scripted_at") diff --git a/web/backend/models.py b/web/backend/models.py index abd243a..a515abc 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -1115,6 +1115,7 @@ class WeiboExternalOrder(Base): String(20), default="incomplete", server_default="incomplete", 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( UTCDateTime, nullable=True ) diff --git a/web/backend/schemas.py b/web/backend/schemas.py index d98f40e..12743a5 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -934,6 +934,7 @@ class WeiboCollabOrderOut(BaseModel): exchange_verdict: str = "" exchange_message: str = "" recharged_at: datetime | None = None + scripted_at: datetime | None = None done_at: datetime | None = None void_reason: str = "" is_archived: bool = False diff --git a/web/backend/services/weibo_collab_service.py b/web/backend/services/weibo_collab_service.py index ae66bd0..d95f2fe 100644 --- a/web/backend/services/weibo_collab_service.py +++ b/web/backend/services/weibo_collab_service.py @@ -33,6 +33,7 @@ from .weibo_service import query_weibo_mall, refresh_weibo_account STATUS_INCOMPLETE = "incomplete" STATUS_TO_RECHARGE = "to_recharge" STATUS_RECHARGED = "recharged" +STATUS_SCRIPTED = "scripted" STATUS_DONE = "done" STATUS_WEIDOU_ERROR = "weidou_error" STATUS_FAILED = "failed" @@ -42,6 +43,7 @@ ORDER_STATUSES = ( STATUS_INCOMPLETE, STATUS_TO_RECHARGE, STATUS_RECHARGED, + STATUS_SCRIPTED, STATUS_DONE, STATUS_WEIDOU_ERROR, STATUS_FAILED, @@ -52,6 +54,7 @@ ORDER_STATUS_LABELS = { STATUS_INCOMPLETE: "待补资料", STATUS_TO_RECHARGE: "待充值", STATUS_RECHARGED: "已充值", + STATUS_SCRIPTED: "已上脚本", STATUS_DONE: "兑换成功", STATUS_WEIDOU_ERROR: "微豆异常", STATUS_FAILED: "兑换失败", @@ -62,13 +65,15 @@ STATUS_FLOW = ( STATUS_INCOMPLETE, STATUS_TO_RECHARGE, STATUS_RECHARGED, + STATUS_SCRIPTED, STATUS_DONE, ) STAGE_INDEX = {status: index for index, status in enumerate(STATUS_FLOW)} NEXT_STATUS = { STATUS_INCOMPLETE: STATUS_TO_RECHARGE, STATUS_TO_RECHARGE: STATUS_RECHARGED, - STATUS_RECHARGED: STATUS_DONE, + STATUS_RECHARGED: STATUS_SCRIPTED, + STATUS_SCRIPTED: STATUS_DONE, } BALANCE_GROUP_STATUSES = ( @@ -712,15 +717,25 @@ def set_status( now = _now() if target == STATUS_TO_RECHARGE: order.recharged_at = None + order.scripted_at = None order.exchange_started_at = None order.done_at = None elif target in (STATUS_RECHARGED, STATUS_WEIDOU_ERROR): if order.recharged_at is None: order.recharged_at = now if rollback: + order.scripted_at = None order.exchange_started_at = None order.done_at = None + elif target == STATUS_SCRIPTED: + if order.recharged_at is None: + raise CollabError("请先完成余额核账", code="recharge_check_required") + if order.scripted_at is None: + order.scripted_at = now + order.done_at = None elif target == STATUS_DONE: + if order.scripted_at is None: + order.scripted_at = now if order.exchange_started_at is None: order.exchange_started_at = now order.done_at = now @@ -1216,7 +1231,7 @@ def check_exchange_records( order.exchange_record_snapshot = _json_safe(record) order.exchange_verdict = VERDICT_SUCCESS order.exchange_message = "兑换记录唯一命中" - if order.status == STATUS_RECHARGED: + if order.status in (STATUS_RECHARGED, STATUS_SCRIPTED): set_status( db, current, @@ -1518,6 +1533,7 @@ def serialize_order( "exchange_verdict": order.exchange_verdict or "", "exchange_message": order.exchange_message or "", "recharged_at": order.recharged_at, + "scripted_at": order.scripted_at, "done_at": order.done_at, "void_reason": order.void_reason or "", "is_archived": order.archived_at is not None, diff --git a/web/frontend/src/api/weiboCollab.ts b/web/frontend/src/api/weiboCollab.ts index dbfc31b..4d7034b 100644 --- a/web/frontend/src/api/weiboCollab.ts +++ b/web/frontend/src/api/weiboCollab.ts @@ -4,6 +4,7 @@ export type WeiboCollabStatus = | 'incomplete' | 'to_recharge' | 'recharged' + | 'scripted' | 'done' | 'weidou_error' | 'failed' @@ -119,6 +120,7 @@ export interface WeiboCollabOrder { exchange_verdict: WeiboCollabVerdict; exchange_message: string; recharged_at: string | null; + scripted_at: string | null; done_at: string | null; void_reason: string; is_archived: boolean; diff --git a/web/frontend/src/pages/WeiboCollabPage.tsx b/web/frontend/src/pages/WeiboCollabPage.tsx index c283484..2589a7f 100644 --- a/web/frontend/src/pages/WeiboCollabPage.tsx +++ b/web/frontend/src/pages/WeiboCollabPage.tsx @@ -72,6 +72,7 @@ const STATUS_META: Record = incomplete: { label: '待补资料', color: 'default' }, to_recharge: { label: '待充值', color: 'blue' }, recharged: { label: '已充值', color: 'cyan' }, + scripted: { label: '已上脚本', color: 'purple' }, done: { label: '兑换成功', color: 'success' }, weidou_error: { label: '微豆异常', color: 'orange' }, failed: { label: '兑换失败', color: 'error' }, @@ -84,10 +85,17 @@ const ACCOUNT_CK_LABELS: Record = { error: '查询失败', }; +const ACCOUNT_CK_COLORS: Record = { + valid: 'success', + invalid: 'error', + error: 'warning', +}; + const STATUS_ORDER: WeiboCollabStatus[] = [ 'incomplete', 'to_recharge', 'recharged', + 'scripted', 'done', 'weidou_error', 'failed', @@ -115,6 +123,117 @@ const VERDICT_META: Record = { '': { label: '未判定', color: 'default' }, }; +const TIMELINE_ACTION_LABELS: Record = { + 'weibo:collab:create': '新建协作单', + 'weibo:collab:update': '编辑资料', + 'weibo:collab:verify': '校验账号', + 'weibo:collab:status': '修改状态', + 'weibo:collab:status_auto': '自动流转', + 'weibo:collab:balance_check': '充值核账', + 'weibo:collab:exchange_check': '查询兑换记录', + 'weibo:collab:void': '作废订单', + 'weibo:collab:archive': '归档订单', + 'weibo:collab:unarchive': '取消归档', +}; + +const TIMELINE_FIELD_LABELS: Record = { + from: '原状态', + to: '新状态', + reason: '原因', + account_id: '账号 ID', + uid: 'UID', + role: '绑定角色', + ck_status: 'CK 状态', + balance: '余额', + required_total: '需求合计', + surplus: '结余', + threshold: '阈值', + goods_name: '商品', + required_weidou: '所需微豆', + shop: '店铺', + draft: '草稿', + order_no: '订单号', + records_total: '兑换记录数', + records_matched: '匹配数', + verdict: '兑换结论', + message: '说明', +}; + +function statusLabel(value: unknown): string { + const key = String(value || ''); + return STATUS_META[key as WeiboCollabStatus]?.label || key || '-'; +} + +function metricSourceLabel(source: string): string { + if (source === 'balance_check') return '充值核账'; + if (source === 'exchange_records') return '兑换记录'; + return source || '其他查询'; +} + +function timelineActionLabel(action: string): string { + return TIMELINE_ACTION_LABELS[action] || action || '其他操作'; +} + +function timelineDetailText(action: string, detail: string): string { + let data: Record; + try { + const parsed = JSON.parse(detail) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return detail || '-'; + } + data = parsed as Record; + } catch { + return detail || '-'; + } + + const parts: string[] = []; + if (data.from !== undefined || data.to !== undefined) { + parts.push(`${statusLabel(data.from)} → ${statusLabel(data.to)}`); + } + if (action === 'weibo:collab:balance_check') { + parts.push( + `余额 ${Number(data.balance ?? 0).toLocaleString()} / 需求 ${Number(data.required_total ?? 0).toLocaleString()} / 结余 ${Number(data.surplus ?? 0).toLocaleString()}`, + ); + } + if (action === 'weibo:collab:exchange_check') { + parts.push( + `匹配 ${data.records_matched ?? 0}/${data.records_total ?? 0} 条兑换记录`, + ); + } + const skipKeys = new Set([ + 'from', + 'to', + 'balance', + 'required_total', + 'surplus', + 'records_matched', + 'records_total', + 'account_id', + 'order_ids', + 'group_key', + ]); + Object.entries(data).forEach(([key, value]) => { + if (skipKeys.has(key) || value === null || value === undefined || value === '') { + return; + } + const label = TIMELINE_FIELD_LABELS[key] || '详情'; + const rendered = + typeof value === 'boolean' + ? value + ? '是' + : '否' + : typeof value === 'object' + ? value && + 'old' in value && + 'new' in value + ? `${String((value as { old?: unknown }).old ?? '-')} → ${String((value as { new?: unknown }).new ?? '-')}` + : '已更新' + : String(value); + parts.push(`${label}:${rendered}`); + }); + return parts.join(';') || '无附加信息'; +} + function balanceMeta(reason: string) { if (reason === 'ok') return { label: '余额正常', color: 'success' }; if (reason === 'shortage') return { label: '微豆不足', color: 'error' }; @@ -813,6 +932,36 @@ export default function WeiboCollabPage() { ), }, + { + title: 'CK 状态', + dataIndex: 'account_ck_status', + width: 140, + render: (value: string, row) => { + const label = ACCOUNT_CK_LABELS[value] || value || '未检测'; + return ( + + {label} + {row.account_ck_message && ( + + + {row.account_ck_message} + + + )} + + ); + }, + }, { title: '商品 / 微豆', width: 190, @@ -1225,7 +1374,7 @@ export default function WeiboCollabPage() { loading={loading} columns={columns} dataSource={orders} - scroll={{ x: 1500 }} + scroll={{ x: 1650 }} rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)), @@ -1439,19 +1588,99 @@ export default function WeiboCollabPage() { + 协作单详情 + + {detail.order_no} + + + {detail.status_label} + + + ) : ( + '协作单详情' + ) + } open={detailOpen} onClose={() => setDetailOpen(false)} - size={880} + size="min(1180px, 96vw)" loading={detailLoading} > {detail && ( + + {[ + { + title: '账号余额', + value: detail.weidou_balance, + suffix: '微豆', + color: undefined, + }, + { + title: '礼包需求', + value: detail.weidou_required_total ?? detail.required_weidou, + suffix: '微豆', + color: undefined, + }, + { + title: '当前结余', + value: detail.weidou_surplus, + suffix: '微豆', + color: + detail.weidou_surplus != null && + (detail.weidou_surplus < 0 || + (detail.weidou_threshold != null && + detail.weidou_surplus > detail.weidou_threshold)) + ? token.colorError + : '#389e0d', + }, + { + title: '允许结余', + value: detail.weidou_threshold, + suffix: '微豆', + color: undefined, + }, + ].map((item) => ( + +
+ +
+ + ))} +
+ {detail.status_label} + + ), + }, { key: 'account', label: '微博账号', @@ -1487,21 +1716,84 @@ export default function WeiboCollabPage() { ), }, { key: 'role', label: '绑定角色', children: detail.game_role_name || '-' }, - { key: 'goods', label: '商品 / 所需微豆', children: `${detail.goods_name || '-'} / ${detail.required_weidou ?? '-'}` }, - { key: 'status', label: '业务状态', children: {detail.status_label} }, - { key: 'balance', label: '最近核账', children: detail.weidou_check_message || '-' }, - { key: 'exchange', label: '兑换确认', children: detail.exchange_message || '-' }, - { key: 'updated', label: '最后修改', children: `${detail.updated_by_name || '-'} · ${formatTime(detail.updated_at)}` }, + { + key: 'ck', + label: 'CK 状态', + children: + ACCOUNT_CK_LABELS[detail.account_ck_status] || + detail.account_ck_status || + '未检测', + }, + { + key: 'goods', + label: '商品', + children: detail.goods_name || '-', + }, + { + key: 'required', + label: '所需微豆', + children: + detail.required_weidou?.toLocaleString() ?? '-', + }, + { + key: 'checked', + label: '最近核账', + children: formatTime(detail.weidou_checked_at), + }, + { + key: 'scripted', + label: '上脚本时间', + children: formatTime(detail.scripted_at), + }, + { + key: 'balance_message', + label: '核账结论', + span: 2, + children: detail.weidou_check_message || '尚未核账', + }, + { + key: 'exchange', + label: '兑换确认', + span: 3, + children: detail.exchange_message || '尚未查询兑换记录', + }, + { + key: 'created', + label: '创建记录', + children: `${detail.created_by_name || '-'} · ${formatTime(detail.created_at)}`, + }, + { + key: 'updated', + label: '最后修改', + span: 2, + children: `${detail.updated_by_name || '-'} · ${formatTime(detail.updated_at)}`, + }, + ...(detail.remark + ? [ + { + key: 'remark', + label: '备注', + span: 3, + children: detail.remark, + }, + ] + : []), ]} /> {detail.home_images.length > 0 && ( - + {detail.home_images.map((key) => ( 主页截图 ))} @@ -1526,23 +1818,70 @@ export default function WeiboCollabPage() { rowKey="id" size="small" pagination={false} + scroll={{ x: 980 }} + locale={{ emptyText: '暂无核账或兑换记录' }} dataSource={detailMetrics} columns={[ - { title: '时间', dataIndex: 'created_at', width: 170, render: formatTime }, - { title: '来源', dataIndex: 'source', width: 130 }, { - title: '核账', - width: 220, - render: (_, row) => - `余额 ${row.weidou_balance ?? '-'} / 合计 ${row.weidou_required_total ?? '-'} / 结余 ${row.weidou_surplus ?? '-'}`, + title: '查询时间', + dataIndex: 'created_at', + width: 170, + render: formatTime, }, { - title: '兑换', - width: 220, - render: (_, row) => - `${row.verdict_label || '未判定'} · ${row.records_matched}/${row.records_total}`, + title: '查询类型', + dataIndex: 'source', + width: 110, + render: (value: string) => metricSourceLabel(value), + }, + { + title: '账号余额', + dataIndex: 'weidou_balance', + width: 110, + align: 'right', + render: (value: number | null) => + value?.toLocaleString() ?? '-', + }, + { + title: '需求合计', + dataIndex: 'weidou_required_total', + width: 110, + align: 'right', + render: (value: number | null) => + value?.toLocaleString() ?? '-', + }, + { + title: '结余', + dataIndex: 'weidou_surplus', + width: 110, + align: 'right', + render: (value: number | null) => ( + + {value?.toLocaleString() ?? '-'} + + ), + }, + { + title: '兑换匹配', + width: 120, + render: (_, row) => ( + + + {row.verdict_label || + VERDICT_META[row.verdict]?.label || + '未判定'} + + + {row.records_matched}/{row.records_total} + + + ), + }, + { + title: '结论', + dataIndex: 'message', + render: (value: string) => value || '-', }, - { title: '结论', dataIndex: 'message' }, ]} /> 操作时间线 @@ -1550,12 +1889,38 @@ export default function WeiboCollabPage() { rowKey="id" size="small" pagination={false} + scroll={{ x: 980 }} + locale={{ emptyText: '暂无操作记录' }} dataSource={detailTimeline} columns={[ - { title: '时间', dataIndex: 'created_at', width: 170, render: formatTime }, - { title: '操作人', dataIndex: 'username', width: 120 }, - { title: '动作', dataIndex: 'action', width: 190 }, - { title: '详情', dataIndex: 'detail' }, + { + title: '操作时间', + dataIndex: 'created_at', + width: 170, + render: formatTime, + }, + { + title: '操作人', + dataIndex: 'username', + width: 100, + }, + { + title: '动作', + dataIndex: 'action', + width: 140, + render: (value: string) => ( + {timelineActionLabel(value)} + ), + }, + { + title: '详情', + dataIndex: 'detail', + render: (value: string, row) => ( + + {timelineDetailText(row.action, value)} + + ), + }, ]} />