baseline: 薪酬逻辑收口到 shared/payroll.js 后的当前全量状态

这是第一个包含完整工作区的提交,也是后续「拆 SFC / 上 Vite」的回滚基线。

此前的提交均为 index.html 单文件历史快照,由 .bak 文件重建。

当前状态(提交前已实测):
- index.html 内联薪酬算法已删除,改调 WHTPayroll
- shared/payroll.js 为唯一薪酬实现,浏览器与 Node 共用
- 对外接口未变:aggregateMonth / isCutEdge / payDayKind 等保留同名薄包装

未入库内容见 .gitignore:依赖、Chrome 验证 profile、验证截图、
运行时 sqlite 数据与日志、时间戳备份、.trash。
This commit is contained in:
OpenClaw Agent
2026-09-05 14:35:00 +08:00
parent 7d48c4ac65
commit 2329955e32
94 changed files with 12770 additions and 237 deletions
+243
View File
@@ -0,0 +1,243 @@
// 平台无关的 API 核心。
// 输入标准 Request,输出标准 Response,不碰任何 Node 或 Workers 专属 API。
// VPS(node-server.js)与 Cloudflare Workers(worker.js)都只是把请求转进来。
import {
hashPassword, verifyPassword, newToken, hashToken, bearerFrom, SESSION_TTL_MS
} from './auth.js';
import {
BadRequest, normalizeRecord, normalizeAdjust, normalizeSettings,
recordRowToClient, adjustRowToClient, DAY_RE, MONTH_RE
} from './store.js';
export const API_VERSION = 1;
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' };
function json(data, status = 200, extra = {}) {
return new Response(JSON.stringify(data), { status, headers: { ...JSON_HEADERS, ...extra } });
}
function err(message, status = 400, code) {
return json({ ok: false, error: message, code: code || undefined }, status);
}
async function readJson(request, maxBytes = 4 * 1024 * 1024) {
const len = Number(request.headers.get('content-length') || 0);
if (len > maxBytes) throw new BadRequest('请求体过大');
let text;
try { text = await request.text(); } catch { throw new BadRequest('请求体读取失败'); }
if (text.length > maxBytes) throw new BadRequest('请求体过大');
if (!text) return {};
try { return JSON.parse(text); } catch { throw new BadRequest('请求体不是合法 JSON'); }
}
/** 简易滑窗限流:登录/注册专用,防在线爆破。内存态,Workers 冷启动会重置,够用。 */
class RateLimiter {
constructor(limit = 10, windowMs = 60000) {
this.limit = limit; this.windowMs = windowMs; this.hits = new Map();
}
check(key) {
const now = Date.now();
const arr = (this.hits.get(key) || []).filter(t => now - t < this.windowMs);
if (arr.length >= this.limit) return false;
arr.push(now);
this.hits.set(key, arr);
if (this.hits.size > 5000) this.hits.clear(); // 兜底,别把内存吃了
return true;
}
}
const loginLimiter = new RateLimiter(10, 60000);
function clientKey(request) {
return request.headers.get('cf-connecting-ip')
|| request.headers.get('x-forwarded-for')
|| request.headers.get('x-real-ip')
|| 'local';
}
async function requireAuth(store, request) {
const token = bearerFrom(request);
if (!token) throw Object.assign(new Error('未登录'), { status: 401 });
const hit = await store.getSessionWithUser(await hashToken(token));
if (!hit) throw Object.assign(new Error('登录已失效,请重新登录'), { status: 401 });
if (new Date(hit.session.expires_at).getTime() < Date.now()) {
await store.deleteSession(hit.session.token_hash);
throw Object.assign(new Error('登录已过期,请重新登录'), { status: 401 });
}
return hit.user;
}
function validUsername(u) {
return typeof u === 'string' && /^[A-Za-z0-9_.-]{3,32}$/.test(u);
}
/**
* 主入口。
* @param {Request} request
* @param {object} store 适配器实例(见 core/store.js 顶部契约)
* @param {object} opts { allowRegister } allowRegister=false 时只允许首个用户注册
*/
export async function handleApi(request, store, opts = {}) {
const url = new URL(request.url);
const path = url.pathname.replace(/\/+$/, '') || '/';
const method = request.method.toUpperCase();
if (method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'access-control-allow-origin': request.headers.get('origin') || '*',
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
'access-control-allow-headers': 'content-type,authorization',
'access-control-max-age': '86400'
}
});
}
try {
// ---------- 健康检查 ----------
if (path === '/api/health' && method === 'GET') {
return json({ ok: true, api: API_VERSION, users: await store.countUsers(), now: new Date().toISOString() });
}
// ---------- 注册 ----------
// 默认策略:只有库里还没有任何用户时才允许注册(首用户即管理员)。
// 这样公网暴露也不会被陌生人开号。要开放注册就显式传 allowRegister:true。
if (path === '/api/register' && method === 'POST') {
if (!loginLimiter.check('reg:' + clientKey(request))) return err('操作过于频繁,请稍后再试', 429);
const body = await readJson(request);
const username = String(body.username || '').trim();
const password = String(body.password || '');
if (!validUsername(username)) return err('用户名需 3–32 位,仅限字母数字与 _ . -');
if (password.length < 8) return err('密码至少 8 位');
const existing = await store.countUsers();
if (existing > 0 && !opts.allowRegister) return err('注册已关闭', 403, 'REGISTER_CLOSED');
if (await store.getUserByUsername(username)) return err('用户名已被占用', 409);
const { pwHash, pwSalt, pwIter } = await hashPassword(password);
const user = await store.createUser({ username, pwHash, pwSalt, pwIter, createdAt: new Date().toISOString() });
return json({ ok: true, user: { id: user.id, username: user.username } }, 201);
}
// ---------- 登录 ----------
if (path === '/api/login' && method === 'POST') {
if (!loginLimiter.check('login:' + clientKey(request))) return err('尝试过于频繁,请稍后再试', 429);
const body = await readJson(request);
const username = String(body.username || '').trim();
const password = String(body.password || '');
const user = await store.getUserByUsername(username);
// 用户不存在也要走一次真实的密码校验开销,否则能靠响应时间枚举用户名
const okPw = user
? await verifyPassword(password, user)
: await verifyPassword(password, { pw_salt: '00'.repeat(16), pw_iter: 100000, pw_hash: '' });
if (!user || !okPw) return err('用户名或密码错误', 401);
const token = newToken();
const now = new Date();
await store.createSession({
tokenHash: await hashToken(token),
userId: user.id,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + SESSION_TTL_MS).toISOString()
});
await store.purgeExpiredSessions(now.toISOString());
return json({ ok: true, token, user: { id: user.id, username: user.username }, rev: user.rev });
}
// ---------- 以下均需登录 ----------
if (path === '/api/logout' && method === 'POST') {
const token = bearerFrom(request);
if (token) await store.deleteSession(await hashToken(token));
return json({ ok: true });
}
if (path === '/api/me' && method === 'GET') {
const user = await requireAuth(store, request);
return json({ ok: true, user: { id: user.id, username: user.username }, rev: await store.getRev(user.id) });
}
// 全量拉取:首次登录、或本地想直接覆盖时用
if (path === '/api/data' && method === 'GET') {
const user = await requireAuth(store, request);
const dump = await store.fullDump(user.id);
return json({ ok: true, ...dump });
}
// 增量拉取:?since=<rev>,只回比它新的变更(含删除墓碑)
if (path === '/api/changes' && method === 'GET') {
const user = await requireAuth(store, request);
const since = Number(url.searchParams.get('since') || 0);
if (!Number.isFinite(since) || since < 0) return err('since 必须是非负整数');
const out = await store.changesSince(user.id, since);
return json({ ok: true, since, ...out });
}
// 推送本地变更。整批原子写入,返回新 rev。
if (path === '/api/push' && method === 'POST') {
const user = await requireAuth(store, request);
const body = await readJson(request);
const records = [];
if (body.records && typeof body.records === 'object') {
for (const [day, raw] of Object.entries(body.records)) records.push(normalizeRecord(day, raw));
}
const adjust = [];
if (body.monthAdjust && typeof body.monthAdjust === 'object') {
for (const [month, raw] of Object.entries(body.monthAdjust)) adjust.push(normalizeAdjust(month, raw));
}
const settings = body.settings !== undefined && body.settings !== null
? normalizeSettings(body.settings)
: null;
if (!records.length && !adjust.length && !settings) {
return json({ ok: true, rev: await store.getRev(user.id), applied: 0 });
}
const rev = await store.applyPush(user.id, { records, adjust, settings });
return json({ ok: true, rev, applied: records.length + adjust.length + (settings ? 1 : 0) });
}
// 删除单日 / 单月:等价于 push 一个墓碑,给客户端一个更直白的入口
if (method === 'DELETE' && path.startsWith('/api/record/')) {
const user = await requireAuth(store, request);
const day = path.slice('/api/record/'.length);
if (!DAY_RE.test(day)) return err('日期格式错误');
const rev = await store.applyPush(user.id, { records: [{ day, deleted: 1 }], adjust: [], settings: null });
return json({ ok: true, rev });
}
if (method === 'DELETE' && path.startsWith('/api/adjust/')) {
const user = await requireAuth(store, request);
const month = path.slice('/api/adjust/'.length);
if (!MONTH_RE.test(month)) return err('月份格式错误');
const rev = await store.applyPush(user.id, { records: [], adjust: [{ month, deleted: 1 }], settings: null });
return json({ ok: true, rev });
}
// 服务端导出:与前端「导出备份」同一份 bundle 结构,可直接互相导入
if (path === '/api/export' && method === 'GET') {
const user = await requireAuth(store, request);
const dump = await store.fullDump(user.id);
const recs = {};
for (const [k, v] of Object.entries(dump.records)) recs[k] = recordRowToClient(v);
const adj = {};
for (const [k, v] of Object.entries(dump.adjust)) adj[k] = adjustRowToClient(v);
return json({
app: 'work-hours-tracker',
version: 3,
exportedAt: new Date().toISOString(),
rev: dump.rev,
settings: dump.settings || {},
records: recs,
monthAdjust: adj
}, 200, { 'content-disposition': 'attachment; filename="work-hours-backup.json"' });
}
return err('接口不存在: ' + path, 404);
} catch (e) {
const status = e.status || (e instanceof BadRequest ? 400 : 500);
if (status >= 500) console.error('[api]', e);
return err(e.message || '服务器内部错误', status);
}
}
+65
View File
@@ -0,0 +1,65 @@
// 认证:只用 Web Crypto(globalThis.crypto.subtle)
// 这样同一份代码在 Node 22 与 Cloudflare Workers 上都能跑,不引入任何 npm 依赖。
const enc = new TextEncoder();
export const DEFAULT_PW_ITER = 100000;
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 天
function toHex(buf) {
const b = new Uint8Array(buf);
let s = '';
for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, '0');
return s;
}
function fromHex(hex) {
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
return out;
}
/** PBKDF2-SHA256 派生 32 字节,返回 hex */
export async function derive(password, saltHex, iterations) {
const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: fromHex(saltHex), iterations, hash: 'SHA-256' },
key,
256
);
return toHex(bits);
}
export async function hashPassword(password, iterations = DEFAULT_PW_ITER) {
const salt = toHex(crypto.getRandomValues(new Uint8Array(16)));
const hash = await derive(password, salt, iterations);
return { pwHash: hash, pwSalt: salt, pwIter: iterations };
}
/** 定长比较,避免按字符提前返回造成的时序泄漏 */
export function safeEqualHex(a, b) {
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export async function verifyPassword(password, user) {
const hash = await derive(password, user.pw_salt, user.pw_iter);
return safeEqualHex(hash, user.pw_hash);
}
/** 会话令牌:明文只回给客户端一次,库里只存 SHA-256,泄库也换不出可用 token */
export function newToken() {
return toHex(crypto.getRandomValues(new Uint8Array(32)));
}
export async function hashToken(token) {
return toHex(await crypto.subtle.digest('SHA-256', enc.encode(token)));
}
export function bearerFrom(request) {
const h = request.headers.get('authorization') || '';
const m = /^Bearer\s+([A-Za-z0-9._-]+)$/.exec(h.trim());
return m ? m[1] : null;
}
+67
View File
@@ -0,0 +1,67 @@
-- 工时记账服务端 schema
-- 同时兼容 VPS 上的 node:sqlite 与 Cloudflare D1(都是 SQLite 方言)
-- 设计要点:
-- 1. users.rev 是该用户的单调递增版本号,任何写入都 +1,客户端靠它做增量拉取
-- 2. records / month_adjust 用 deleted 墓碑标记而非物理删除,否则增量同步无法传播「删除」
-- 3. 所有时间戳统一存 ISO 8601 字符串(UTC),避免 D1 与 SQLite 的时间函数差异
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
pw_hash TEXT NOT NULL,
pw_salt TEXT NOT NULL,
pw_iter INTEGER NOT NULL,
rev INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at);
-- 每日打卡:主键 (user_id, day),day 形如 2026-09-08
CREATE TABLE IF NOT EXISTS records (
user_id INTEGER NOT NULL,
day TEXT NOT NULL,
status TEXT,
shift_type TEXT,
hours REAL,
contraband_found INTEGER NOT NULL DEFAULT 0,
contraband_missed INTEGER NOT NULL DEFAULT 0,
other_penalty REAL,
penalty_reason TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
rev INTEGER NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, day)
);
CREATE INDEX IF NOT EXISTS idx_records_rev ON records(user_id, rev);
-- 月度奖惩终稿:主键 (user_id, month),month 形如 2026-09
-- status = draft(草稿,不计入金额) | final(组长审核定稿,计入金额)
CREATE TABLE IF NOT EXISTS month_adjust (
user_id INTEGER NOT NULL,
month TEXT NOT NULL,
status TEXT NOT NULL,
final_amount REAL,
note TEXT,
finalized_at TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
rev INTEGER NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, month)
);
CREATE INDEX IF NOT EXISTS idx_adjust_rev ON month_adjust(user_id, rev);
-- 设置整体存一份 JSON:字段会随功能迭代增减,拆列反而不好演进
CREATE TABLE IF NOT EXISTS settings (
user_id INTEGER PRIMARY KEY,
json TEXT NOT NULL,
rev INTEGER NOT NULL,
updated_at TEXT NOT NULL
);
+191
View File
@@ -0,0 +1,191 @@
// 存储层契约(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 }>
//
// 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 || ''
};
}