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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user