feat(weibo): 增加协作 CK 失效筛选与实时同步开关
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -288,6 +288,7 @@ export interface WeiboCollabVerifyBatchResult {
|
||||
order_no: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
ck_status: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number | null>(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<OrderFormValues>();
|
||||
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) => (
|
||||
<button
|
||||
key={item.key || 'all'}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatus(item.key);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{
|
||||
border: `1px solid ${status === item.key ? token.colorPrimary : token.colorBorderSecondary}`,
|
||||
background: status === item.key ? token.colorPrimaryBg : token.colorBgContainer,
|
||||
borderRadius: 6,
|
||||
padding: '10px 12px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
{statusCards.map((item) => {
|
||||
const active = !ckStatus && status === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key || 'all'}
|
||||
type="button"
|
||||
onClick={() => applyStatusCard(item.key)}
|
||||
style={{
|
||||
border: `1px solid ${active ? token.colorPrimary : token.colorBorderSecondary}`,
|
||||
background: active ? token.colorPrimaryBg : token.colorBgContainer,
|
||||
borderRadius: 6,
|
||||
padding: '10px 12px',
|
||||
textAlign: 'left',
|
||||
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 }} />
|
||||
</button>
|
||||
))}
|
||||
<Text strong style={{ fontSize: 13 }}>
|
||||
实时同步
|
||||
</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
|
||||
@@ -972,6 +1104,21 @@ export default function WeiboCollabPage() {
|
||||
...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
|
||||
value={archiveScope}
|
||||
onChange={(value) => {
|
||||
|
||||
Reference in New Issue
Block a user