Files
cursor2api/test/migrate-jsonl-to-sqlite.mjs
huangzhenting 1bc91cac24 feat: 新增 SQLite 持久化支持 + Vue UI 后端过滤与分页优化
- 新增 src/logger-db.ts:SQLite 封装层(WAL 模式,支持写入/分页/状态计数/按需 payload 查询)
- logger.ts:双写 SQLite+JSONL,启动时 db_enabled 模式跳过 JSONL 读取避免 OOM,新增游标分页和后端过滤函数
- config.ts/config-api.ts:新增 db_enabled/db_path 配置字段及 LOG_DB_ENABLED/LOG_DB_PATH 环境变量
- log-viewer.ts/index.ts:新增 /api/requests/more 支持 status/keyword/since 后端过滤
- Vue UI:搜索框 400ms 防抖,状态/时间筛选立即触发后端查询,statusCounts 不受状态筛选影响,SSE 实时推送时增量更新计数
- 新增迁移工具 test/migrate-jsonl-to-sqlite.mjs 和单元测试 test/unit-logger-db.mjs
- 完善 README.md、config.yaml.example、docker-compose.yml、vue-ui/README.md 文档
2026-03-22 21:10:26 +08:00

164 lines
5.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* test/migrate-jsonl-to-sqlite.mjs
*
* 将现有 JSONL 日志文件迁移到 SQLite 数据库。
* 运行方式node test/migrate-jsonl-to-sqlite.mjs [--db ./logs/cursor2api.db] [--dir ./logs] [--dry-run]
*
* 选项:
* --db <path> SQLite 文件路径(默认 ./logs/cursor2api.db
* --dir <path> JSONL 日志目录(默认 ./logs
* --dry-run 只统计不写入
* --clear 写入前清空数据库已有数据
*/
import Database from 'better-sqlite3';
import { readFileSync, readdirSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
// ==================== 参数解析 ====================
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(name);
return idx >= 0 ? args[idx + 1] : null;
}
const DB_PATH = getArg('--db') || './logs/cursor2api.db';
const LOG_DIR = getArg('--dir') || './logs';
const DRY_RUN = args.includes('--dry-run');
const CLEAR = args.includes('--clear');
console.log('=== JSONL → SQLite 迁移工具 ===');
console.log(`日志目录: ${LOG_DIR}`);
console.log(`SQLite: ${DB_PATH}`);
console.log(`模式: ${DRY_RUN ? 'dry-run只统计' : '写入'}`);
if (CLEAR && !DRY_RUN) console.log('清空模式: 是');
console.log();
// ==================== 检查日志目录 ====================
if (!existsSync(LOG_DIR)) {
console.error(`日志目录不存在: ${LOG_DIR}`);
process.exit(1);
}
const jsonlFiles = readdirSync(LOG_DIR)
.filter(f => f.startsWith('cursor2api-') && f.endsWith('.jsonl'))
.sort();
if (jsonlFiles.length === 0) {
console.log('未找到 JSONL 日志文件,退出。');
process.exit(0);
}
console.log(`找到 ${jsonlFiles.length} 个 JSONL 文件:`);
for (const f of jsonlFiles) {
const content = readFileSync(join(LOG_DIR, f), 'utf-8');
const lines = content.split('\n').filter(Boolean);
console.log(` ${f} (${lines.length} 行)`);
}
console.log();
if (DRY_RUN) {
let total = 0;
for (const f of jsonlFiles) {
const lines = readFileSync(join(LOG_DIR, f), 'utf-8').split('\n').filter(Boolean);
total += lines.length;
}
console.log(`[dry-run] 共 ${total} 条记录,无写入操作。`);
process.exit(0);
}
// ==================== 初始化 SQLite ====================
const dbDir = dirname(DB_PATH);
if (dbDir && !existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.exec(`
CREATE TABLE IF NOT EXISTS requests (
request_id TEXT PRIMARY KEY,
timestamp INTEGER NOT NULL,
summary_json TEXT NOT NULL,
payload_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON requests(timestamp);
`);
if (CLEAR) {
const { changes } = db.prepare('DELETE FROM requests').run();
console.log(`已清空数据库(删除 ${changes} 条)`);
}
const existingCount = db.prepare('SELECT COUNT(*) as cnt FROM requests').get().cnt;
console.log(`数据库现有记录: ${existingCount}`);
console.log();
// ==================== 迁移 ====================
const insert = db.prepare(
'INSERT OR IGNORE INTO requests (request_id, timestamp, summary_json, payload_json) VALUES (?, ?, ?, ?)'
);
const migrate = db.transaction((lines) => {
let inserted = 0, skipped = 0, malformed = 0;
for (const line of lines) {
try {
const record = JSON.parse(line);
const summary = record.summary;
if (!summary?.requestId) { malformed++; continue; }
const result = insert.run(
summary.requestId,
summary.startTime || record.timestamp || Date.now(),
JSON.stringify(summary),
record.payload ? JSON.stringify(record.payload) : null
);
if (result.changes > 0) inserted++;
else skipped++;
} catch {
malformed++;
}
}
return { inserted, skipped, malformed };
});
let totalInserted = 0, totalSkipped = 0, totalMalformed = 0;
for (const f of jsonlFiles) {
const content = readFileSync(join(LOG_DIR, f), 'utf-8');
const lines = content.split('\n').filter(Boolean);
process.stdout.write(`迁移 ${f} (${lines.length} 行)... `);
const { inserted, skipped, malformed } = migrate(lines);
console.log(`插入 ${inserted},跳过(重复) ${skipped},格式错误 ${malformed}`);
totalInserted += inserted;
totalSkipped += skipped;
totalMalformed += malformed;
}
// ==================== 结果统计 ====================
const finalCount = db.prepare('SELECT COUNT(*) as cnt FROM requests').get().cnt;
console.log();
console.log('=== 迁移完成 ===');
console.log(`插入新记录: ${totalInserted}`);
console.log(`跳过(重复): ${totalSkipped}`);
console.log(`格式错误: ${totalMalformed}`);
console.log(`数据库总计: ${finalCount}`);
// 验证:读取最新 5 条
console.log();
console.log('=== 验证:最新 5 条记录 ===');
const rows = db.prepare('SELECT request_id, timestamp, summary_json FROM requests ORDER BY timestamp DESC LIMIT 5').all();
for (const row of rows) {
const s = JSON.parse(row.summary_json);
const date = new Date(row.timestamp).toISOString();
console.log(` [${date}] ${row.request_id} | ${s.model || '?'} | ${s.status || '?'} | ${s.title ? s.title.slice(0, 40) : '(无标题)'}`);
}
db.close();
console.log();
console.log('完成。');