mirror of
https://github.com/7836246/cursor2api.git
synced 2026-05-07 22:27:15 +08:00
- 新增 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 文档
265 lines
9.5 KiB
JavaScript
265 lines
9.5 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* test/unit-logger-db.mjs
|
||
*
|
||
* 单元测试:logger-db.ts 的 SQLite 接口功能验证
|
||
* 运行方式:node test/unit-logger-db.mjs
|
||
*
|
||
* 测试内容:
|
||
* 1. initDb - 初始化创建表和索引
|
||
* 2. dbInsertRequest - 写入记录
|
||
* 3. dbGetPayload - 按需读取 payload
|
||
* 4. dbGetSummaries - 游标分页查询
|
||
* 5. dbGetSummaryCount - 总数统计
|
||
* 6. dbGetSummariesSince - 按时间范围加载(启动恢复)
|
||
* 7. dbClear - 清空
|
||
* 8. 分页边界:before 游标正确性
|
||
* 9. INSERT OR REPLACE 幂等性
|
||
*/
|
||
|
||
import Database from 'better-sqlite3';
|
||
import { existsSync, unlinkSync, mkdirSync } from 'fs';
|
||
import { dirname } from 'path';
|
||
|
||
// ==================== 测试框架 ====================
|
||
|
||
let passed = 0, failed = 0;
|
||
const errors = [];
|
||
|
||
function assert(condition, msg) {
|
||
if (condition) {
|
||
passed++;
|
||
console.log(` ✓ ${msg}`);
|
||
} else {
|
||
failed++;
|
||
const err = ` ✗ ${msg}`;
|
||
errors.push(err);
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
function assertEq(actual, expected, msg) {
|
||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||
if (!ok) {
|
||
console.error(` actual: ${JSON.stringify(actual)}`);
|
||
console.error(` expected: ${JSON.stringify(expected)}`);
|
||
}
|
||
assert(ok, msg);
|
||
}
|
||
|
||
// ==================== 内联实现(与 src/logger-db.ts 保持同步)====================
|
||
// 使用相同逻辑直接操作 better-sqlite3,不依赖 dist/
|
||
|
||
const TEST_DB_PATH = '/tmp/cursor2api-test.db';
|
||
|
||
// 清理旧测试数据库
|
||
if (existsSync(TEST_DB_PATH)) unlinkSync(TEST_DB_PATH);
|
||
|
||
let db;
|
||
|
||
function initDb(dbPath) {
|
||
const dir = dirname(dbPath);
|
||
if (dir && dir !== '.' && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||
db = new Database(dbPath);
|
||
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);
|
||
`);
|
||
}
|
||
|
||
function dbInsertRequest(summary, payload) {
|
||
db.prepare(
|
||
'INSERT OR REPLACE INTO requests (request_id, timestamp, summary_json, payload_json) VALUES (?, ?, ?, ?)'
|
||
).run(summary.requestId, summary.startTime, JSON.stringify(summary), JSON.stringify(payload));
|
||
}
|
||
|
||
function dbGetPayload(requestId) {
|
||
const row = db.prepare('SELECT payload_json FROM requests WHERE request_id = ?').get(requestId);
|
||
if (!row?.payload_json) return undefined;
|
||
try { return JSON.parse(row.payload_json); } catch { return undefined; }
|
||
}
|
||
|
||
function dbGetSummaries({ limit, before }) {
|
||
let rows;
|
||
if (before !== undefined) {
|
||
rows = db.prepare('SELECT summary_json FROM requests WHERE timestamp < ? ORDER BY timestamp DESC LIMIT ?').all(before, limit);
|
||
} else {
|
||
rows = db.prepare('SELECT summary_json FROM requests ORDER BY timestamp DESC LIMIT ?').all(limit);
|
||
}
|
||
return rows.map(r => { try { return JSON.parse(r.summary_json); } catch { return null; } }).filter(Boolean);
|
||
}
|
||
|
||
function dbGetSummaryCount() {
|
||
return db.prepare('SELECT COUNT(*) as cnt FROM requests').get().cnt;
|
||
}
|
||
|
||
function dbGetSummariesSince(cutoff) {
|
||
const rows = db.prepare('SELECT summary_json FROM requests WHERE timestamp >= ? ORDER BY timestamp ASC').all(cutoff);
|
||
return rows.map(r => { try { return JSON.parse(r.summary_json); } catch { return null; } }).filter(Boolean);
|
||
}
|
||
|
||
function dbClear() {
|
||
db.prepare('DELETE FROM requests').run();
|
||
}
|
||
|
||
// ==================== 测试数据 ====================
|
||
|
||
function makeSummary(id, startTime, extra = {}) {
|
||
return {
|
||
requestId: id,
|
||
startTime,
|
||
endTime: startTime + 1000,
|
||
method: 'POST',
|
||
path: '/v1/messages',
|
||
model: 'claude-sonnet-4-6',
|
||
stream: true,
|
||
apiFormat: 'anthropic',
|
||
hasTools: false,
|
||
toolCount: 0,
|
||
messageCount: 3,
|
||
status: 'success',
|
||
responseChars: 500,
|
||
retryCount: 0,
|
||
continuationCount: 0,
|
||
toolCallsDetected: 0,
|
||
thinkingChars: 0,
|
||
systemPromptLength: 100,
|
||
phaseTimings: [],
|
||
title: `测试请求 ${id}`,
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
function makePayload(id) {
|
||
return {
|
||
question: `用户问题 ${id}`,
|
||
answer: `模型回答 ${id}`,
|
||
answerType: 'text',
|
||
};
|
||
}
|
||
|
||
// 时间基准(各记录间隔 1 秒)
|
||
const BASE_TS = Date.now() - 10000;
|
||
const records = [
|
||
{ summary: makeSummary('req-001', BASE_TS + 1000), payload: makePayload('req-001') },
|
||
{ summary: makeSummary('req-002', BASE_TS + 2000), payload: makePayload('req-002') },
|
||
{ summary: makeSummary('req-003', BASE_TS + 3000), payload: makePayload('req-003') },
|
||
{ summary: makeSummary('req-004', BASE_TS + 4000, { status: 'error', error: '超时' }), payload: makePayload('req-004') },
|
||
{ summary: makeSummary('req-005', BASE_TS + 5000), payload: makePayload('req-005') },
|
||
];
|
||
|
||
// ==================== 开始测试 ====================
|
||
|
||
console.log('=== unit-logger-db: SQLite 接口功能测试 ===\n');
|
||
|
||
// --- 1. initDb ---
|
||
console.log('【1】initDb');
|
||
try {
|
||
initDb(TEST_DB_PATH);
|
||
assert(existsSync(TEST_DB_PATH), '数据库文件已创建');
|
||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name);
|
||
assert(tables.includes('requests'), '表 requests 已创建');
|
||
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all().map(r => r.name);
|
||
assert(indexes.includes('idx_timestamp'), '索引 idx_timestamp 已创建');
|
||
} catch (e) {
|
||
assert(false, `initDb 抛出异常: ${e.message}`);
|
||
}
|
||
|
||
// --- 2. dbInsertRequest ---
|
||
console.log('\n【2】dbInsertRequest');
|
||
for (const { summary, payload } of records) {
|
||
dbInsertRequest(summary, payload);
|
||
}
|
||
assertEq(dbGetSummaryCount(), 5, '插入 5 条后总数为 5');
|
||
|
||
// --- 3. dbGetPayload ---
|
||
console.log('\n【3】dbGetPayload');
|
||
const p2 = dbGetPayload('req-002');
|
||
assert(p2 !== undefined, 'req-002 payload 可读取');
|
||
assertEq(p2.question, '用户问题 req-002', 'payload.question 正确');
|
||
assertEq(p2.answer, '模型回答 req-002', 'payload.answer 正确');
|
||
assert(dbGetPayload('req-999') === undefined, '不存在的 requestId 返回 undefined');
|
||
|
||
// --- 4. dbGetSummaries 无游标(最新在前)---
|
||
console.log('\n【4】dbGetSummaries(无游标)');
|
||
const all = dbGetSummaries({ limit: 10 });
|
||
assertEq(all.length, 5, '返回全部 5 条');
|
||
assertEq(all[0].requestId, 'req-005', '第一条是最新的 req-005');
|
||
assertEq(all[4].requestId, 'req-001', '最后一条是最旧的 req-001');
|
||
|
||
// --- 5. dbGetSummaries limit ---
|
||
console.log('\n【5】dbGetSummaries(limit=3)');
|
||
const top3 = dbGetSummaries({ limit: 3 });
|
||
assertEq(top3.length, 3, '返回 3 条');
|
||
assertEq(top3[0].requestId, 'req-005', '第一条是 req-005');
|
||
assertEq(top3[2].requestId, 'req-003', '第三条是 req-003');
|
||
|
||
// --- 6. dbGetSummaries before 游标翻页 ---
|
||
console.log('\n【6】dbGetSummaries(游标分页)');
|
||
// 第一页:最新 3 条(req-005, req-004, req-003)
|
||
const page1 = dbGetSummaries({ limit: 3 });
|
||
assertEq(page1.length, 3, '第一页 3 条');
|
||
assertEq(page1[0].requestId, 'req-005', '第一页第一条 req-005');
|
||
|
||
// 第二页:before = page1 最后一条的 timestamp
|
||
const beforeTs = page1[page1.length - 1].startTime;
|
||
const page2 = dbGetSummaries({ limit: 3, before: beforeTs });
|
||
assertEq(page2.length, 2, '第二页 2 条(剩余 req-002, req-001)');
|
||
assertEq(page2[0].requestId, 'req-002', '第二页第一条 req-002');
|
||
assertEq(page2[1].requestId, 'req-001', '第二页第二条 req-001');
|
||
|
||
// --- 7. dbGetSummaryCount ---
|
||
console.log('\n【7】dbGetSummaryCount');
|
||
assertEq(dbGetSummaryCount(), 5, '总数为 5');
|
||
|
||
// --- 8. dbGetSummariesSince(启动时加载)---
|
||
console.log('\n【8】dbGetSummariesSince');
|
||
// 只取 timestamp >= BASE_TS + 3000 的记录(req-003, req-004, req-005)
|
||
const since = dbGetSummariesSince(BASE_TS + 3000);
|
||
assertEq(since.length, 3, 'since 返回 3 条');
|
||
assertEq(since[0].requestId, 'req-003', '第一条 req-003(ASC 顺序)');
|
||
assertEq(since[2].requestId, 'req-005', '最后一条 req-005');
|
||
|
||
// cutoff 比所有记录都新 → 返回空
|
||
const sinceEmpty = dbGetSummariesSince(Date.now() + 99999);
|
||
assertEq(sinceEmpty.length, 0, '未来 cutoff 返回空数组');
|
||
|
||
// --- 9. INSERT OR REPLACE 幂等性 ---
|
||
console.log('\n【9】INSERT OR REPLACE 幂等性');
|
||
const updatedSummary = { ...records[0].summary, status: 'error', title: '已更新' };
|
||
dbInsertRequest(updatedSummary, records[0].payload);
|
||
assertEq(dbGetSummaryCount(), 5, '重复插入后总数不变(仍 5 条)');
|
||
const allAfter = dbGetSummaries({ limit: 10 });
|
||
const updated = allAfter.find(s => s.requestId === 'req-001');
|
||
assertEq(updated?.title, '已更新', 'REPLACE 更新了 summary 内容');
|
||
|
||
// --- 10. dbClear ---
|
||
console.log('\n【10】dbClear');
|
||
dbClear();
|
||
assertEq(dbGetSummaryCount(), 0, '清空后总数为 0');
|
||
const afterClear = dbGetSummaries({ limit: 10 });
|
||
assertEq(afterClear.length, 0, '清空后查询返回空数组');
|
||
assert(dbGetPayload('req-001') === undefined, '清空后 payload 也不可读取');
|
||
|
||
// ==================== 结果 ====================
|
||
|
||
console.log(`\n${'='.repeat(40)}`);
|
||
console.log(`测试结果: ${passed} 通过 / ${failed} 失败`);
|
||
if (errors.length > 0) {
|
||
console.error('\n失败项:');
|
||
for (const e of errors) console.error(e);
|
||
}
|
||
|
||
// 清理
|
||
db.close();
|
||
try { unlinkSync(TEST_DB_PATH); } catch { /* ignore */ }
|
||
|
||
process.exit(failed > 0 ? 1 : 0);
|