Files
work-hours-tracker/server/core/store.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

196 lines
7.7 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.
// 存储层契约(Store Contract)
//
// 为什么要有这一层:现在跑 VPS(node:sqlite),以后要上 Cloudflare Workers(D1),
// 再往后做 APP 还可能换别的后端。把「业务逻辑」和「怎么存」切开,
// 换平台只需要重写一个适配器,core/api.js 一行都不用动。
//
// 适配器必须实现下列方法。所有时间统一 ISO 8601 UTC 字符串。
//
// // ---- 用户与会话 ----
// countUsers(): Promise<number>
// getUserByUsername(username): Promise<UserRow|null>
// getUserById(id): Promise<UserRow|null>
// createUser({ username, pwHash, pwSalt, pwIter, createdAt }): Promise<UserRow>
// createSession({ tokenHash, userId, createdAt, expiresAt }): Promise<void>
// getSessionWithUser(tokenHash): Promise<{ session, user }|null>
// deleteSession(tokenHash): Promise<void>
// purgeExpiredSessions(nowIso): Promise<number>
//
// // ---- 数据同步 ----
// getRev(userId): Promise<number>
// // 原子写入:内部自行 +1 rev、给所有行打上同一个 rev,返回新 rev
// applyPush(userId, { records, adjust, settings }): Promise<number>
// // 增量拉取:返回 rev > since 的全部变更(含 deleted 墓碑)
// changesSince(userId, since): Promise<{ rev, records, adjust, settings }>
// // 全量导出,用于备份与首次同步
// fullDump(userId): Promise<{ rev, records, adjust, settings }>
//
// // ---- App 级键值 ----(运行时配置,如 MCP 开关/令牌;不属于任何用户,不参与同步)
// getSetting(key): Promise<string|null>
// setSetting(key, value): Promise<void>
//
// UserRow: { id, username, pw_hash, pw_salt, pw_iter, rev, created_at }
export const HOUR_MIN = 6;
export const HOUR_MAX = 12;
const STATUS = new Set(['work', 'rest', 'leave']);
const SHIFT = new Set(['day', 'night']);
const ADJUST_STATUS = new Set(['draft', 'final']);
export const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
export const MONTH_RE = /^\d{4}-\d{2}$/;
/**
* 键的合法性不能只看形状。`2026-13` 能过 /^\d{4}-\d{2}$/,`2026-02-31` 也能过日期正则,
* 但它们都不是真实存在的月份/日期,放进库里会变成永远算不进任何月份的孤儿行。
*/
export function isValidMonthKey(k) {
if (!MONTH_RE.test(k)) return false;
const mo = Number(k.slice(5, 7));
return mo >= 1 && mo <= 12;
}
export function isValidDayKey(k) {
if (!DAY_RE.test(k)) return false;
const y = Number(k.slice(0, 4)), mo = Number(k.slice(5, 7)), d = Number(k.slice(8, 10));
if (mo < 1 || mo > 12 || d < 1 || d > 31) return false;
// 用 UTC 构造再回读,闰年与月末天数一次性交给 Date 判定
const dt = new Date(Date.UTC(y, mo - 1, d));
return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
}
/** 设置字段白名单:与前端 DEFAULTS 对齐,多余字段一律丢弃 */
export const SETTINGS_FIELDS = {
rate: 'number',
fullDays: 'number',
fullBonusPerHour: 'number',
foundBonus: 'number',
missedPenalty: 'number',
cutoff: 'number',
payDayA: 'number',
payDayB: 'number',
dayHours: 'number',
nightHours: 'number',
defaultShift: 'shift'
};
export class BadRequest extends Error {
constructor(msg) { super(msg); this.status = 400; }
}
function num(v, name, { min = -1e9, max = 1e9, int = false } = {}) {
const n = Number(v);
if (!Number.isFinite(n)) throw new BadRequest(`${name} 必须是数字`);
if (int && !Number.isInteger(n)) throw new BadRequest(`${name} 必须是整数`);
if (n < min || n > max) throw new BadRequest(`${name} 超出范围 [${min}, ${max}]`);
return n;
}
/**
* 规范化单日打卡。
* 服务端不做「业务合理性」判断(比如工时必须 6–12),只做类型与边界校验,
* 因为业务规则会变,而历史数据不该因为改规则就存不进来。
*/
export function normalizeRecord(day, raw) {
if (!isValidDayKey(day)) throw new BadRequest(`日期键不是有效日期: ${day}`);
if (raw === null || raw === undefined) return { day, deleted: 1 };
if (typeof raw !== 'object') throw new BadRequest(`${day} 的记录必须是对象`);
if (raw.deleted === true || raw.deleted === 1) return { day, deleted: 1 };
const status = String(raw.status || '');
if (!STATUS.has(status)) throw new BadRequest(`${day} 的 status 只能是 work/rest/leave`);
if (status !== 'work') {
// 休息/请假不携带工时与奖惩,统一清零,避免脏数据参与结算
return {
day, deleted: 0, status,
shift_type: null, hours: 0,
contraband_found: 0, contraband_missed: 0,
other_penalty: null, penalty_reason: ''
};
}
const shift = String(raw.shift_type || '');
if (!SHIFT.has(shift)) throw new BadRequest(`${day} 的 shift_type 只能是 day/night`);
const op = raw.other_penalty;
const penalty = (op === null || op === undefined || op === '')
? null
: num(op, `${day} 的 other_penalty`, { min: 0, max: 100000 });
return {
day, deleted: 0, status,
shift_type: shift,
hours: num(raw.hours, `${day} 的 hours`, { min: 0, max: 24 }),
contraband_found: num(raw.contraband_found ?? 0, `${day} 的 contraband_found`, { min: 0, max: 9999, int: true }),
contraband_missed: num(raw.contraband_missed ?? 0, `${day} 的 contraband_missed`, { min: 0, max: 9999, int: true }),
other_penalty: penalty,
penalty_reason: String(raw.penalty_reason || '').slice(0, 200)
};
}
/** 规范化月度奖惩终稿 */
export function normalizeAdjust(month, raw) {
if (!isValidMonthKey(month)) throw new BadRequest(`月份键不是有效月份: ${month}`);
if (raw === null || raw === undefined) return { month, deleted: 1 };
if (typeof raw !== 'object') throw new BadRequest(`${month} 的奖惩必须是对象`);
if (raw.deleted === true || raw.deleted === 1) return { month, deleted: 1 };
const status = String(raw.status || '');
if (!ADJUST_STATUS.has(status)) throw new BadRequest(`${month} 的 status 只能是 draft/final`);
const amt = raw.finalAmount ?? raw.final_amount;
// draft 允许金额为空(还没填完就存草稿);final 必须有确定金额,否则前端无法判定「已定稿」
let finalAmount = null;
if (amt !== null && amt !== undefined && amt !== '') {
finalAmount = num(amt, `${month} 的 finalAmount`, { min: -1000000, max: 1000000 });
} else if (status === 'final') {
throw new BadRequest(`${month} 定稿必须填写终稿金额`);
}
return {
month, deleted: 0, status, final_amount: finalAmount,
note: String(raw.note || '').slice(0, 200),
finalized_at: status === 'final' ? String(raw.finalizedAt || raw.finalized_at || new Date().toISOString()) : ''
};
}
/** 规范化设置:只保留白名单字段 */
export function normalizeSettings(raw) {
if (!raw || typeof raw !== 'object') throw new BadRequest('settings 必须是对象');
const out = {};
for (const [k, kind] of Object.entries(SETTINGS_FIELDS)) {
if (raw[k] === undefined) continue;
if (kind === 'number') out[k] = num(raw[k], `settings.${k}`, { min: 0, max: 100000 });
else if (kind === 'shift') {
const v = String(raw[k]);
if (!SHIFT.has(v)) throw new BadRequest('settings.defaultShift 只能是 day/night');
out[k] = v;
}
}
return out;
}
/** DB 行 → 前端使用的记录对象(去掉同步用的内部字段) */
export function recordRowToClient(r) {
return {
status: r.status,
shift_type: r.shift_type,
hours: r.hours,
contraband_found: r.contraband_found,
contraband_missed: r.contraband_missed,
other_penalty: r.other_penalty,
penalty_reason: r.penalty_reason || ''
};
}
export function adjustRowToClient(r) {
return {
status: r.status,
finalAmount: r.final_amount,
note: r.note || '',
finalizedAt: r.finalized_at || ''
};
}