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);
}
}