fix(snapshot): 前端 API 改走 Next.js rewrites 代理 + WebSocket 改 REST 轮询

- snapshot.api.ts: 从直连 localhost:port 改为 /api/snapshots/* 走 Next.js 代理
- next.config: 两个前端都添加 /api/snapshots/:path* → snapshot-service 代理规则
- docker-compose.2.0-snapshot.yml: overlay 中追加 mining-admin-web 的 SNAPSHOT_SERVICE_URL
- useSnapshotWebSocket → useSnapshotPolling: 2秒轮询 GET /snapshots/:id 获取进度
- 移除 socket.io-client 依赖(Next.js standalone 不支持 WebSocket proxy)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-24 00:37:41 -08:00
parent 9a88fb473a
commit ee94f1420d
13 changed files with 270 additions and 262 deletions

View File

@ -4,6 +4,11 @@
# 纯新增 overlay不修改任何现有服务配置
services:
# 为 mining-admin-web 补充 snapshot-service 代理地址
mining-admin-web:
environment:
- SNAPSHOT_SERVICE_URL=http://snapshot-service-2:3199
snapshot-service-2:
build:
context: ./snapshot-service

View File

@ -14,6 +14,15 @@ const nextConfig: NextConfig = {
},
],
},
async rewrites() {
const snapshotServiceUrl = process.env.SNAPSHOT_SERVICE_URL || 'http://localhost:3099';
return [
{
source: '/api/snapshots/:path*',
destination: `${snapshotServiceUrl}/api/v1/:path*`,
},
];
},
async redirects() {
return [
{

View File

@ -28,7 +28,6 @@
"react-redux": "^9.2.0",
"recharts": "^2.15.0",
"redux-persist": "^6.0.0",
"socket.io-client": "^4.7.4",
"xlsx": "^0.18.5",
"zustand": "^5.0.3"
},

View File

@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { snapshotApi } from '@/infrastructure/api/snapshot.api';
import { useSnapshotWebSocket } from '@/hooks/useSnapshotWebSocket';
import { useSnapshotPolling } from '@/hooks/useSnapshotPolling';
import type {
BackupTarget,
StorageType,
@ -66,8 +66,8 @@ export default function SnapshotsPage() {
// 当前执行中的任务
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const { progresses, isConnected, taskCompleted, totalSize, duration } =
useSnapshotWebSocket(activeTaskId);
const { progresses, taskCompleted, totalSize, duration } =
useSnapshotPolling(activeTaskId);
// 历史列表
const [tasks, setTasks] = useState<SnapshotTask[]>([]);
@ -245,10 +245,7 @@ export default function SnapshotsPage() {
{/* 实时进度 */}
{activeTaskId && progresses.size > 0 && (
<div className={styles.card}>
<h2 className={styles.cardTitle}>
{isConnected && <span className={styles.wsStatus}>WebSocket </span>}
</h2>
<h2 className={styles.cardTitle}></h2>
<div className={styles.progressList}>
{Array.from(progresses.values()).map((p) => (

View File

@ -0,0 +1,117 @@
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { snapshotApi } from '@/infrastructure/api/snapshot.api';
import type { BackupTarget, SnapshotTask } from '@/types/snapshot.types';
interface TargetProgress {
target: BackupTarget;
percent: number;
message: string;
status: 'pending' | 'running' | 'completed' | 'failed';
}
interface UseSnapshotPollingReturn {
progresses: Map<string, TargetProgress>;
taskCompleted: boolean;
totalSize: string | null;
duration: number | null;
error: string | null;
}
const POLL_INTERVAL = 2000; // 2 秒轮询
export function useSnapshotPolling(taskId: string | null): UseSnapshotPollingReturn {
const [progresses, setProgresses] = useState<Map<string, TargetProgress>>(new Map());
const [taskCompleted, setTaskCompleted] = useState(false);
const [totalSize, setTotalSize] = useState<string | null>(null);
const [duration, setDuration] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const updateFromTask = useCallback((task: SnapshotTask) => {
// 从 task.details 构建进度 Map
const next = new Map<string, TargetProgress>();
for (const d of task.details) {
const statusMap: Record<string, TargetProgress['status']> = {
PENDING: 'pending',
RUNNING: 'running',
COMPLETED: 'completed',
FAILED: 'failed',
};
next.set(d.target, {
target: d.target,
percent: d.status === 'COMPLETED' ? 100 : d.progress,
message:
d.error
? d.error
: d.status === 'COMPLETED'
? '完成'
: d.status === 'RUNNING'
? `备份中... ${d.progress}%`
: '等待中',
status: statusMap[d.status] || 'pending',
});
}
setProgresses(next);
// 任务整体完成/失败 → 停止轮询
if (task.status === 'COMPLETED' || task.status === 'FAILED') {
setTaskCompleted(true);
setTotalSize(task.totalSize);
if (task.startedAt && task.completedAt) {
setDuration(new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime());
}
if (task.error) {
setError(task.error);
}
}
}, []);
useEffect(() => {
if (!taskId) return;
// 重置状态
setProgresses(new Map());
setTaskCompleted(false);
setTotalSize(null);
setDuration(null);
setError(null);
let stopped = false;
const poll = async () => {
if (stopped) return;
try {
const task = await snapshotApi.getById(taskId);
if (stopped) return;
updateFromTask(task);
// 任务结束,停止轮询
if (task.status === 'COMPLETED' || task.status === 'FAILED') {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
} catch {
// 网络错误静默忽略,下次继续轮询
}
};
// 立即拉一次
poll();
// 定时轮询
timerRef.current = setInterval(poll, POLL_INTERVAL);
return () => {
stopped = true;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [taskId, updateFromTask]);
return { progresses, taskCompleted, totalSize, duration, error };
}

View File

@ -1,117 +0,0 @@
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import type { BackupTarget, SnapshotProgress } from '@/types/snapshot.types';
const SNAPSHOT_WS_URL = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3099';
interface TargetProgress {
target: BackupTarget;
percent: number;
message: string;
status: 'pending' | 'running' | 'completed' | 'failed';
}
interface UseSnapshotWebSocketReturn {
progresses: Map<string, TargetProgress>;
isConnected: boolean;
taskCompleted: boolean;
totalSize: string | null;
duration: number | null;
error: string | null;
}
export function useSnapshotWebSocket(taskId: string | null): UseSnapshotWebSocketReturn {
const [progresses, setProgresses] = useState<Map<string, TargetProgress>>(new Map());
const [isConnected, setIsConnected] = useState(false);
const [taskCompleted, setTaskCompleted] = useState(false);
const [totalSize, setTotalSize] = useState<string | null>(null);
const [duration, setDuration] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
if (!taskId) return;
// 重置状态
setProgresses(new Map());
setTaskCompleted(false);
setTotalSize(null);
setDuration(null);
setError(null);
const socket = io(`${SNAPSHOT_WS_URL}/snapshots`, {
transports: ['websocket', 'polling'],
});
socketRef.current = socket;
socket.on('connect', () => setIsConnected(true));
socket.on('disconnect', () => setIsConnected(false));
socket.on('snapshot:started', (data: { taskId: string; targets: BackupTarget[] }) => {
if (data.taskId !== taskId) return;
const initial = new Map<string, TargetProgress>();
data.targets.forEach((t) => {
initial.set(t, { target: t, percent: 0, message: '等待中', status: 'pending' });
});
setProgresses(initial);
});
socket.on('snapshot:progress', (data: SnapshotProgress) => {
if (data.taskId !== taskId) return;
setProgresses((prev) => {
const next = new Map(prev);
next.set(data.target, {
target: data.target,
percent: data.percent,
message: data.message,
status: 'running',
});
return next;
});
});
socket.on('snapshot:target-complete', (data: { taskId: string; target: string; fileSize: string }) => {
if (data.taskId !== taskId) return;
setProgresses((prev) => {
const next = new Map(prev);
const existing = next.get(data.target);
if (existing) {
next.set(data.target, { ...existing, percent: 100, message: '完成', status: 'completed' });
}
return next;
});
});
socket.on('snapshot:complete', (data: { taskId: string; totalSize: string; duration: number }) => {
if (data.taskId !== taskId) return;
setTaskCompleted(true);
setTotalSize(data.totalSize);
setDuration(data.duration);
});
socket.on('snapshot:error', (data: { taskId: string; target?: string; error: string }) => {
if (data.taskId !== taskId) return;
if (data.target) {
setProgresses((prev) => {
const next = new Map(prev);
const existing = next.get(data.target!);
if (existing) {
next.set(data.target!, { ...existing, message: data.error, status: 'failed' });
}
return next;
});
} else {
setError(data.error);
}
});
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [taskId]);
return { progresses, isConnected, taskCompleted, totalSize, duration, error };
}

View File

@ -1,11 +1,9 @@
import type { CreateSnapshotDto, SnapshotTask } from '@/types/snapshot.types';
const SNAPSHOT_BASE = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3099';
/** snapshot-service 独立请求(不走通用 apiClient因为 snapshot 服务有独立端口) */
/** snapshot-service 请求 — 走 Next.js rewrites 代理 */
async function snapshotFetch<T>(url: string, options?: RequestInit): Promise<T> {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const res = await fetch(`${SNAPSHOT_BASE}/api/v1${url}`, {
const res = await fetch(`/api/snapshots${url}`, {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
@ -40,5 +38,5 @@ export const snapshotApi = {
snapshotFetch<{ message: string }>(`/snapshots/${id}`, { method: 'DELETE' }),
getDownloadUrl: (id: string, target: string) =>
`${SNAPSHOT_BASE}/api/v1/snapshots/${id}/download/${target}`,
`/api/snapshots/snapshots/${id}/download/${target}`,
};

View File

@ -10,6 +10,7 @@ const nextConfig = {
const miningAdminUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3023';
const tradingServiceUrl = process.env.TRADING_SERVICE_URL || 'http://localhost:3022';
const miningServiceUrl = process.env.MINING_SERVICE_URL || 'http://localhost:3021';
const snapshotServiceUrl = process.env.SNAPSHOT_SERVICE_URL || 'http://localhost:3199';
// 移除末尾可能存在的路径避免重复
const cleanMiningAdminUrl = miningAdminUrl.replace(/\/api\/v2.*$/, '');
@ -19,6 +20,10 @@ const nextConfig = {
if (apiGatewayUrl) {
// 通过 Kong 网关: 所有请求经 Kong 路由分发到各服务
return [
{
source: '/api/snapshots/:path*',
destination: `${snapshotServiceUrl}/api/v1/:path*`,
},
{
source: '/api/trading/:path*',
destination: `${apiGatewayUrl}/api/v2/trading/:path*`,
@ -35,6 +40,10 @@ const nextConfig = {
} else {
// 直连各服务: 前端与后端在同一 Docker 网络内直接通信
return [
{
source: '/api/snapshots/:path*',
destination: `${snapshotServiceUrl}/api/v1/:path*`,
},
{
source: '/api/trading/:path*',
destination: `${cleanTradingUrl}/api/v2/:path*`,

View File

@ -39,7 +39,6 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.50.1",
"react-redux": "^9.1.0",
"socket.io-client": "^4.7.4",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4",

View File

@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { snapshotApi } from '@/lib/api/snapshot.api';
import { useSnapshotWebSocket } from '@/lib/hooks/useSnapshotWebSocket';
import { useSnapshotPolling } from '@/lib/hooks/useSnapshotPolling';
import type {
BackupTarget,
StorageType,
@ -47,8 +47,8 @@ export default function SnapshotsPage() {
const [selectedTargets, setSelectedTargets] = useState<BackupTarget[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const { progresses, isConnected, taskCompleted, totalSize, duration } =
useSnapshotWebSocket(activeTaskId);
const { progresses, taskCompleted, totalSize, duration } =
useSnapshotPolling(activeTaskId);
const [tasks, setTasks] = useState<SnapshotTask[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
@ -222,14 +222,7 @@ export default function SnapshotsPage() {
{/* 实时进度 */}
{activeTaskId && progresses.size > 0 && (
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h2 className="mb-4 flex items-center gap-3 text-lg font-semibold">
{isConnected && (
<span className="rounded bg-green-50 px-2 py-0.5 text-xs font-normal text-green-600">
WebSocket
</span>
)}
</h2>
<h2 className="mb-4 text-lg font-semibold"></h2>
<div className="space-y-4">
{Array.from(progresses.values()).map((p) => (

View File

@ -1,10 +1,8 @@
import type { CreateSnapshotDto, SnapshotTask } from '@/types/snapshot.types';
const SNAPSHOT_BASE = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3199';
async function snapshotFetch<T>(url: string, options?: RequestInit): Promise<T> {
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null;
const res = await fetch(`${SNAPSHOT_BASE}/api/v1${url}`, {
const res = await fetch(`/api/snapshots${url}`, {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
@ -39,5 +37,5 @@ export const snapshotApi = {
snapshotFetch<{ message: string }>(`/snapshots/${id}`, { method: 'DELETE' }),
getDownloadUrl: (id: string, target: string) =>
`${SNAPSHOT_BASE}/api/v1/snapshots/${id}/download/${target}`,
`/api/snapshots/snapshots/${id}/download/${target}`,
};

View File

@ -0,0 +1,117 @@
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
import { snapshotApi } from '@/lib/api/snapshot.api';
import type { BackupTarget, SnapshotTask } from '@/types/snapshot.types';
interface TargetProgress {
target: BackupTarget;
percent: number;
message: string;
status: 'pending' | 'running' | 'completed' | 'failed';
}
interface UseSnapshotPollingReturn {
progresses: Map<string, TargetProgress>;
taskCompleted: boolean;
totalSize: string | null;
duration: number | null;
error: string | null;
}
const POLL_INTERVAL = 2000; // 2 秒轮询
export function useSnapshotPolling(taskId: string | null): UseSnapshotPollingReturn {
const [progresses, setProgresses] = useState<Map<string, TargetProgress>>(new Map());
const [taskCompleted, setTaskCompleted] = useState(false);
const [totalSize, setTotalSize] = useState<string | null>(null);
const [duration, setDuration] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const updateFromTask = useCallback((task: SnapshotTask) => {
// 从 task.details 构建进度 Map
const next = new Map<string, TargetProgress>();
for (const d of task.details) {
const statusMap: Record<string, TargetProgress['status']> = {
PENDING: 'pending',
RUNNING: 'running',
COMPLETED: 'completed',
FAILED: 'failed',
};
next.set(d.target, {
target: d.target,
percent: d.status === 'COMPLETED' ? 100 : d.progress,
message:
d.error
? d.error
: d.status === 'COMPLETED'
? '完成'
: d.status === 'RUNNING'
? `备份中... ${d.progress}%`
: '等待中',
status: statusMap[d.status] || 'pending',
});
}
setProgresses(next);
// 任务整体完成/失败 → 停止轮询
if (task.status === 'COMPLETED' || task.status === 'FAILED') {
setTaskCompleted(true);
setTotalSize(task.totalSize);
if (task.startedAt && task.completedAt) {
setDuration(new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime());
}
if (task.error) {
setError(task.error);
}
}
}, []);
useEffect(() => {
if (!taskId) return;
// 重置状态
setProgresses(new Map());
setTaskCompleted(false);
setTotalSize(null);
setDuration(null);
setError(null);
let stopped = false;
const poll = async () => {
if (stopped) return;
try {
const task = await snapshotApi.getById(taskId);
if (stopped) return;
updateFromTask(task);
// 任务结束,停止轮询
if (task.status === 'COMPLETED' || task.status === 'FAILED') {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
} catch {
// 网络错误静默忽略,下次继续轮询
}
};
// 立即拉一次
poll();
// 定时轮询
timerRef.current = setInterval(poll, POLL_INTERVAL);
return () => {
stopped = true;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [taskId, updateFromTask]);
return { progresses, taskCompleted, totalSize, duration, error };
}

View File

@ -1,116 +0,0 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import type { BackupTarget, SnapshotProgress } from '@/types/snapshot.types';
const SNAPSHOT_WS_URL = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3199';
interface TargetProgress {
target: BackupTarget;
percent: number;
message: string;
status: 'pending' | 'running' | 'completed' | 'failed';
}
interface UseSnapshotWebSocketReturn {
progresses: Map<string, TargetProgress>;
isConnected: boolean;
taskCompleted: boolean;
totalSize: string | null;
duration: number | null;
error: string | null;
}
export function useSnapshotWebSocket(taskId: string | null): UseSnapshotWebSocketReturn {
const [progresses, setProgresses] = useState<Map<string, TargetProgress>>(new Map());
const [isConnected, setIsConnected] = useState(false);
const [taskCompleted, setTaskCompleted] = useState(false);
const [totalSize, setTotalSize] = useState<string | null>(null);
const [duration, setDuration] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
if (!taskId) return;
setProgresses(new Map());
setTaskCompleted(false);
setTotalSize(null);
setDuration(null);
setError(null);
const socket = io(`${SNAPSHOT_WS_URL}/snapshots`, {
transports: ['websocket', 'polling'],
});
socketRef.current = socket;
socket.on('connect', () => setIsConnected(true));
socket.on('disconnect', () => setIsConnected(false));
socket.on('snapshot:started', (data: { taskId: string; targets: BackupTarget[] }) => {
if (data.taskId !== taskId) return;
const initial = new Map<string, TargetProgress>();
data.targets.forEach((t) => {
initial.set(t, { target: t, percent: 0, message: '等待中', status: 'pending' });
});
setProgresses(initial);
});
socket.on('snapshot:progress', (data: SnapshotProgress) => {
if (data.taskId !== taskId) return;
setProgresses((prev) => {
const next = new Map(prev);
next.set(data.target, {
target: data.target,
percent: data.percent,
message: data.message,
status: 'running',
});
return next;
});
});
socket.on('snapshot:target-complete', (data: { taskId: string; target: string; fileSize: string }) => {
if (data.taskId !== taskId) return;
setProgresses((prev) => {
const next = new Map(prev);
const existing = next.get(data.target);
if (existing) {
next.set(data.target, { ...existing, percent: 100, message: '完成', status: 'completed' });
}
return next;
});
});
socket.on('snapshot:complete', (data: { taskId: string; totalSize: string; duration: number }) => {
if (data.taskId !== taskId) return;
setTaskCompleted(true);
setTotalSize(data.totalSize);
setDuration(data.duration);
});
socket.on('snapshot:error', (data: { taskId: string; target?: string; error: string }) => {
if (data.taskId !== taskId) return;
if (data.target) {
setProgresses((prev) => {
const next = new Map(prev);
const existing = next.get(data.target!);
if (existing) {
next.set(data.target!, { ...existing, message: data.error, status: 'failed' });
}
return next;
});
} else {
setError(data.error);
}
});
return () => {
socket.disconnect();
socketRef.current = null;
};
}, [taskId]);
return { progresses, isConnected, taskCompleted, totalSize, duration, error };
}