feat(weibo): 增加协作 CK 失效筛选与实时同步开关

This commit is contained in:
yml
2026-11-12 20:36:11 +08:00
parent aa978a808e
commit f3aab9dabb
6 changed files with 283 additions and 27 deletions
+66
View File
@@ -4,7 +4,9 @@ from __future__ import annotations
from sqlalchemy.orm import Session 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
from web.backend.routers import weibo_collab as weibo_collab_router
from web.backend.schemas import WeiboCollabCkExportOut from web.backend.schemas import WeiboCollabCkExportOut
from web.backend.services import weibo_collab_service as collab from web.backend.services import weibo_collab_service as collab
@@ -223,3 +225,67 @@ def test_export_cks_exposes_skipped_orders(db):
assert dumped["exported"] == 1 assert dumped["exported"] == 1
assert {item["id"] for item in dumped["skipped"]} == {unbound.id, 999999} assert {item["id"] for item in dumped["skipped"]} == {unbound.id, 999999}
assert any("还没关联微博账号" in item["reason"] for item in dumped["skipped"]) assert any("还没关联微博账号" in item["reason"] for item in dumped["skipped"])
def test_list_orders_filters_invalid_ck_and_summary_excludes_error(db):
user = make_user(db)
valid_account = make_account(db, uid="4014000101")
invalid_account = make_account(db, uid="4014000102")
error_account = make_account(db, uid="4014000103")
invalid_account.ck_check_status = "invalid"
error_account.ck_check_status = "error"
db.commit()
for index, account in enumerate(
(valid_account, invalid_account, error_account), start=1
):
collab.create_order(
db,
user,
order_no=f"WB-CK-FILTER-{index}",
account_id=account.id,
goods_name="商品A",
required_weidou=100,
require_verified=False,
)
invalid = collab.list_orders(
db,
page=1,
page_size=20,
ck_status="invalid",
)
assert invalid["total"] == 1
assert invalid["items"][0]["account_id"] == invalid_account.id
assert collab.summary(db)["ck_invalid"] == 1
def test_verify_batch_persists_ck_invalid_after_row_rollback(monkeypatch, db):
user = make_user(db)
account = make_account(db)
order = collab.create_order(
db,
user,
order_no="WB-CK-VERIFY-INVALID",
account_id=account.id,
goods_name="商品A",
required_weidou=100,
require_verified=False,
)
def fail_refresh(*_args, **_kwargs):
raise WeiboAuthError("CK 已过期")
monkeypatch.setattr(collab, "refresh_weibo_account", fail_refresh)
result = weibo_collab_router.verify_external_orders_batch(
[order.id],
db=db,
current=user,
)
assert result["success"] is False
assert result["results"][0]["ck_status"] == "invalid"
db.refresh(account)
assert account.ck_check_status == "invalid"
+18 -1
View File
@@ -48,6 +48,7 @@ from ..schemas import (
) )
from ..services import weibo_collab_service as collab from ..services import weibo_collab_service as collab
from ..services.weibo_collab_events import collab_events from ..services.weibo_collab_events import collab_events
from ..services.weibo_service import mark_weibo_account_refresh_failure
router = APIRouter(prefix="/api/weibo", tags=["微博外部单协作"]) router = APIRouter(prefix="/api/weibo", tags=["微博外部单协作"])
WEIBO_DEFAULT_ACTIVITY = os.getenv("WEIBO_DEFAULT_ACTIVITY", "").strip() WEIBO_DEFAULT_ACTIVITY = os.getenv("WEIBO_DEFAULT_ACTIVITY", "").strip()
@@ -320,6 +321,7 @@ def list_external_orders(
page_size: int = Query(20, ge=1, le=200), page_size: int = Query(20, ge=1, le=200),
archive_scope: str = Query("active"), archive_scope: str = Query("active"),
status: str = Query(""), status: str = Query(""),
ck_status: str = Query(""),
shop: str = Query(""), shop: str = Query(""),
owner_id: int | None = Query(None), owner_id: int | None = Query(None),
goods_name: str = Query(""), goods_name: str = Query(""),
@@ -334,6 +336,7 @@ def list_external_orders(
page_size=page_size, page_size=page_size,
archive_scope=archive_scope, archive_scope=archive_scope,
status=status, status=status,
ck_status=ck_status,
shop=shop, shop=shop,
owner_id=owner_id, owner_id=owner_id,
goods_name=goods_name, goods_name=goods_name,
@@ -397,30 +400,44 @@ def verify_external_orders_batch(
rows = collab.list_orders_by_ids(db, ids) rows = collab.list_orders_by_ids(db, ids)
results: list[dict] = [] results: list[dict] = []
for order in rows: for order in rows:
ck_status = ""
try: try:
with db.begin_nested(): with db.begin_nested():
collab.verify_order_account( account = collab.verify_order_account(
db, db,
current, current,
order, order,
activity=WEIBO_DEFAULT_ACTIVITY, activity=WEIBO_DEFAULT_ACTIVITY,
commit=False, commit=False,
) )
ck_status = account.ck_check_status
results.append( results.append(
{ {
"id": order.id, "id": order.id,
"order_no": order.order_no, "order_no": order.order_no,
"success": True, "success": True,
"message": "", "message": "",
"ck_status": ck_status,
} }
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
if order.account_id:
account = (
db.query(WeiboAccount)
.filter(WeiboAccount.id == order.account_id)
.first()
)
if account and mark_weibo_account_refresh_failure(
db, account, exc, commit=False
):
ck_status = account.ck_check_status
results.append( results.append(
{ {
"id": order.id, "id": order.id,
"order_no": order.order_no, "order_no": order.order_no,
"success": False, "success": False,
"message": str(exc), "message": str(exc),
"ck_status": ck_status,
} }
) )
db.commit() db.commit()
+8 -1
View File
@@ -1553,6 +1553,7 @@ def _apply_filters(
*, *,
archive_scope: str = "active", archive_scope: str = "active",
status: str = "", status: str = "",
ck_status: str = "",
shop: str = "", shop: str = "",
owner_id: int | None = None, owner_id: int | None = None,
goods_name: str = "", goods_name: str = "",
@@ -1568,6 +1569,10 @@ def _apply_filters(
query = query.filter(WeiboExternalOrder.status != STATUS_VOID) query = query.filter(WeiboExternalOrder.status != STATUS_VOID)
if _text(status): if _text(status):
query = query.filter(WeiboExternalOrder.status == status) query = query.filter(WeiboExternalOrder.status == status)
if _text(ck_status):
query = query.join(
WeiboAccount, WeiboAccount.id == WeiboExternalOrder.account_id
).filter(WeiboAccount.ck_check_status == ck_status)
if _text(shop): if _text(shop):
query = query.filter(WeiboExternalOrder.shop == shop) query = query.filter(WeiboExternalOrder.shop == shop)
if owner_id is not None: if owner_id is not None:
@@ -1598,6 +1603,7 @@ def list_orders(
page_size: int, page_size: int,
archive_scope: str = "active", archive_scope: str = "active",
status: str = "", status: str = "",
ck_status: str = "",
shop: str = "", shop: str = "",
owner_id: int | None = None, owner_id: int | None = None,
goods_name: str = "", goods_name: str = "",
@@ -1608,6 +1614,7 @@ def list_orders(
_base_query(db), _base_query(db),
archive_scope=archive_scope, archive_scope=archive_scope,
status=status, status=status,
ck_status=ck_status,
shop=shop, shop=shop,
owner_id=owner_id, owner_id=owner_id,
goods_name=goods_name, goods_name=goods_name,
@@ -1710,7 +1717,7 @@ def summary(
) )
ck_invalid = ( ck_invalid = (
query.join(WeiboAccount, WeiboAccount.id == WeiboExternalOrder.account_id) query.join(WeiboAccount, WeiboAccount.id == WeiboExternalOrder.account_id)
.filter(WeiboAccount.ck_check_status.in_(("invalid", "error"))) .filter(WeiboAccount.ck_check_status == "invalid")
.with_entities(func.count(WeiboExternalOrder.id)) .with_entities(func.count(WeiboExternalOrder.id))
.scalar() .scalar()
or 0 or 0
+21 -3
View File
@@ -190,9 +190,7 @@ def refresh_weibo_account(
snapshot = client.fetch_home(activity) snapshot = client.fetch_home(activity)
client.check_payment_access(activity, home=snapshot) client.check_payment_access(activity, home=snapshot)
except (WeiboError, ValueError) as exc: except (WeiboError, ValueError) as exc:
account.ck_check_status = _error_status(exc) mark_weibo_account_refresh_failure(db, account, exc, commit=False)
account.ck_check_message = str(exc)
account.ck_checked_at = _now()
if commit: if commit:
db.commit() db.commit()
raise raise
@@ -204,6 +202,26 @@ def refresh_weibo_account(
return snapshot return snapshot
def mark_weibo_account_refresh_failure(
db: Session,
account: WeiboAccount,
exc: Exception,
*,
commit: bool = True,
) -> bool:
"""把一次刷新失败写回账号;业务校验失败不应被误标成 CK 失效。"""
if not isinstance(exc, (WeiboError, ValueError)):
return False
account.ck_check_status = _error_status(exc)
account.ck_check_message = str(exc)
account.ck_checked_at = _now()
if commit:
db.commit()
db.refresh(account)
return True
def update_weibo_account_metadata( def update_weibo_account_metadata(
db: Session, db: Session,
account: WeiboAccount, account: WeiboAccount,
+1
View File
@@ -288,6 +288,7 @@ export interface WeiboCollabVerifyBatchResult {
order_no: string; order_no: string;
success: boolean; success: boolean;
message: string; message: string;
ck_status: string;
}>; }>;
} }
+169 -22
View File
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
AutoComplete, AutoComplete,
Badge,
Button, Button,
Checkbox, Checkbox,
Col, Col,
@@ -17,6 +18,7 @@ import {
Select, Select,
Space, Space,
Statistic, Statistic,
Switch,
Table, Table,
Tag, Tag,
Tooltip, Tooltip,
@@ -92,6 +94,16 @@ const STATUS_ORDER: WeiboCollabStatus[] = [
'void', 'void',
]; ];
const REALTIME_PAUSED_STORAGE_KEY = 'weibo.collab.realtime-paused';
function readRealtimePaused(): boolean {
try {
return window.localStorage.getItem(REALTIME_PAUSED_STORAGE_KEY) === '1';
} catch {
return false;
}
}
const MANUAL_STATUS_OPTIONS = STATUS_ORDER.filter( const MANUAL_STATUS_OPTIONS = STATUS_ORDER.filter(
(item) => item !== 'weidou_error', (item) => item !== 'weidou_error',
).map((value) => ({ value, label: STATUS_META[value].label })); ).map((value) => ({ value, label: STATUS_META[value].label }));
@@ -147,12 +159,14 @@ export default function WeiboCollabPage() {
const [copyingCk, setCopyingCk] = useState(false); const [copyingCk, setCopyingCk] = useState(false);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState(''); const [status, setStatus] = useState('');
const [ckStatus, setCkStatus] = useState('');
const [shop, setShop] = useState(''); const [shop, setShop] = useState('');
const [ownerId, setOwnerId] = useState<number | null>(null); const [ownerId, setOwnerId] = useState<number | null>(null);
const [goodsName, setGoodsName] = useState(''); const [goodsName, setGoodsName] = useState('');
const [includeVoid, setIncludeVoid] = useState(false); const [includeVoid, setIncludeVoid] = useState(false);
const [archiveScope, setArchiveScope] = useState<'active' | 'archived' | 'all'>('active'); const [archiveScope, setArchiveScope] = useState<'active' | 'archived' | 'all'>('active');
const [threshold, setThreshold] = useState(100); const [threshold, setThreshold] = useState(100);
const [realtimePaused, setRealtimePaused] = useState(readRealtimePaused);
const [form] = Form.useForm<OrderFormValues>(); const [form] = Form.useForm<OrderFormValues>();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
@@ -214,6 +228,7 @@ export default function WeiboCollabPage() {
page_size: pageSize, page_size: pageSize,
archive_scope: archiveScope, archive_scope: archiveScope,
status: status || undefined, status: status || undefined,
ck_status: ckStatus || undefined,
shop: shop || undefined, shop: shop || undefined,
owner_id: ownerId ?? undefined, owner_id: ownerId ?? undefined,
goods_name: goodsName || undefined, goods_name: goodsName || undefined,
@@ -230,6 +245,7 @@ export default function WeiboCollabPage() {
} }
}, [ }, [
archiveScope, archiveScope,
ckStatus,
goodsName, goodsName,
includeVoid, includeVoid,
keyword, keyword,
@@ -253,12 +269,32 @@ export default function WeiboCollabPage() {
void loadOrders(); void loadOrders();
}, [loadOrders]); }, [loadOrders]);
useCollabRealtime({ const { connected: realtimeConnected, reconnecting: realtimeReconnecting } =
enabled: true, useCollabRealtime({
enabled: !realtimePaused,
path: '/api/weibo/external-orders/ws', path: '/api/weibo/external-orders/ws',
onInvalidate: () => refreshAll(), onInvalidate: () => refreshAll(),
}); });
const toggleRealtimeSync = (nextEnabled: boolean) => {
const nextPaused = !nextEnabled;
setRealtimePaused(nextPaused);
try {
window.localStorage.setItem(
REALTIME_PAUSED_STORAGE_KEY,
nextPaused ? '1' : '0',
);
} catch {
// localStorage 不可写时仍保留本次会话内的开关状态。
}
if (nextPaused) {
message.info('已暂停实时同步,需要最新数据时请点「刷新」');
return;
}
message.success('已恢复实时同步');
refreshAll();
};
const accountOptions = accounts.map((item) => ({ const accountOptions = accounts.map((item) => ({
value: item.id, value: item.id,
label: `${item.nickname || item.uid} · UID ${item.uid}${item.game_role_name ? ` · ${item.game_role_name}` : ''}`, label: `${item.nickname || item.uid} · UID ${item.uid}${item.game_role_name ? ` · ${item.game_role_name}` : ''}`,
@@ -466,8 +502,22 @@ export default function WeiboCollabPage() {
if (!ids.length) return; if (!ids.length) return;
try { try {
const result = await weiboCollabApi.verifyBatch(ids); const result = await weiboCollabApi.verifyBatch(ids);
const invalidCount = result.results.filter(
(item) => item.ck_status === 'invalid',
).length;
if (invalidCount > 0) {
setStatus('');
setCkStatus('invalid');
setPage(1);
void loadBase();
message.warning(`检测到 ${invalidCount} 个 CK 失效,已切换到 CK 失效列表`);
return;
}
if (result.success) message.success(`已校验 ${ids.length} 单`); if (result.success) message.success(`已校验 ${ids.length} 单`);
else message.warning(`校验完成,${result.results.filter((item) => !item.success).length} 单失败`); else
message.warning(
`校验完成,${result.results.filter((item) => !item.success).length} 单失败`,
);
refreshAll(); refreshAll();
} catch (error) { } catch (error) {
message.error(getErrorMessage(error)); message.error(getErrorMessage(error));
@@ -615,6 +665,18 @@ export default function WeiboCollabPage() {
[summary], [summary],
); );
const applyStatusCard = (nextStatus: string) => {
setStatus(nextStatus);
setCkStatus('');
setPage(1);
};
const applyCkInvalidCard = () => {
setStatus('');
setCkStatus('invalid');
setPage(1);
};
const copySelectedUsernames = async () => { const copySelectedUsernames = async () => {
const selectedOrders = orders.filter((row) => selectedIds.includes(row.id)); const selectedOrders = orders.filter((row) => selectedIds.includes(row.id));
const values = Array.from( const values = Array.from(
@@ -894,26 +956,96 @@ export default function WeiboCollabPage() {
gap: 8, gap: 8,
}} }}
> >
{statusCards.map((item) => ( {statusCards.map((item) => {
<button const active = !ckStatus && status === item.key;
key={item.key || 'all'} return (
type="button" <button
onClick={() => { key={item.key || 'all'}
setStatus(item.key); type="button"
setPage(1); onClick={() => applyStatusCard(item.key)}
}} style={{
style={{ border: `1px solid ${active ? token.colorPrimary : token.colorBorderSecondary}`,
border: `1px solid ${status === item.key ? token.colorPrimary : token.colorBorderSecondary}`, background: active ? token.colorPrimaryBg : token.colorBgContainer,
background: status === item.key ? token.colorPrimaryBg : token.colorBgContainer, borderRadius: 6,
borderRadius: 6, padding: '10px 12px',
padding: '10px 12px', textAlign: 'left',
textAlign: 'left', cursor: 'pointer',
cursor: 'pointer', }}
}} >
<Statistic
title={item.label}
value={item.value}
valueStyle={{ fontSize: 20 }}
/>
</button>
);
})}
<button
type="button"
onClick={applyCkInvalidCard}
style={{
border: `1px solid ${ckStatus === 'invalid' ? token.colorPrimary : token.colorBorderSecondary}`,
background:
ckStatus === 'invalid'
? token.colorPrimaryBg
: token.colorBgContainer,
borderRadius: 6,
padding: '10px 12px',
textAlign: 'left',
cursor: 'pointer',
}}
>
<Statistic
title="CK 失效"
value={summary?.ck_invalid ?? 0}
valueStyle={{ fontSize: 20, color: token.colorError }}
/>
</button>
<div
style={{
border: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
borderRadius: 6,
padding: '10px 12px',
}}
>
<Space
size={6}
style={{ width: '100%', justifyContent: 'space-between' }}
> >
<Statistic title={item.label} value={item.value} valueStyle={{ fontSize: 20 }} /> <Text strong style={{ fontSize: 13 }}>
</button> 实时同步
))} </Text>
<Switch
size="small"
checked={!realtimePaused}
onChange={toggleRealtimeSync}
aria-label="实时同步开关"
/>
</Space>
<Badge
status={
realtimePaused
? 'default'
: realtimeConnected
? 'success'
: realtimeReconnecting
? 'warning'
: 'error'
}
text={
<Text type="secondary" style={{ fontSize: 12 }}>
{realtimePaused
? '已暂停,仅手动刷新'
: realtimeConnected
? '已连接,自动刷新'
: realtimeReconnecting
? '重连中'
: '未连接'}
</Text>
}
/>
</div>
</div> </div>
<div <div
@@ -972,6 +1104,21 @@ export default function WeiboCollabPage() {
...STATUS_ORDER.map((item) => ({ value: item, label: STATUS_META[item].label })), ...STATUS_ORDER.map((item) => ({ value: item, label: STATUS_META[item].label })),
]} ]}
/> />
<Select
value={ckStatus || undefined}
onChange={(value) => {
setCkStatus(value || '');
setPage(1);
}}
allowClear
placeholder="CK 状态"
style={{ width: 130 }}
options={[
{ value: 'valid', label: ACCOUNT_CK_LABELS.valid },
{ value: 'invalid', label: ACCOUNT_CK_LABELS.invalid },
{ value: 'error', label: ACCOUNT_CK_LABELS.error },
]}
/>
<Select <Select
value={archiveScope} value={archiveScope}
onChange={(value) => { onChange={(value) => {