diff --git a/tests/test_weibo_collab.py b/tests/test_weibo_collab.py index a27b6b5..e1bf37b 100644 --- a/tests/test_weibo_collab.py +++ b/tests/test_weibo_collab.py @@ -4,7 +4,9 @@ from __future__ import annotations from sqlalchemy.orm import Session +from core.weibo import WeiboAuthError 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.services import weibo_collab_service as collab @@ -223,3 +225,67 @@ def test_export_cks_exposes_skipped_orders(db): assert dumped["exported"] == 1 assert {item["id"] for item in dumped["skipped"]} == {unbound.id, 999999} 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" diff --git a/web/backend/routers/weibo_collab.py b/web/backend/routers/weibo_collab.py index 19f04c0..9952606 100644 --- a/web/backend/routers/weibo_collab.py +++ b/web/backend/routers/weibo_collab.py @@ -48,6 +48,7 @@ from ..schemas import ( ) from ..services import weibo_collab_service as collab from ..services.weibo_collab_events import collab_events +from ..services.weibo_service import mark_weibo_account_refresh_failure router = APIRouter(prefix="/api/weibo", tags=["微博外部单协作"]) 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), archive_scope: str = Query("active"), status: str = Query(""), + ck_status: str = Query(""), shop: str = Query(""), owner_id: int | None = Query(None), goods_name: str = Query(""), @@ -334,6 +336,7 @@ def list_external_orders( page_size=page_size, archive_scope=archive_scope, status=status, + ck_status=ck_status, shop=shop, owner_id=owner_id, goods_name=goods_name, @@ -397,30 +400,44 @@ def verify_external_orders_batch( rows = collab.list_orders_by_ids(db, ids) results: list[dict] = [] for order in rows: + ck_status = "" try: with db.begin_nested(): - collab.verify_order_account( + account = collab.verify_order_account( db, current, order, activity=WEIBO_DEFAULT_ACTIVITY, commit=False, ) + ck_status = account.ck_check_status results.append( { "id": order.id, "order_no": order.order_no, "success": True, "message": "", + "ck_status": ck_status, } ) 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( { "id": order.id, "order_no": order.order_no, "success": False, "message": str(exc), + "ck_status": ck_status, } ) db.commit() diff --git a/web/backend/services/weibo_collab_service.py b/web/backend/services/weibo_collab_service.py index d4c1176..ae66bd0 100644 --- a/web/backend/services/weibo_collab_service.py +++ b/web/backend/services/weibo_collab_service.py @@ -1553,6 +1553,7 @@ def _apply_filters( *, archive_scope: str = "active", status: str = "", + ck_status: str = "", shop: str = "", owner_id: int | None = None, goods_name: str = "", @@ -1568,6 +1569,10 @@ def _apply_filters( query = query.filter(WeiboExternalOrder.status != STATUS_VOID) if _text(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): query = query.filter(WeiboExternalOrder.shop == shop) if owner_id is not None: @@ -1598,6 +1603,7 @@ def list_orders( page_size: int, archive_scope: str = "active", status: str = "", + ck_status: str = "", shop: str = "", owner_id: int | None = None, goods_name: str = "", @@ -1608,6 +1614,7 @@ def list_orders( _base_query(db), archive_scope=archive_scope, status=status, + ck_status=ck_status, shop=shop, owner_id=owner_id, goods_name=goods_name, @@ -1710,7 +1717,7 @@ def summary( ) ck_invalid = ( 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)) .scalar() or 0 diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py index 4739f28..29572a1 100644 --- a/web/backend/services/weibo_service.py +++ b/web/backend/services/weibo_service.py @@ -190,9 +190,7 @@ def refresh_weibo_account( snapshot = client.fetch_home(activity) client.check_payment_access(activity, home=snapshot) except (WeiboError, ValueError) as exc: - account.ck_check_status = _error_status(exc) - account.ck_check_message = str(exc) - account.ck_checked_at = _now() + mark_weibo_account_refresh_failure(db, account, exc, commit=False) if commit: db.commit() raise @@ -204,6 +202,26 @@ def refresh_weibo_account( 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( db: Session, account: WeiboAccount, diff --git a/web/frontend/src/api/weiboCollab.ts b/web/frontend/src/api/weiboCollab.ts index 4abf7c5..dbfc31b 100644 --- a/web/frontend/src/api/weiboCollab.ts +++ b/web/frontend/src/api/weiboCollab.ts @@ -288,6 +288,7 @@ export interface WeiboCollabVerifyBatchResult { order_no: string; success: boolean; message: string; + ck_status: string; }>; } diff --git a/web/frontend/src/pages/WeiboCollabPage.tsx b/web/frontend/src/pages/WeiboCollabPage.tsx index 5e875d9..c283484 100644 --- a/web/frontend/src/pages/WeiboCollabPage.tsx +++ b/web/frontend/src/pages/WeiboCollabPage.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { Alert, AutoComplete, + Badge, Button, Checkbox, Col, @@ -17,6 +18,7 @@ import { Select, Space, Statistic, + Switch, Table, Tag, Tooltip, @@ -92,6 +94,16 @@ const STATUS_ORDER: WeiboCollabStatus[] = [ '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( (item) => item !== 'weidou_error', ).map((value) => ({ value, label: STATUS_META[value].label })); @@ -147,12 +159,14 @@ export default function WeiboCollabPage() { const [copyingCk, setCopyingCk] = useState(false); const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState(''); + const [ckStatus, setCkStatus] = useState(''); const [shop, setShop] = useState(''); const [ownerId, setOwnerId] = useState(null); const [goodsName, setGoodsName] = useState(''); const [includeVoid, setIncludeVoid] = useState(false); const [archiveScope, setArchiveScope] = useState<'active' | 'archived' | 'all'>('active'); const [threshold, setThreshold] = useState(100); + const [realtimePaused, setRealtimePaused] = useState(readRealtimePaused); const [form] = Form.useForm(); const [editOpen, setEditOpen] = useState(false); @@ -214,6 +228,7 @@ export default function WeiboCollabPage() { page_size: pageSize, archive_scope: archiveScope, status: status || undefined, + ck_status: ckStatus || undefined, shop: shop || undefined, owner_id: ownerId ?? undefined, goods_name: goodsName || undefined, @@ -230,6 +245,7 @@ export default function WeiboCollabPage() { } }, [ archiveScope, + ckStatus, goodsName, includeVoid, keyword, @@ -253,12 +269,32 @@ export default function WeiboCollabPage() { void loadOrders(); }, [loadOrders]); - useCollabRealtime({ - enabled: true, + const { connected: realtimeConnected, reconnecting: realtimeReconnecting } = + useCollabRealtime({ + enabled: !realtimePaused, path: '/api/weibo/external-orders/ws', 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) => ({ value: item.id, 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; try { 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} 单`); - else message.warning(`校验完成,${result.results.filter((item) => !item.success).length} 单失败`); + else + message.warning( + `校验完成,${result.results.filter((item) => !item.success).length} 单失败`, + ); refreshAll(); } catch (error) { message.error(getErrorMessage(error)); @@ -615,6 +665,18 @@ export default function WeiboCollabPage() { [summary], ); + const applyStatusCard = (nextStatus: string) => { + setStatus(nextStatus); + setCkStatus(''); + setPage(1); + }; + + const applyCkInvalidCard = () => { + setStatus(''); + setCkStatus('invalid'); + setPage(1); + }; + const copySelectedUsernames = async () => { const selectedOrders = orders.filter((row) => selectedIds.includes(row.id)); const values = Array.from( @@ -894,26 +956,96 @@ export default function WeiboCollabPage() { gap: 8, }} > - {statusCards.map((item) => ( - + ); + })} + +
+ - - - ))} + + 实时同步 + + + + + {realtimePaused + ? '已暂停,仅手动刷新' + : realtimeConnected + ? '已连接,自动刷新' + : realtimeReconnecting + ? '重连中' + : '未连接'} + + } + /> +
({ value: item, label: STATUS_META[item].label })), ]} /> + {