feat(huya): 协作台账增加 WebSocket 实时同步

- 后端新增进程内事件总线与 /api/huya/external-orders/ws\n- 所有写接口成功后发布轻量失效事件\n- 前端自动重连、静默刷新、断线兜底轮询
This commit is contained in:
yml2213
2026-10-17 13:44:27 +08:00
parent 26b8d6b054
commit cad06ae789
6 changed files with 519 additions and 12 deletions
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from 'react';
export interface CollabRealtimeEvent {
type: 'collab.ready' | 'collab.ping' | 'collab.invalidate';
revision: number;
scope?: 'orders' | 'shops' | 'all';
action?: string;
actor_id?: number | null;
at?: string;
}
interface UseCollabRealtimeOptions {
enabled: boolean;
onInvalidate: (event: CollabRealtimeEvent) => void;
}
const WS_PATH = '/api/huya/external-orders/ws';
const HEARTBEAT_TIMEOUT_MS = 60_000;
const MAX_RECONNECT_DELAY_MS = 30_000;
function websocketUrl(path: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${protocol}://${window.location.host}${path}`;
}
/**
* 协作台账实时失效通知。
*
* 服务端只推「数据变了」,前端收到后按自己的筛选条件静默重拉;
* 断线自动重连,页面恢复可见/浏览器恢复在线时立即重连。
*/
export function useCollabRealtime({
enabled,
onInvalidate,
}: UseCollabRealtimeOptions) {
const [connected, setConnected] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const onInvalidateRef = useRef(onInvalidate);
const lastRevisionRef = useRef(0);
useEffect(() => {
onInvalidateRef.current = onInvalidate;
}, [onInvalidate]);
useEffect(() => {
if (!enabled) {
setConnected(false);
setReconnecting(false);
return undefined;
}
let stopped = false;
let socket: WebSocket | null = null;
let reconnectTimer: number | undefined;
let heartbeatTimer: number | undefined;
let attempts = 0;
const clearReconnectTimer = () => {
if (reconnectTimer !== undefined) {
window.clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
};
const clearHeartbeatTimer = () => {
if (heartbeatTimer !== undefined) {
window.clearTimeout(heartbeatTimer);
heartbeatTimer = undefined;
}
};
const armHeartbeat = () => {
clearHeartbeatTimer();
heartbeatTimer = window.setTimeout(() => {
// 服务端每 25 秒至少发一次 ping;超时说明连接已假死,主动重连。
socket?.close();
}, HEARTBEAT_TIMEOUT_MS);
};
const scheduleReconnect = () => {
if (stopped || reconnectTimer !== undefined) return;
setReconnecting(true);
const delay = Math.min(
MAX_RECONNECT_DELAY_MS,
1_000 * 2 ** Math.min(attempts, 5),
);
attempts += 1;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = undefined;
connect();
}, delay + Math.floor(Math.random() * 400));
};
const handleMessage = (event: MessageEvent) => {
armHeartbeat();
let message: CollabRealtimeEvent;
try {
message = JSON.parse(String(event.data)) as CollabRealtimeEvent;
} catch {
return;
}
if (!message || typeof message.revision !== 'number') return;
const previousRevision = lastRevisionRef.current;
lastRevisionRef.current = Math.max(previousRevision, message.revision);
if (message.type === 'collab.invalidate') {
onInvalidateRef.current(message);
return;
}
if (message.type === 'collab.ready' && previousRevision > 0) {
// 重连成功:断线期间可能漏过事件,补一次静默刷新。
onInvalidateRef.current({
type: 'collab.invalidate',
revision: message.revision,
scope: 'all',
action: 'reconnect',
});
}
};
const connect = () => {
if (stopped) return;
if (
socket &&
(socket.readyState === WebSocket.CONNECTING ||
socket.readyState === WebSocket.OPEN)
) {
return;
}
const nextSocket = new WebSocket(websocketUrl(WS_PATH));
socket = nextSocket;
nextSocket.onopen = () => {
attempts = 0;
setConnected(true);
setReconnecting(false);
armHeartbeat();
};
nextSocket.onmessage = handleMessage;
nextSocket.onerror = () => nextSocket.close();
nextSocket.onclose = () => {
clearHeartbeatTimer();
const wasActive = socket === nextSocket;
if (wasActive) socket = null;
setConnected(false);
if (!stopped && wasActive) scheduleReconnect();
};
};
const reconnectNow = () => {
if (stopped) return;
clearReconnectTimer();
if (socket && socket.readyState === WebSocket.OPEN) return;
attempts = 0;
socket?.close();
socket = null;
connect();
};
const handleOnline = () => reconnectNow();
const handleVisibility = () => {
if (document.visibilityState === 'visible') reconnectNow();
};
connect();
window.addEventListener('online', handleOnline);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
stopped = true;
clearReconnectTimer();
clearHeartbeatTimer();
window.removeEventListener('online', handleOnline);
document.removeEventListener('visibilitychange', handleVisibility);
socket?.close();
socket = null;
setConnected(false);
setReconnecting(false);
};
}, [enabled]);
return { connected, reconnecting };
}
+78 -8
View File
@@ -42,9 +42,18 @@ import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
import { copyTextToClipboard } from '../utils/clipboard';
import { usePermissions } from '../hooks/usePermissions';
import { useCollabRealtime, type CollabRealtimeEvent } from '../hooks/useCollabRealtime';
const { Text, Paragraph } = Typography;
function hasTextInputFocus(): boolean {
const active = document.activeElement;
return (
active instanceof HTMLElement &&
(active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)
);
}
const STATUS_META: Record<HuyaCollabStatus, { label: string; color: string }> = {
pending: { label: '待处理', color: 'default' },
// 人工核对过「探针读到的绑定角色」与「主页截图」一致
@@ -292,6 +301,7 @@ export default function HuyaCollabPage() {
const [giftNames, setGiftNames] = useState<string[]>([]);
const [shopModalOpen, setShopModalOpen] = useState(false);
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({ include_void: false });
const [realtimePending, setRealtimePending] = useState(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [detail, setDetail] = useState<HuyaCollabOrder | null>(null);
@@ -347,22 +357,24 @@ export default function HuyaCollabPage() {
const [ckExport, setCkExport] = useState<HuyaCollabCkExportResult | null>(null);
const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain');
const load = useCallback(async () => {
setLoading(true);
const load = useCallback(async (options?: { silent?: boolean; withMeta?: boolean }) => {
const silent = options?.silent === true;
const withMeta = options?.withMeta !== false;
if (!silent) setLoading(true);
try {
const [list, nextSummary, nextOwners, nextShops, nextGiftNames] = await Promise.all([
huyaApi.listCollabOrders({ ...filters, page, page_size: pageSize }),
huyaApi.collabOrdersSummary(),
huyaApi.collabOrderOwners(),
huyaApi.listCollabShops(),
huyaApi.collabOrderGifts(),
withMeta ? huyaApi.collabOrderOwners() : Promise.resolve(null),
withMeta ? huyaApi.listCollabShops() : Promise.resolve(null),
withMeta ? huyaApi.collabOrderGifts() : Promise.resolve(null),
]);
setRows(list.items);
setTotal(list.total);
setSummary(nextSummary);
setOwners(nextOwners);
setShops(nextShops);
setGiftNames(nextGiftNames);
if (nextOwners) setOwners(nextOwners);
if (nextShops) setShops(nextShops);
if (nextGiftNames) setGiftNames(nextGiftNames);
// 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。
setSelectedIds((ids) => ids.filter((id) => list.items.some((row) => row.id === id)));
} catch (e: unknown) {
@@ -374,6 +386,56 @@ export default function HuyaCollabPage() {
useEffect(() => { void load(); }, [load]);
const realtimeEnabled = can('huya:collab:view');
const collabModalOpen = Boolean(
createOpen || detail || statusAction || batchStatusOpen || ckTarget || ckExportOpen || shopModalOpen,
);
const handleRealtimeInvalidate = useCallback((event: CollabRealtimeEvent) => {
if (!realtimeEnabled) return;
if (collabModalOpen || hasTextInputFocus()) {
setRealtimePending(true);
return;
}
void load({ silent: true, withMeta: event.scope !== 'orders' });
}, [realtimeEnabled, collabModalOpen, load]);
const {
connected: realtimeConnected,
reconnecting: realtimeReconnecting,
} = useCollabRealtime({
enabled: realtimeEnabled,
onInvalidate: handleRealtimeInvalidate,
});
useEffect(() => {
if (!realtimePending || collabModalOpen) return undefined;
let cancelled = false;
const flush = () => {
if (cancelled || collabModalOpen || hasTextInputFocus()) return;
setRealtimePending(false);
void load({ silent: true, withMeta: true });
};
const timer = window.setTimeout(flush, 300);
const handleFocusOut = () => window.setTimeout(flush, 0);
window.addEventListener('focusout', handleFocusOut);
return () => {
cancelled = true;
window.clearTimeout(timer);
window.removeEventListener('focusout', handleFocusOut);
};
}, [realtimePending, collabModalOpen, load]);
// WebSocket 断开时用低频轮询兜底;连接正常时完全不轮询。
useEffect(() => {
if (!realtimeEnabled || realtimeConnected) return undefined;
const timer = window.setInterval(() => {
if (document.visibilityState !== 'visible' || collabModalOpen) return;
void load({ silent: true, withMeta: false });
}, 30_000);
return () => window.clearInterval(timer);
}, [realtimeEnabled, realtimeConnected, collabModalOpen, load]);
const applyUpdated = useCallback((updated: HuyaCollabOrder) => {
setRows((list) => list.map((row) => (row.id === updated.id ? updated : row)));
setDetail((current) => (current && current.id === updated.id ? updated : current));
@@ -1325,6 +1387,14 @@ export default function HuyaCollabPage() {
{/* 第二行:动作(固定在这一行;勾选后加「(N)」只是按钮变宽,不会把列表顶下去) */}
<Space wrap style={{ width: '100%' }}>
<Button icon={<ReloadOutlined />} onClick={() => void load()} disabled={loading}>刷新</Button>
{realtimeEnabled && (
<Tooltip title={realtimeConnected ? '协作台账实时同步已连接' : '实时连接已断开,正在自动重连'}>
<Tag color={realtimeConnected ? 'success' : realtimeReconnecting ? 'warning' : 'default'}>
{realtimeConnected ? '实时同步' : realtimeReconnecting ? '实时重连中' : '实时离线'}
</Tag>
</Tooltip>
)}
{realtimePending && <Tag color="processing">有更新待同步</Tag>}
{canEdit && (
<Button icon={<SafetyCertificateOutlined />} disabled={selectedIds.length === 0 || busy}
onClick={() => void verifySelected()}>