feat(weibo): 优化协作单详情并增加已上脚本状态

This commit is contained in:
yml
2026-11-12 22:27:57 +08:00
parent f3aab9dabb
commit 9cf4f65a3d
8 changed files with 524 additions and 38 deletions
@@ -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")
+1
View File
@@ -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
)
+1
View File
@@ -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
+18 -2
View File
@@ -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,
+2
View File
@@ -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;
+392 -27
View File
@@ -72,6 +72,7 @@ const STATUS_META: Record<WeiboCollabStatus, { label: string; color: string }> =
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<string, string> = {
error: '查询失败',
};
const ACCOUNT_CK_COLORS: Record<string, string> = {
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<string, { label: string; color: string }> = {
'': { label: '未判定', color: 'default' },
};
const TIMELINE_ACTION_LABELS: Record<string, string> = {
'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<string, string> = {
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<string, unknown>;
try {
const parsed = JSON.parse(detail) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return detail || '-';
}
data = parsed as Record<string, unknown>;
} 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() {
</Space>
),
},
{
title: 'CK 状态',
dataIndex: 'account_ck_status',
width: 140,
render: (value: string, row) => {
const label = ACCOUNT_CK_LABELS[value] || value || '未检测';
return (
<Space orientation="vertical" size={2} style={{ width: '100%' }}>
<Tag color={ACCOUNT_CK_COLORS[value] || 'default'}>{label}</Tag>
{row.account_ck_message && (
<Tooltip title={row.account_ck_message}>
<Text
type="secondary"
style={{
display: 'block',
maxWidth: 120,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 12,
}}
>
{row.account_ck_message}
</Text>
</Tooltip>
)}
</Space>
);
},
},
{
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() {
</Modal>
<Drawer
title={detail ? `协作单详情 · ${detail.order_no}` : '协作单详情'}
title={
detail ? (
<Space size={10} wrap>
<span>协作单详情</span>
<Text code copyable={{ text: detail.order_no }}>
{detail.order_no}
</Text>
<Tag color={STATUS_META[detail.status]?.color}>
{detail.status_label}
</Tag>
</Space>
) : (
'协作单详情'
)
}
open={detailOpen}
onClose={() => setDetailOpen(false)}
size={880}
size="min(1180px, 96vw)"
loading={detailLoading}
>
{detail && (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Row gutter={[12, 12]}>
{[
{
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) => (
<Col xs={12} md={6} key={item.title}>
<div
style={{
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 6,
background: token.colorBgContainer,
padding: '10px 12px',
}}
>
<Statistic
title={item.title}
value={item.value ?? '-'}
suffix={item.value == null ? undefined : item.suffix}
valueStyle={{
fontSize: 22,
color: item.color,
}}
/>
</div>
</Col>
))}
</Row>
<Descriptions
size="small"
column={2}
bordered
column={3}
items={[
{ key: 'shop', label: '店铺 / 负责人', children: `${detail.shop || '-'} / ${detail.owner_name || '-'}` },
{ key: 'shop', label: '店铺', children: detail.shop || '-' },
{ key: 'owner', label: '负责人', children: detail.owner_name || '-' },
{
key: 'status',
label: '业务状态',
children: (
<Tag color={STATUS_META[detail.status]?.color}>
{detail.status_label}
</Tag>
),
},
{
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: <Tag color={STATUS_META[detail.status]?.color}>{detail.status_label}</Tag> },
{ 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 && (
<Space wrap>
<Space wrap size={10}>
{detail.home_images.map((key) => (
<img
key={key}
src={`/api/files/object?key=${encodeURIComponent(key)}&variant=thumb`}
alt="主页截图"
style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 4 }}
style={{
width: 112,
height: 112,
objectFit: 'cover',
borderRadius: 6,
border: `1px solid ${token.colorBorderSecondary}`,
}}
/>
))}
</Space>
@@ -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) => (
<Text type={value != null && value < 0 ? 'danger' : undefined}>
{value?.toLocaleString() ?? '-'}
</Text>
),
},
{
title: '兑换匹配',
width: 120,
render: (_, row) => (
<Space size={4}>
<Tag color={VERDICT_META[row.verdict]?.color}>
{row.verdict_label ||
VERDICT_META[row.verdict]?.label ||
'未判定'}
</Tag>
<Text type="secondary">
{row.records_matched}/{row.records_total}
</Text>
</Space>
),
},
{
title: '结论',
dataIndex: 'message',
render: (value: string) => value || '-',
},
{ title: '结论', dataIndex: 'message' },
]}
/>
<Title level={5}>操作时间线</Title>
@@ -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) => (
<Tag color="blue">{timelineActionLabel(value)}</Tag>
),
},
{
title: '详情',
dataIndex: 'detail',
render: (value: string, row) => (
<Text style={{ fontSize: 13 }}>
{timelineDetailText(row.action, value)}
</Text>
),
},
]}
/>
</Space>