feat: 重构项目心跳数据结构并实现项目列表API
- 新增统一项目列表Redis键和迁移工具 - 实现GET /api/projects端点获取项目列表 - 实现POST /api/projects/migrate端点支持数据迁移 - 更新前端ProjectSelector组件使用真实项目数据 - 扩展projectStore状态管理 - 更新相关文档和OpenSpec规范 - 添加测试用例验证新功能
This commit is contained in:
29
src/backend/app.js
Normal file
29
src/backend/app.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
import logRoutes from './routes/logs.js';
|
||||
import commandRoutes from './routes/commands.js';
|
||||
import projectRoutes from './routes/projects.js';
|
||||
|
||||
export function createApp(options = {}) {
|
||||
const { redis } = options;
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/api/logs', logRoutes);
|
||||
app.use('/api/commands', commandRoutes);
|
||||
app.use('/api/projects', projectRoutes);
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
|
||||
if (redis) {
|
||||
app.locals.redis = redis;
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,14 +1,93 @@
|
||||
import express from 'express';
|
||||
import axios from 'axios';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
import { getRedisClient } from '../services/redisClient.js';
|
||||
import { projectControlKey } from '../services/redisKeys.js';
|
||||
import { projectHeartbeatKey } from '../services/redisKeys.js';
|
||||
import { getProjectsList } from '../services/migrateHeartbeatData.js';
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function splitCommandLine(commandLine) {
|
||||
const tokens = String(commandLine || '')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (tokens.length === 0) return { apiName: '', args: [], argsText: '' };
|
||||
const [ apiName, ...args ] = tokens;
|
||||
return {
|
||||
apiName: apiName.trim(),
|
||||
args,
|
||||
argsText: args.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLastActiveAt(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const asNum = Number(value);
|
||||
if (Number.isFinite(asNum)) return asNum;
|
||||
const asDate = Date.parse(value);
|
||||
if (Number.isFinite(asDate)) return asDate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildTargetUrl(apiBaseUrl, apiName) {
|
||||
const base = String(apiBaseUrl || '').trim();
|
||||
const name = String(apiName || '').trim();
|
||||
if (!base) return null;
|
||||
if (!name) return null;
|
||||
|
||||
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
|
||||
const normalizedName = name.startsWith('/') ? name.slice(1) : name;
|
||||
try {
|
||||
return new URL(normalizedName, normalizedBase).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function truncateForLog(value, maxLen = 2000) {
|
||||
if (value == null) return value;
|
||||
if (typeof value === 'string')
|
||||
return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value;
|
||||
return value;
|
||||
}
|
||||
|
||||
async function getProjectHeartbeat(redis, projectName) {
|
||||
try {
|
||||
const projectsList = await getProjectsList(redis);
|
||||
const project = projectsList.find((p) => p.projectName === projectName);
|
||||
|
||||
if (project) {
|
||||
return {
|
||||
apiBaseUrl: project.apiBaseUrl,
|
||||
lastActiveAt: project.lastActiveAt,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[getProjectHeartbeat] Failed to get from projects list:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
|
||||
if (heartbeatRaw) {
|
||||
try {
|
||||
return JSON.parse(heartbeatRaw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 发送指令
|
||||
router.post('/', async (req, res) => {
|
||||
const { targetProjectName, command, args } = req.body;
|
||||
@@ -28,12 +107,24 @@ router.post('/', async (req, res) => {
|
||||
}
|
||||
|
||||
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const { apiName, args: parsedArgs, argsText } = splitCommandLine(command);
|
||||
|
||||
if (!isNonEmptyString(apiName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '指令格式错误:第一个 token 必须为 API 接口名',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
id: commandId,
|
||||
commandId,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'BLS Project Console',
|
||||
command: command.trim(),
|
||||
args: typeof args === 'object' && args !== null ? args : undefined,
|
||||
apiName,
|
||||
args: parsedArgs,
|
||||
argsText,
|
||||
// backward compatible, if caller still sends structured args
|
||||
extraArgs: typeof args === 'object' && args !== null ? args : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -44,21 +135,98 @@ router.post('/', async (req, res) => {
|
||||
message: 'Redis 未就绪',
|
||||
});
|
||||
}
|
||||
const key = projectControlKey(targetProjectName.trim());
|
||||
await redis.rPush(key, JSON.stringify(payload));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: '指令已写入目标项目控制队列',
|
||||
const heartbeatKey = projectHeartbeatKey(targetProjectName.trim());
|
||||
const heartbeat = await getProjectHeartbeat(
|
||||
redis,
|
||||
targetProjectName.trim(),
|
||||
);
|
||||
|
||||
if (!heartbeat) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
message: `目标项目未上报心跳:${heartbeatKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
const apiBaseUrl =
|
||||
typeof heartbeat.apiBaseUrl === 'string'
|
||||
? heartbeat.apiBaseUrl.trim()
|
||||
: '';
|
||||
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
message: '目标项目心跳缺少 apiBaseUrl',
|
||||
});
|
||||
}
|
||||
|
||||
const offlineThresholdMs = Number.parseInt(
|
||||
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
|
||||
10,
|
||||
);
|
||||
const now = Date.now();
|
||||
if (
|
||||
!lastActiveAt ||
|
||||
(Number.isFinite(offlineThresholdMs) &&
|
||||
now - lastActiveAt > offlineThresholdMs)
|
||||
) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
message: '目标项目已离线(心跳超时)',
|
||||
lastActiveAt: lastActiveAt || null,
|
||||
});
|
||||
}
|
||||
|
||||
const targetUrl = buildTargetUrl(apiBaseUrl, apiName);
|
||||
if (!targetUrl) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无法构造目标 API 地址(请检查 apiBaseUrl/apiName)',
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutMs = Number.parseInt(
|
||||
process.env.COMMAND_API_TIMEOUT_MS || '5000',
|
||||
10,
|
||||
);
|
||||
const resp = await axios.post(targetUrl, payload, {
|
||||
timeout: Number.isFinite(timeoutMs) ? timeoutMs : 5000,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
if (resp.status >= 200 && resp.status < 300) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: '已调用目标项目 API',
|
||||
commandId,
|
||||
targetUrl,
|
||||
upstreamStatus: resp.status,
|
||||
upstreamData: truncateForLog(resp.data),
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
message: '目标项目 API 返回非成功状态',
|
||||
commandId,
|
||||
targetUrl,
|
||||
upstreamStatus: resp.status,
|
||||
upstreamData: truncateForLog(resp.data),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to enqueue command', err);
|
||||
return res.status(500).json({
|
||||
const status = err?.response?.status;
|
||||
const data = err?.response?.data;
|
||||
const message = err?.message || '调用失败';
|
||||
console.error('Failed to call target API', message);
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
message: '写入Redis失败',
|
||||
message,
|
||||
upstreamStatus: typeof status === 'number' ? status : undefined,
|
||||
upstreamData: truncateForLog(data),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
@@ -3,7 +3,11 @@ import express from 'express';
|
||||
const router = express.Router();
|
||||
|
||||
import { getRedisClient } from '../services/redisClient.js';
|
||||
import { projectConsoleKey, projectStatusKey } from '../services/redisKeys.js';
|
||||
import {
|
||||
projectConsoleKey,
|
||||
projectHeartbeatKey,
|
||||
} from '../services/redisKeys.js';
|
||||
import { getProjectsList } from '../services/migrateHeartbeatData.js';
|
||||
|
||||
function parsePositiveInt(value, defaultValue) {
|
||||
const num = Number.parseInt(String(value), 10);
|
||||
@@ -11,9 +15,53 @@ function parsePositiveInt(value, defaultValue) {
|
||||
return num;
|
||||
}
|
||||
|
||||
function parseLastActiveAt(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const asNum = Number(value);
|
||||
if (Number.isFinite(asNum)) return asNum;
|
||||
const asDate = Date.parse(value);
|
||||
if (Number.isFinite(asDate)) return asDate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getProjectHeartbeat(redis, projectName) {
|
||||
try {
|
||||
const projectsList = await getProjectsList(redis);
|
||||
const project = projectsList.find((p) => p.projectName === projectName);
|
||||
|
||||
if (project) {
|
||||
return {
|
||||
apiBaseUrl: project.apiBaseUrl,
|
||||
lastActiveAt: project.lastActiveAt,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[getProjectHeartbeat] Failed to get from projects list:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
|
||||
if (heartbeatRaw) {
|
||||
try {
|
||||
return JSON.parse(heartbeatRaw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取日志列表
|
||||
router.get('/', async (req, res) => {
|
||||
const projectName = typeof req.query.projectName === 'string' ? req.query.projectName.trim() : '';
|
||||
const projectName =
|
||||
typeof req.query.projectName === 'string'
|
||||
? req.query.projectName.trim()
|
||||
: '';
|
||||
const limit = parsePositiveInt(req.query.limit, 200);
|
||||
|
||||
if (!projectName) {
|
||||
@@ -36,35 +84,61 @@ router.get('/', async (req, res) => {
|
||||
const key = projectConsoleKey(projectName);
|
||||
const list = await redis.lRange(key, -limit, -1);
|
||||
|
||||
const logs = list
|
||||
.map((raw, idx) => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const timestamp = parsed.timestamp || new Date().toISOString();
|
||||
const level = (parsed.level || 'info').toString().toLowerCase();
|
||||
const message = parsed.message != null ? String(parsed.message) : '';
|
||||
return {
|
||||
id: parsed.id || `log-${timestamp}-${idx}`,
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
metadata: parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: `log-${Date.now()}-${idx}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: raw,
|
||||
};
|
||||
}
|
||||
});
|
||||
const logs = list.map((raw, idx) => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const timestamp = parsed.timestamp || new Date().toISOString();
|
||||
const level = (parsed.level || 'info').toString().toLowerCase();
|
||||
const message = parsed.message != null ? String(parsed.message) : '';
|
||||
return {
|
||||
id: parsed.id || `log-${timestamp}-${idx}`,
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
metadata:
|
||||
parsed.metadata && typeof parsed.metadata === 'object'
|
||||
? parsed.metadata
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: `log-${Date.now()}-${idx}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: raw,
|
||||
};
|
||||
}
|
||||
});
|
||||
const heartbeat = await getProjectHeartbeat(redis, projectName);
|
||||
|
||||
const status = await redis.get(projectStatusKey(projectName));
|
||||
const offlineThresholdMs = Number.parseInt(
|
||||
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
|
||||
10,
|
||||
);
|
||||
const now = Date.now();
|
||||
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
|
||||
const ageMs = lastActiveAt ? now - lastActiveAt : null;
|
||||
const isOnline =
|
||||
lastActiveAt && Number.isFinite(offlineThresholdMs)
|
||||
? ageMs <= offlineThresholdMs
|
||||
: Boolean(lastActiveAt);
|
||||
|
||||
const computedStatus = heartbeat ? (isOnline ? '在线' : '离线') : null;
|
||||
|
||||
return res.status(200).json({
|
||||
logs,
|
||||
projectStatus: status || null,
|
||||
projectStatus: computedStatus || null,
|
||||
heartbeat: heartbeat
|
||||
? {
|
||||
apiBaseUrl:
|
||||
typeof heartbeat.apiBaseUrl === 'string'
|
||||
? heartbeat.apiBaseUrl
|
||||
: null,
|
||||
lastActiveAt: lastActiveAt || null,
|
||||
isOnline,
|
||||
ageMs,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to read logs', err);
|
||||
@@ -76,4 +150,4 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
62
src/backend/routes/projects.integration.test.js
Normal file
62
src/backend/routes/projects.integration.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import request from 'supertest';
|
||||
|
||||
import { createApp } from '../app.js';
|
||||
import { createFakeRedis } from '../test/fakeRedis.js';
|
||||
|
||||
describe('projects API', () => {
|
||||
it('GET /api/projects returns projects from unified list', async () => {
|
||||
const now = Date.now();
|
||||
const redis = createFakeRedis({
|
||||
项目心跳: JSON.stringify([
|
||||
{ projectName: 'Demo', apiBaseUrl: 'http://localhost:8080', lastActiveAt: now },
|
||||
]),
|
||||
});
|
||||
|
||||
const app = createApp({ redis });
|
||||
const resp = await request(app).get('/api/projects');
|
||||
|
||||
expect(resp.status).toBe(200);
|
||||
expect(resp.body.success).toBe(true);
|
||||
expect(resp.body.count).toBe(1);
|
||||
expect(resp.body.projects[0]).toMatchObject({
|
||||
id: 'Demo',
|
||||
name: 'Demo',
|
||||
apiBaseUrl: 'http://localhost:8080',
|
||||
});
|
||||
expect([ 'online', 'offline', 'unknown' ]).toContain(
|
||||
resp.body.projects[0].status,
|
||||
);
|
||||
});
|
||||
|
||||
it('POST /api/projects/migrate migrates old *_项目心跳 keys into 项目心跳 list', async () => {
|
||||
const now = Date.now();
|
||||
const redis = createFakeRedis({
|
||||
'A_项目心跳': JSON.stringify({
|
||||
apiBaseUrl: 'http://a',
|
||||
lastActiveAt: now,
|
||||
}),
|
||||
});
|
||||
|
||||
const app = createApp({ redis });
|
||||
const resp = await request(app)
|
||||
.post('/api/projects/migrate')
|
||||
.send({ dryRun: false, deleteOldKeys: true });
|
||||
|
||||
expect(resp.status).toBe(200);
|
||||
expect(resp.body.success).toBe(true);
|
||||
expect(resp.body.migrated).toBe(1);
|
||||
|
||||
const listRaw = await redis.get('项目心跳');
|
||||
expect(typeof listRaw).toBe('string');
|
||||
const list = JSON.parse(listRaw);
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
expect(list[0]).toMatchObject({
|
||||
projectName: 'A',
|
||||
apiBaseUrl: 'http://a',
|
||||
});
|
||||
|
||||
const old = await redis.get('A_项目心跳');
|
||||
expect(old).toBeNull();
|
||||
});
|
||||
});
|
||||
114
src/backend/routes/projects.js
Normal file
114
src/backend/routes/projects.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
import { getRedisClient } from '../services/redisClient.js';
|
||||
import {
|
||||
migrateHeartbeatData,
|
||||
getProjectsList,
|
||||
} from '../services/migrateHeartbeatData.js';
|
||||
|
||||
function parseLastActiveAt(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const asNum = Number(value);
|
||||
if (Number.isFinite(asNum)) return asNum;
|
||||
const asDate = Date.parse(value);
|
||||
if (Number.isFinite(asDate)) return asDate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeProjectStatus(project) {
|
||||
const offlineThresholdMs = Number.parseInt(
|
||||
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
|
||||
10,
|
||||
);
|
||||
const now = Date.now();
|
||||
const lastActiveAt = parseLastActiveAt(project?.lastActiveAt);
|
||||
|
||||
if (!lastActiveAt) {
|
||||
return {
|
||||
status: 'unknown',
|
||||
isOnline: false,
|
||||
ageMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
const ageMs = now - lastActiveAt;
|
||||
const isOnline = Number.isFinite(offlineThresholdMs)
|
||||
? ageMs <= offlineThresholdMs
|
||||
: true;
|
||||
|
||||
return {
|
||||
status: isOnline ? 'online' : 'offline',
|
||||
isOnline,
|
||||
ageMs,
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const redis = req.app?.locals?.redis || (await getRedisClient());
|
||||
if (!redis?.isReady) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
message: 'Redis 未就绪',
|
||||
projects: [],
|
||||
});
|
||||
}
|
||||
|
||||
const projectsList = await getProjectsList(redis);
|
||||
const projects = projectsList.map((project) => {
|
||||
const statusInfo = computeProjectStatus(project);
|
||||
return {
|
||||
id: project.projectName,
|
||||
name: project.projectName,
|
||||
apiBaseUrl: project.apiBaseUrl,
|
||||
lastActiveAt: project.lastActiveAt,
|
||||
...statusInfo,
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
projects,
|
||||
count: projects.length,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to get projects list', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '获取项目列表失败',
|
||||
projects: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/migrate', async (req, res) => {
|
||||
const { deleteOldKeys = false, dryRun = false } = req.body;
|
||||
|
||||
try {
|
||||
const redis = req.app?.locals?.redis || (await getRedisClient());
|
||||
const result = await migrateHeartbeatData({
|
||||
deleteOldKeys: Boolean(deleteOldKeys),
|
||||
dryRun: Boolean(dryRun),
|
||||
redis,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: '数据迁移完成',
|
||||
...result,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to migrate heartbeat data', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '数据迁移失败',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,24 +1,9 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import logRoutes from './routes/logs.js';
|
||||
import commandRoutes from './routes/commands.js';
|
||||
import { getRedisClient } from './services/redisClient.js';
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// 路由
|
||||
app.use('/api/logs', logRoutes);
|
||||
app.use('/api/commands', commandRoutes);
|
||||
|
||||
// 健康检查
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
const app = createApp();
|
||||
|
||||
// 启动服务器
|
||||
const server = app.listen(PORT, async () => {
|
||||
@@ -43,4 +28,4 @@ process.on('SIGINT', async () => {
|
||||
} finally {
|
||||
server.close(() => process.exit(0));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
206
src/backend/services/migrateHeartbeatData.js
Normal file
206
src/backend/services/migrateHeartbeatData.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { getRedisClient } from './redisClient.js';
|
||||
import { projectsListKey } from './redisKeys.js';
|
||||
|
||||
export function parseLastActiveAt(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const asNum = Number(value);
|
||||
if (Number.isFinite(asNum)) return asNum;
|
||||
const asDate = Date.parse(value);
|
||||
if (Number.isFinite(asDate)) return asDate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeProjectEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
|
||||
const projectName =
|
||||
typeof entry.projectName === 'string' ? entry.projectName.trim() : '';
|
||||
if (!projectName) return null;
|
||||
|
||||
const apiBaseUrl =
|
||||
typeof entry.apiBaseUrl === 'string' && entry.apiBaseUrl.trim()
|
||||
? entry.apiBaseUrl.trim()
|
||||
: null;
|
||||
|
||||
const lastActiveAt = parseLastActiveAt(entry.lastActiveAt);
|
||||
|
||||
return {
|
||||
projectName,
|
||||
apiBaseUrl,
|
||||
lastActiveAt: lastActiveAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProjectsList(list) {
|
||||
if (!Array.isArray(list)) return [];
|
||||
|
||||
const byName = new Map();
|
||||
for (const item of list) {
|
||||
const normalized = normalizeProjectEntry(item);
|
||||
if (!normalized) continue;
|
||||
|
||||
const existing = byName.get(normalized.projectName);
|
||||
if (!existing) {
|
||||
byName.set(normalized.projectName, normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingTs = existing.lastActiveAt || 0;
|
||||
const nextTs = normalized.lastActiveAt || 0;
|
||||
byName.set(
|
||||
normalized.projectName,
|
||||
nextTs >= existingTs ? normalized : existing,
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(byName.values());
|
||||
}
|
||||
|
||||
async function getReadyRedis(redisOverride) {
|
||||
const redis = redisOverride || (await getRedisClient());
|
||||
if (!redis?.isReady) {
|
||||
throw new Error('Redis 未就绪');
|
||||
}
|
||||
return redis;
|
||||
}
|
||||
|
||||
export async function migrateHeartbeatData(options = {}) {
|
||||
const { deleteOldKeys = false, dryRun = false, redis: redisOverride } =
|
||||
options;
|
||||
|
||||
const redis = await getReadyRedis(redisOverride);
|
||||
|
||||
console.log('[migrate] 开始迁移心跳数据...');
|
||||
|
||||
try {
|
||||
const keys = await redis.keys('*_项目心跳');
|
||||
console.log(`[migrate] 找到 ${keys.length} 个心跳键`);
|
||||
|
||||
const projectsList = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const heartbeatRaw = await redis.get(key);
|
||||
if (!heartbeatRaw) {
|
||||
console.log(`[migrate] 跳过空键: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let heartbeat;
|
||||
try {
|
||||
heartbeat = JSON.parse(heartbeatRaw);
|
||||
} catch (err) {
|
||||
console.error(`[migrate] 解析失败: ${key}`, err.message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectName = key.replace('_项目心跳', '');
|
||||
const project = normalizeProjectEntry({
|
||||
projectName,
|
||||
apiBaseUrl: heartbeat?.apiBaseUrl,
|
||||
lastActiveAt: heartbeat?.lastActiveAt,
|
||||
});
|
||||
|
||||
if (!project?.apiBaseUrl) {
|
||||
console.log(`[migrate] 跳过无效项目 (无apiBaseUrl): ${projectName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
projectsList.push(project);
|
||||
console.log(`[migrate] 添加项目: ${projectName}`);
|
||||
}
|
||||
|
||||
const normalizedProjectsList = normalizeProjectsList(projectsList);
|
||||
|
||||
console.log(`[migrate] 共迁移 ${normalizedProjectsList.length} 个项目`);
|
||||
|
||||
if (dryRun) {
|
||||
console.log('[migrate] 干运行模式,不写入数据');
|
||||
return {
|
||||
success: true,
|
||||
migrated: normalizedProjectsList.length,
|
||||
projects: normalizedProjectsList,
|
||||
dryRun: true,
|
||||
};
|
||||
}
|
||||
|
||||
const listKey = projectsListKey();
|
||||
await redis.set(listKey, JSON.stringify(normalizedProjectsList));
|
||||
console.log(`[migrate] 已写入项目列表到: ${listKey}`);
|
||||
|
||||
if (deleteOldKeys) {
|
||||
console.log('[migrate] 删除旧键...');
|
||||
for (const key of keys) {
|
||||
await redis.del(key);
|
||||
console.log(`[migrate] 已删除: ${key}`);
|
||||
}
|
||||
} else {
|
||||
console.log('[migrate] 保留旧键 (deleteOldKeys=false)');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
migrated: normalizedProjectsList.length,
|
||||
projects: normalizedProjectsList,
|
||||
listKey,
|
||||
deleteOldKeys,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[migrate] 迁移失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProjectsList(redisOverride) {
|
||||
const redis = await getReadyRedis(redisOverride);
|
||||
|
||||
const listKey = projectsListKey();
|
||||
const raw = await redis.get(listKey);
|
||||
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const list = JSON.parse(raw);
|
||||
return normalizeProjectsList(list);
|
||||
} catch (err) {
|
||||
console.error('[getProjectsList] 解析项目列表失败:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProjectHeartbeat(
|
||||
projectName,
|
||||
heartbeatData,
|
||||
redisOverride,
|
||||
) {
|
||||
const redis = await getReadyRedis(redisOverride);
|
||||
|
||||
const projectsList = await getProjectsList(redis);
|
||||
const existingIndex = projectsList.findIndex(
|
||||
(p) => p.projectName === projectName,
|
||||
);
|
||||
|
||||
const project = normalizeProjectEntry({
|
||||
projectName,
|
||||
apiBaseUrl: heartbeatData?.apiBaseUrl,
|
||||
lastActiveAt: heartbeatData?.lastActiveAt,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error('无效的项目心跳数据');
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
projectsList[existingIndex] = project;
|
||||
} else {
|
||||
projectsList.push(project);
|
||||
}
|
||||
|
||||
const listKey = projectsListKey();
|
||||
await redis.set(listKey, JSON.stringify(projectsList));
|
||||
|
||||
return project;
|
||||
}
|
||||
52
src/backend/services/migrateHeartbeatData.test.js
Normal file
52
src/backend/services/migrateHeartbeatData.test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
parseLastActiveAt,
|
||||
normalizeProjectEntry,
|
||||
normalizeProjectsList,
|
||||
} from './migrateHeartbeatData.js';
|
||||
|
||||
describe('migrateHeartbeatData helpers', () => {
|
||||
it('parseLastActiveAt handles number and numeric string', () => {
|
||||
expect(parseLastActiveAt(123)).toBe(123);
|
||||
expect(parseLastActiveAt('456')).toBe(456);
|
||||
});
|
||||
|
||||
it('parseLastActiveAt handles ISO date string', () => {
|
||||
const ts = parseLastActiveAt('2026-01-13T00:00:00.000Z');
|
||||
expect(typeof ts).toBe('number');
|
||||
expect(Number.isFinite(ts)).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizeProjectEntry enforces required fields and trims', () => {
|
||||
expect(
|
||||
normalizeProjectEntry({
|
||||
projectName: ' A ',
|
||||
apiBaseUrl: ' http://localhost:8080 ',
|
||||
lastActiveAt: '1000',
|
||||
}),
|
||||
).toEqual({
|
||||
projectName: 'A',
|
||||
apiBaseUrl: 'http://localhost:8080',
|
||||
lastActiveAt: 1000,
|
||||
});
|
||||
|
||||
expect(normalizeProjectEntry({ projectName: ' ' })).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizeProjectsList de-duplicates by projectName and keeps latest heartbeat', () => {
|
||||
const list = normalizeProjectsList([
|
||||
{ projectName: 'A', apiBaseUrl: 'http://a', lastActiveAt: 1000 },
|
||||
{ projectName: 'A', apiBaseUrl: 'http://a2', lastActiveAt: 2000 },
|
||||
{ projectName: 'B', apiBaseUrl: 'http://b', lastActiveAt: null },
|
||||
]);
|
||||
|
||||
expect(list.length).toBe(2);
|
||||
const a = list.find((p) => p.projectName === 'A');
|
||||
expect(a).toEqual({
|
||||
projectName: 'A',
|
||||
apiBaseUrl: 'http://a2',
|
||||
lastActiveAt: 2000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ function parseIntOrDefault(value, defaultValue) {
|
||||
export async function getRedisClient() {
|
||||
if (client) return client;
|
||||
|
||||
const host = process.env.REDIS_HOST || 'localhost';
|
||||
const host = process.env.REDIS_HOST || '10.8.8.109';
|
||||
const port = parseIntOrDefault(process.env.REDIS_PORT, 6379);
|
||||
const password = process.env.REDIS_PASSWORD || undefined;
|
||||
const db = parseIntOrDefault(process.env.REDIS_DB, 0);
|
||||
@@ -26,7 +26,10 @@ export async function getRedisClient() {
|
||||
password,
|
||||
database: db,
|
||||
socket: {
|
||||
connectTimeout: parseIntOrDefault(process.env.REDIS_CONNECT_TIMEOUT_MS, 2000),
|
||||
connectTimeout: parseIntOrDefault(
|
||||
process.env.REDIS_CONNECT_TIMEOUT_MS,
|
||||
2000,
|
||||
),
|
||||
reconnectStrategy: (retries) => {
|
||||
// exponential-ish backoff with cap
|
||||
return Math.min(1000 * Math.max(1, retries), 30_000);
|
||||
@@ -38,8 +41,14 @@ export async function getRedisClient() {
|
||||
const now = Date.now();
|
||||
if (now - lastErrorLogAt >= ERROR_LOG_THROTTLE_MS) {
|
||||
lastErrorLogAt = now;
|
||||
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : 'UNKNOWN';
|
||||
const message = err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err);
|
||||
const code =
|
||||
err && typeof err === 'object' && 'code' in err
|
||||
? String(err.code)
|
||||
: 'UNKNOWN';
|
||||
const message =
|
||||
err && typeof err === 'object' && 'message' in err
|
||||
? String(err.message)
|
||||
: String(err);
|
||||
const summary = `${code}: ${message.split('\n')[0]}`;
|
||||
console.error('[redis] error', summary);
|
||||
}
|
||||
@@ -78,7 +87,9 @@ export async function ensureRedisReady(options = {}) {
|
||||
try {
|
||||
await Promise.race([
|
||||
connectPromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Redis connect timeout')), timeoutMs)),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Redis connect timeout')), timeoutMs),
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
export function projectStatusKey(projectName) {
|
||||
return `${projectName}_项目状态`;
|
||||
}
|
||||
|
||||
export function projectConsoleKey(projectName) {
|
||||
return `${projectName}_项目控制台`;
|
||||
}
|
||||
@@ -9,3 +5,11 @@ export function projectConsoleKey(projectName) {
|
||||
export function projectControlKey(projectName) {
|
||||
return `${projectName}_控制`;
|
||||
}
|
||||
|
||||
export function projectHeartbeatKey(projectName) {
|
||||
return `${projectName}_项目心跳`;
|
||||
}
|
||||
|
||||
export function projectsListKey() {
|
||||
return '项目心跳';
|
||||
}
|
||||
|
||||
53
src/backend/test/fakeRedis.js
Normal file
53
src/backend/test/fakeRedis.js
Normal file
@@ -0,0 +1,53 @@
|
||||
function escapeRegex(input) {
|
||||
return String(input).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function globToRegex(glob) {
|
||||
const parts = String(glob).split('*').map(escapeRegex);
|
||||
const regex = `^${parts.join('.*')}$`;
|
||||
return new RegExp(regex);
|
||||
}
|
||||
|
||||
export function createFakeRedis(initial = {}) {
|
||||
const kv = new Map(Object.entries(initial));
|
||||
|
||||
return {
|
||||
isReady: true,
|
||||
|
||||
async get(key) {
|
||||
return kv.has(key) ? kv.get(key) : null;
|
||||
},
|
||||
|
||||
async set(key, value) {
|
||||
kv.set(key, String(value));
|
||||
return 'OK';
|
||||
},
|
||||
|
||||
async del(key) {
|
||||
const existed = kv.delete(key);
|
||||
return existed ? 1 : 0;
|
||||
},
|
||||
|
||||
async keys(pattern) {
|
||||
const re = globToRegex(pattern);
|
||||
return Array.from(kv.keys()).filter((k) => re.test(k));
|
||||
},
|
||||
|
||||
// optional: used by logs route
|
||||
async lRange(key, start, stop) {
|
||||
const raw = kv.get(key);
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
|
||||
const normalizeIndex = (idx) => (idx < 0 ? list.length + idx : idx);
|
||||
const s = normalizeIndex(start);
|
||||
const e = normalizeIndex(stop);
|
||||
|
||||
return list.slice(Math.max(0, s), Math.min(list.length, e + 1));
|
||||
},
|
||||
|
||||
// helper for tests
|
||||
_dump() {
|
||||
return Object.fromEntries(kv.entries());
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user