Files
work-hours-tracker/server/adapters/sqlite-node.js
T
OpenClaw Agent 26306eee52 feat: 设置页 MCP 管理卡片(方案B)— DB 真开关 + 即时生效 + env 仅首次种子
- app_settings 表(key/value):MCP 开关与令牌存 DB,不属于任何用户、不参与同步协议
- 配置读取/写入收敛到 core/mcp.js(readMcpConfig/saveMcpToken/setMcpEnabled)
- 状态语义:未配置→404(端点视为不存在);已配置关闭→503(可重新开启);令牌错→401
- env MCP_AUTH_TOKEN 仅首次种子(库里从没有过令牌时迁入 DB),不设也行;后台改动即时生效无需重启
- 管理 API(登录态):GET /api/mcp/status(掩码)、POST /api/mcp/token(生成/重置,明文仅一次)、GET /api/mcp/token(复制用)、POST /api/mcp/enabled
- 设置页新增「AI 接入 (MCP)」卡片:开关 toggle、掩码展示、一键生成并复制(writeClipboard 兜底)、重置令牌;未登录徽章「本设备请先登录」
- 入口接线:node-server.js 与 worker.js 均挂 /mcp(seedToken 透传);sw.js 放行裸 /mcp(v2→v3)
- 两个适配器补 getSetting/setSetting(node:sqlite 与 D1 同语义)
- 测试:server/test/mcp.test.js 39 项(未配置关闭/鉴权/JSON-RPC 全链路/开关热切换/令牌轮换);npm test 全绿(107+72+39)
- 线上 E2E:生产令牌 initialize/tools 7 个/2026-09 大盘(出勤11天/82h/挣得1599);公网 HTTPS 链路通;UI puppeteer 17/17(含关→503→开→200 热切换,全程无重启)
- 文档:README + server/README(环境变量表/接口表/数据模型/MCP 章节)
2026-09-13 10:24:33 +08:00

192 lines
7.5 KiB
JavaScript
Raw 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.
// 存储适配器:VPS 本机 —— node:sqlite(Node 22 内置,零 npm 依赖)
//
// 与 D1 适配器保持完全相同的行为,关键是 rev 的写法:
// 用 `(SELECT rev FROM users WHERE id=?)` 直接在 SQL 里取新版本号,
// 而不是先 SELECT 回 JS 再拼进 INSERT。这样两个平台都能在单个事务/批次里原子完成。
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
export class SqliteStore {
constructor(path) {
this.db = new DatabaseSync(path);
this.db.exec('PRAGMA journal_mode = WAL'); // 并发读不阻塞写
this.db.exec('PRAGMA foreign_keys = ON');
this.db.exec('PRAGMA busy_timeout = 5000');
this.db.exec(readFileSync(join(__dirname, '..', 'core', 'schema.sql'), 'utf8'));
}
close() { try { this.db.close(); } catch {} }
// ---------- 用户与会话 ----------
async countUsers() {
return this.db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
}
async getUserByUsername(username) {
return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) || null;
}
async getUserById(id) {
return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) || null;
}
async createUser({ username, pwHash, pwSalt, pwIter, createdAt }) {
this.db.prepare(
'INSERT INTO users (username, pw_hash, pw_salt, pw_iter, rev, created_at) VALUES (?,?,?,?,0,?)'
).run(username, pwHash, pwSalt, pwIter, createdAt);
return this.getUserByUsername(username);
}
async createSession({ tokenHash, userId, createdAt, expiresAt }) {
this.db.prepare(
'INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?,?,?,?)'
).run(tokenHash, userId, createdAt, expiresAt);
}
async getSessionWithUser(tokenHash) {
const s = this.db.prepare('SELECT * FROM sessions WHERE token_hash = ?').get(tokenHash);
if (!s) return null;
const u = this.db.prepare('SELECT * FROM users WHERE id = ?').get(s.user_id);
if (!u) return null;
return { session: s, user: u };
}
async deleteSession(tokenHash) {
this.db.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash);
}
async purgeExpiredSessions(nowIso) {
return this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(nowIso).changes;
}
// ---------- 同步 ----------
async getRev(userId) {
const r = this.db.prepare('SELECT rev FROM users WHERE id = ?').get(userId);
return r ? r.rev : 0;
}
async applyPush(userId, { records = [], adjust = [], settings = null }) {
const now = new Date().toISOString();
const NEW_REV = '(SELECT rev FROM users WHERE id = ?)';
this.db.exec('BEGIN IMMEDIATE');
try {
this.db.prepare('UPDATE users SET rev = rev + 1 WHERE id = ?').run(userId);
const upRec = this.db.prepare(`
INSERT INTO records (user_id, day, status, shift_type, hours, contraband_found,
contraband_missed, other_penalty, penalty_reason, deleted, rev, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,${NEW_REV},?)
ON CONFLICT(user_id, day) DO UPDATE SET
status=excluded.status, shift_type=excluded.shift_type, hours=excluded.hours,
contraband_found=excluded.contraband_found, contraband_missed=excluded.contraband_missed,
other_penalty=excluded.other_penalty, penalty_reason=excluded.penalty_reason,
deleted=excluded.deleted, rev=excluded.rev, updated_at=excluded.updated_at
`);
for (const r of records) {
upRec.run(
userId, r.day,
r.deleted ? null : r.status,
r.deleted ? null : (r.shift_type ?? null),
r.deleted ? 0 : (r.hours ?? 0),
r.deleted ? 0 : (r.contraband_found ?? 0),
r.deleted ? 0 : (r.contraband_missed ?? 0),
r.deleted ? null : (r.other_penalty ?? null),
r.deleted ? '' : (r.penalty_reason ?? ''),
r.deleted ? 1 : 0,
userId, now
);
}
const upAdj = this.db.prepare(`
INSERT INTO month_adjust (user_id, month, status, final_amount, note, finalized_at, deleted, rev, updated_at)
VALUES (?,?,?,?,?,?,?,${NEW_REV},?)
ON CONFLICT(user_id, month) DO UPDATE SET
status=excluded.status, final_amount=excluded.final_amount, note=excluded.note,
finalized_at=excluded.finalized_at, deleted=excluded.deleted,
rev=excluded.rev, updated_at=excluded.updated_at
`);
for (const a of adjust) {
upAdj.run(
userId, a.month,
a.deleted ? 'draft' : a.status,
a.deleted ? null : (a.final_amount ?? null),
a.deleted ? '' : (a.note ?? ''),
a.deleted ? '' : (a.finalized_at ?? ''),
a.deleted ? 1 : 0,
userId, now
);
}
if (settings) {
// 设置是整份合并:客户端可能只传改动的字段,不能把没传的清掉
const cur = this.db.prepare('SELECT json FROM settings WHERE user_id = ?').get(userId);
let merged = settings;
if (cur) {
try { merged = { ...JSON.parse(cur.json), ...settings }; } catch { merged = settings; }
}
this.db.prepare(`
INSERT INTO settings (user_id, json, rev, updated_at) VALUES (?,?,${NEW_REV},?)
ON CONFLICT(user_id) DO UPDATE SET json=excluded.json, rev=excluded.rev, updated_at=excluded.updated_at
`).run(userId, JSON.stringify(merged), userId, now);
}
const rev = this.db.prepare('SELECT rev FROM users WHERE id = ?').get(userId).rev;
this.db.exec('COMMIT');
return rev;
} catch (e) {
try { this.db.exec('ROLLBACK'); } catch {}
throw e;
}
}
async changesSince(userId, since) {
const recs = this.db.prepare('SELECT * FROM records WHERE user_id = ? AND rev > ?').all(userId, since);
const adjs = this.db.prepare('SELECT * FROM month_adjust WHERE user_id = ? AND rev > ?').all(userId, since);
const st = this.db.prepare('SELECT * FROM settings WHERE user_id = ? AND rev > ?').get(userId, since);
const records = {}, adjust = {};
for (const r of recs) records[r.day] = r;
for (const a of adjs) adjust[a.month] = a;
return {
rev: await this.getRev(userId),
records,
adjust,
settings: st ? JSON.parse(st.json) : null
};
}
async fullDump(userId) {
const recs = this.db.prepare('SELECT * FROM records WHERE user_id = ? AND deleted = 0').all(userId);
const adjs = this.db.prepare('SELECT * FROM month_adjust WHERE user_id = ? AND deleted = 0').all(userId);
const st = this.db.prepare('SELECT * FROM settings WHERE user_id = ?').get(userId);
const records = {}, adjust = {};
for (const r of recs) records[r.day] = r;
for (const a of adjs) adjust[a.month] = a;
return {
rev: await this.getRev(userId),
records,
adjust,
settings: st ? JSON.parse(st.json) : null
};
}
// ---------- App 级键值 ----------
// 查无返回 null(node:sqlite 的 .get() 查无是 undefined,统一掰成 null)。
async getSetting(key) {
const r = this.db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key);
return r ? r.value : null;
}
async setSetting(key, value) {
this.db.prepare(`
INSERT INTO app_settings (key, value, updated_at) VALUES (?,?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
`).run(key, value, new Date().toISOString());
}
}