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,184 @@
|
||||
# 工时记账 · 服务端与同步
|
||||
|
||||
数据从「只在浏览器 localStorage」升级为「服务器 SQLite/D1 + 本地离线缓存」。
|
||||
本地仍是唯一真相,断网、没登录都照常记账;同步只是额外把数据推到服务器。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
work-hours-tracker/
|
||||
├── index.html 前端(已内置同步面板)
|
||||
├── sync.js 客户端同步引擎(不依赖 Vue,可复用到 APP 壳)
|
||||
├── verify_payroll.js 核算算法用例(220 项)
|
||||
└── server/
|
||||
├── core/ ← 平台无关,VPS 与 CF Workers 共用
|
||||
│ ├── api.js HTTP 接口逻辑(收发标准 Request/Response)
|
||||
│ ├── auth.js PBKDF2 密码 + SHA-256 令牌(纯 Web Crypto)
|
||||
│ ├── store.js 存储契约 + 输入校验
|
||||
│ └── schema.sql 建表语句(SQLite / D1 通用)
|
||||
├── adapters/
|
||||
│ ├── sqlite-node.js VPS:node:sqlite(Node 22 内置,零 npm 依赖)
|
||||
│ └── d1.js Cloudflare D1
|
||||
├── node-server.js VPS 入口:静态页 + /api/* 同一端口
|
||||
├── worker.js CF Workers 入口
|
||||
├── wrangler.toml CF 部署配置
|
||||
└── test/api.test.js 服务端用例(72 项)
|
||||
```
|
||||
|
||||
换平台只需换适配器,`core/` 一行都不用动。
|
||||
|
||||
## VPS 部署(当前方式)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm start # 默认 9523,静态页与 API 同源
|
||||
```
|
||||
|
||||
环境变量:
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `PORT` | 9523 | 监听端口 |
|
||||
| `HOST` | 0.0.0.0 | 监听地址 |
|
||||
| `DB_PATH` | `server/data/work-hours.db` | SQLite 文件位置 |
|
||||
| `STATIC_DIR` | 项目根 | 静态目录 |
|
||||
| `ALLOW_REGISTER` | 未设 | 设 `1` 才开放注册 |
|
||||
|
||||
首次访问 `http://<地址>:9523` → 设置页 → 云端同步 → 创建首个账号。
|
||||
**建首个账号后注册自动关闭**,公网暴露也不会被陌生人开号。
|
||||
|
||||
跑测试:
|
||||
|
||||
```bash
|
||||
cd server && npm test # 72 项
|
||||
cd .. && node verify_payroll.js # 220 项
|
||||
```
|
||||
|
||||
### systemd
|
||||
|
||||
现有 `work-hours-tracker.service` 是 `python3 -m http.server 9522`,只发静态文件,
|
||||
且会把 `.bak`、`server/`、数据库一并发出去。切到新服务顺带解决这个问题:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Work Hours Tracker
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/.openclaw/workspace/work-hours-tracker/server
|
||||
ExecStart=/usr/bin/node --disable-warning=ExperimentalWarning node-server.js
|
||||
Environment=PORT=9523
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
## Cloudflare Workers 部署
|
||||
|
||||
```bash
|
||||
cd server
|
||||
wrangler d1 create work-hours # 把 database_id 填进 wrangler.toml
|
||||
npm run cf:migrate # 建表
|
||||
npm run cf:deploy
|
||||
```
|
||||
|
||||
静态资源走 Workers Assets(`wrangler.toml` 的 `[assets]`),API 走同一个 Worker,仍是同源。
|
||||
|
||||
已确认可行:`core/` 只用 `Request`/`Response`/`crypto.subtle`/`TextEncoder`,这些 Workers 全都有;
|
||||
PBKDF2 十万次迭代实测约 17ms,远低于 Workers 的 CPU 时间预算。
|
||||
|
||||
## 接口
|
||||
|
||||
除 `/api/health`、`/api/register`、`/api/login` 外都需要 `Authorization: Bearer <token>`。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|---|---|---|
|
||||
| GET | `/api/health` | 探活,返回用户数(前端据此决定显示「登录」还是「创建首个账号」) |
|
||||
| POST | `/api/register` | 注册(默认仅首个账号) |
|
||||
| POST | `/api/login` | 登录,返回 token 与当前 rev |
|
||||
| POST | `/api/logout` | 令牌立即失效 |
|
||||
| GET | `/api/me` | 当前用户与 rev |
|
||||
| GET | `/api/data` | 全量拉取 |
|
||||
| GET | `/api/changes?since=N` | 增量拉取(含删除墓碑) |
|
||||
| POST | `/api/push` | 推送变更,整批原子写入,返回新 rev |
|
||||
| DELETE | `/api/record/YYYY-MM-DD` | 删单日 |
|
||||
| DELETE | `/api/adjust/YYYY-MM` | 删单月奖惩 |
|
||||
| GET | `/api/export` | 导出 JSON,与前端「导出备份」同构,可互相导入 |
|
||||
|
||||
## 同步机制
|
||||
|
||||
**版本号 rev。** 每个用户一个单调递增计数器,任何写入都 +1,被改动的行打上这个 rev。
|
||||
客户端记住上次见过的 rev,拉取时只问「比它新的」,不做全量对比。
|
||||
|
||||
**墓碑删除。** 删除不物理删行,而是置 `deleted=1`。否则「这天被删了」这件事没法传播到其他设备——
|
||||
对方只会看到自己有、服务器没有,然后又给推回来。
|
||||
|
||||
**先推后拉。** 本地改动记在 dirty 集合里,同步时先 push 再 pull,所以本地改动天然赢,
|
||||
不会被远端旧值覆盖。dirty 里的键在应用远端数据时会被跳过。
|
||||
|
||||
**离线优先。** localStorage 始终是本地真相。没登录、断网、服务器挂了都能照常记账,
|
||||
改动攒在 dirty 里,等能连上再一次推上去。
|
||||
|
||||
**设置合并而非覆盖。** 客户端可能只传改动的字段,服务端与现有值合并,不会把没传的清掉。
|
||||
|
||||
**首次登录的冲突处理**(`index.html` 的 `doLogin`):
|
||||
先 `pullAll` 看服务端有没有数据——服务端为空且本地有数据 → 自动 `pushAll` 上传;
|
||||
否则拉下来合并。不静默丢任何一边。
|
||||
|
||||
## 数据模型
|
||||
|
||||
| 表 | 主键 | 说明 |
|
||||
|---|---|---|
|
||||
| `users` | id | 含 `rev`,该用户的全局版本号 |
|
||||
| `sessions` | token_hash | 只存 SHA-256,泄库换不出可用 token |
|
||||
| `records` | (user_id, day) | day = `2026-09-08` |
|
||||
| `month_adjust` | (user_id, month) | month = `2026-09`,`status` = draft/final |
|
||||
| `settings` | user_id | 整份 JSON,字段会迭代所以不拆列 |
|
||||
|
||||
## 安全
|
||||
|
||||
已做:
|
||||
|
||||
- 密码 PBKDF2-SHA256 十万次迭代 + 随机盐,不存明文
|
||||
- 令牌只存哈希,登出立即失效,30 天过期
|
||||
- 登录/注册限流(每 IP 每分钟 10 次)
|
||||
- 用户名不存在时也走一次真实密码校验开销,防止靠响应时间枚举用户名
|
||||
- 首个账号建立后自动关闭注册
|
||||
- 逐用户数据隔离(已有用例验证两账号互不可见)
|
||||
- 静态服务屏蔽 `server/`、`.trash/`、`png/`、`.bak`、`.db`、`.sql`、`verify_*.js`,防目录穿越
|
||||
- 服务端输入校验:非法状态/班次/工时/件数、`2026-13`、`2026-02-31` 这类假日期全部拒收
|
||||
|
||||
未做,公网暴露前需要补:
|
||||
|
||||
- **HTTPS**。当前 HTTP 明文,密码与令牌在链路上可见。上公网必须配反代 + 证书,或直接用 CF Workers(自带 HTTPS)。
|
||||
- 令牌存在 localStorage,XSS 可窃取。当前页面无用户生成内容渲染,风险低。
|
||||
- 无二次验证、无密码找回。忘记密码只能删库重建。
|
||||
|
||||
## 前端接入点
|
||||
|
||||
`sync.js` 暴露 `window.WHTSync`:
|
||||
|
||||
```js
|
||||
WHTSync.setApplyHook(fn) // 注册「如何把远端数据写进本地」
|
||||
WHTSync.markDirty(kind, key) // kind: 'record' | 'adjust' | 'settings'
|
||||
WHTSync.syncNow() // 手动同步
|
||||
WHTSync.pullAll() // 服务器覆盖本地
|
||||
WHTSync.pushAll() // 本地全量上传
|
||||
WHTSync.onChange(fn) // 订阅状态变化
|
||||
WHTSync.setApiBase(url) // 跨域部署时指定 API 地址,留空 = 同源
|
||||
```
|
||||
|
||||
`index.html` 里 `persist(dirtyDays)` / `persistAdjust(dirtyMonths)` 接收改动的键并自动标脏,
|
||||
1.5 秒防抖后上传。回到前台、网络恢复时各补一次同步。
|
||||
|
||||
`suppressSync` 标志用于远端数据写回本地时关闭标脏,避免「拉下来 → 又当成本地改动推回去」的回环。
|
||||
|
||||
## 做 APP 时
|
||||
|
||||
`sync.js` 只依赖 `localStorage` 与 `fetch`,不碰 Vue 也不碰浏览器独有 API。
|
||||
套 Capacitor / WebView / React Native 时这一层可整块复用,把 `setApiBase()` 指向服务器即可。
|
||||
`core/` 已经是平台无关的,换 Bun/Deno/其他 runtime 也只需要写一个入口文件。
|
||||
@@ -0,0 +1,146 @@
|
||||
// 存储适配器:Cloudflare D1
|
||||
//
|
||||
// 与 sqlite-node.js 行为一致。差异只有两点:
|
||||
// 1. D1 全异步,用 .bind().all()/.first()/.run()
|
||||
// 2. 原子写入用 env.DB.batch()(整批在一个事务里),而不是手写 BEGIN/COMMIT
|
||||
// rev 同样靠 `(SELECT rev FROM users WHERE id=?)` 在 SQL 内部取,保证批次内一致。
|
||||
|
||||
export class D1Store {
|
||||
constructor(db) { this.db = db; }
|
||||
|
||||
async countUsers() {
|
||||
const r = await this.db.prepare('SELECT COUNT(*) AS n FROM users').first();
|
||||
return r ? r.n : 0;
|
||||
}
|
||||
|
||||
async getUserByUsername(username) {
|
||||
return await this.db.prepare('SELECT * FROM users WHERE username = ?').bind(username).first() || null;
|
||||
}
|
||||
|
||||
async getUserById(id) {
|
||||
return await this.db.prepare('SELECT * FROM users WHERE id = ?').bind(id).first() || null;
|
||||
}
|
||||
|
||||
async createUser({ username, pwHash, pwSalt, pwIter, createdAt }) {
|
||||
await this.db.prepare(
|
||||
'INSERT INTO users (username, pw_hash, pw_salt, pw_iter, rev, created_at) VALUES (?,?,?,?,0,?)'
|
||||
).bind(username, pwHash, pwSalt, pwIter, createdAt).run();
|
||||
return this.getUserByUsername(username);
|
||||
}
|
||||
|
||||
async createSession({ tokenHash, userId, createdAt, expiresAt }) {
|
||||
await this.db.prepare(
|
||||
'INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?,?,?,?)'
|
||||
).bind(tokenHash, userId, createdAt, expiresAt).run();
|
||||
}
|
||||
|
||||
async getSessionWithUser(tokenHash) {
|
||||
const s = await this.db.prepare('SELECT * FROM sessions WHERE token_hash = ?').bind(tokenHash).first();
|
||||
if (!s) return null;
|
||||
const u = await this.db.prepare('SELECT * FROM users WHERE id = ?').bind(s.user_id).first();
|
||||
if (!u) return null;
|
||||
return { session: s, user: u };
|
||||
}
|
||||
|
||||
async deleteSession(tokenHash) {
|
||||
await this.db.prepare('DELETE FROM sessions WHERE token_hash = ?').bind(tokenHash).run();
|
||||
}
|
||||
|
||||
async purgeExpiredSessions(nowIso) {
|
||||
const r = await this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').bind(nowIso).run();
|
||||
return r.meta ? r.meta.changes : 0;
|
||||
}
|
||||
|
||||
async getRev(userId) {
|
||||
const r = await this.db.prepare('SELECT rev FROM users WHERE id = ?').bind(userId).first();
|
||||
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 = ?)';
|
||||
const stmts = [this.db.prepare('UPDATE users SET rev = rev + 1 WHERE id = ?').bind(userId)];
|
||||
|
||||
const recSql = 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) {
|
||||
stmts.push(recSql.bind(
|
||||
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 adjSql = 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) {
|
||||
stmts.push(adjSql.bind(
|
||||
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 = await this.db.prepare('SELECT json FROM settings WHERE user_id = ?').bind(userId).first();
|
||||
let merged = settings;
|
||||
if (cur) { try { merged = { ...JSON.parse(cur.json), ...settings }; } catch { merged = settings; } }
|
||||
stmts.push(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
|
||||
`).bind(userId, JSON.stringify(merged), userId, now));
|
||||
}
|
||||
|
||||
await this.db.batch(stmts);
|
||||
return this.getRev(userId);
|
||||
}
|
||||
|
||||
async changesSince(userId, since) {
|
||||
const [recs, adjs, st] = await Promise.all([
|
||||
this.db.prepare('SELECT * FROM records WHERE user_id = ? AND rev > ?').bind(userId, since).all(),
|
||||
this.db.prepare('SELECT * FROM month_adjust WHERE user_id = ? AND rev > ?').bind(userId, since).all(),
|
||||
this.db.prepare('SELECT * FROM settings WHERE user_id = ? AND rev > ?').bind(userId, since).first()
|
||||
]);
|
||||
const records = {}, adjust = {};
|
||||
for (const r of (recs.results || [])) records[r.day] = r;
|
||||
for (const a of (adjs.results || [])) adjust[a.month] = a;
|
||||
return { rev: await this.getRev(userId), records, adjust, settings: st ? JSON.parse(st.json) : null };
|
||||
}
|
||||
|
||||
async fullDump(userId) {
|
||||
const [recs, adjs, st] = await Promise.all([
|
||||
this.db.prepare('SELECT * FROM records WHERE user_id = ? AND deleted = 0').bind(userId).all(),
|
||||
this.db.prepare('SELECT * FROM month_adjust WHERE user_id = ? AND deleted = 0').bind(userId).all(),
|
||||
this.db.prepare('SELECT * FROM settings WHERE user_id = ?').bind(userId).first()
|
||||
]);
|
||||
const records = {}, adjust = {};
|
||||
for (const r of (recs.results || [])) records[r.day] = r;
|
||||
for (const a of (adjs.results || [])) adjust[a.month] = a;
|
||||
return { rev: await this.getRev(userId), records, adjust, settings: st ? JSON.parse(st.json) : null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// 存储适配器: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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 || ''
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
// VPS 本机入口:一个进程同时提供静态页面与 /api/*
|
||||
//
|
||||
// 为什么不用 express/hono:core/api.js 收发的是标准 Request/Response,
|
||||
// Node 22 原生就有,加依赖只会让 CF Workers 那边多一层不必要的适配。
|
||||
//
|
||||
// 环境变量:
|
||||
// PORT 监听端口(默认 9523,避开现有的 9522 静态服务)
|
||||
// HOST 监听地址(默认 0.0.0.0;已有登录鉴权,但仍建议配反代 + HTTPS)
|
||||
// DB_PATH SQLite 文件(默认 <server>/data/work-hours.db)
|
||||
// STATIC_DIR 静态目录(默认项目根,即 index.html 所在处)
|
||||
// ALLOW_REGISTER=1 开放注册(默认只允许「库里还没有用户」时注册首个账号)
|
||||
// ACCESS_LOG 访问日志文件(默认 <server>/data/access.log;设 off 关闭)
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { mkdirSync, createWriteStream, statSync, renameSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { gzip, brotliCompress, constants as zlibConstants } from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, resolve, normalize, extname } from 'node:path';
|
||||
import { SqliteStore } from './adapters/sqlite-node.js';
|
||||
import { handleApi } from './core/api.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PORT = Number(process.env.PORT || 9523);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const DB_PATH = process.env.DB_PATH || join(__dirname, 'data', 'work-hours.db');
|
||||
const STATIC_DIR = resolve(process.env.STATIC_DIR || join(__dirname, '..'));
|
||||
const ALLOW_REGISTER = process.env.ALLOW_REGISTER === '1';
|
||||
const ACCESS_LOG = process.env.ACCESS_LOG === 'off'
|
||||
? null
|
||||
: resolve(process.env.ACCESS_LOG || join(__dirname, 'data', 'access.log'));
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
const store = new SqliteStore(DB_PATH);
|
||||
|
||||
// ===== 访问日志 =====
|
||||
// 存在的理由很具体:出现过「页面到了、167KB 的 vue.global.prod.js 没到」这类单请求失败,
|
||||
// 服务端却完全查不到那次请求,只能靠猜。记下状态码与实发字节数,下次同类问题可直接对账:
|
||||
// 有记录且 bytes 完整 → 问题在客户端/链路;没记录 → 请求根本没到服务器。
|
||||
// 只记这一台自用服务需要的最小字段,不记 cookie / Authorization / 请求体。
|
||||
const LOG_MAX_BYTES = 8 * 1024 * 1024; // 超过就滚动一次,避免无人看管长成巨型文件
|
||||
let logStream = null;
|
||||
|
||||
function openLog() {
|
||||
if (!ACCESS_LOG) return;
|
||||
mkdirSync(dirname(ACCESS_LOG), { recursive: true });
|
||||
try {
|
||||
if (statSync(ACCESS_LOG).size > LOG_MAX_BYTES) renameSync(ACCESS_LOG, ACCESS_LOG + '.1');
|
||||
} catch { /* 文件不存在,正常 */ }
|
||||
logStream = createWriteStream(ACCESS_LOG, { flags: 'a' });
|
||||
// 日志写不动绝不能拖垮服务,出错就退化成「不记日志」继续跑。
|
||||
logStream.on('error', e => {
|
||||
console.error('[access-log] 写入失败,已停用日志:', e.message);
|
||||
logStream = null;
|
||||
});
|
||||
}
|
||||
openLog();
|
||||
|
||||
function clientIp(req) {
|
||||
const fwd = req.headers['x-forwarded-for'];
|
||||
if (typeof fwd === 'string' && fwd) return fwd.split(',')[0].trim();
|
||||
return req.socket.remoteAddress || '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* 记一条访问。
|
||||
* status 用 '-' 表示连响应头都没发出去(客户端中途断开时会这样)。
|
||||
* sent 是本次响应实际写出的字节数(含响应头,所以比 want 大几百字节是正常的),want 是 body 应发字节数。
|
||||
* sent 明显小于 want 就是传输被截断——这正是 167KB 的 Vue 只到一半那类问题要抓的证据。
|
||||
* @param {import('node:net').Socket|null} sock 开头就存下的 socket 引用:res.socket 在 close 时已经置空,现场取不到。
|
||||
* @param {number} baseline 该 socket 在本次请求前已写字节数;keep-alive 下一条连接跑多个请求,不减就会累加。
|
||||
*/
|
||||
function writeAccess(req, res, startedAt, sock, baseline) {
|
||||
if (!logStream) return;
|
||||
const ms = Date.now() - startedAt;
|
||||
const status = res.headersSent ? res.statusCode : '-';
|
||||
const sent = sock ? Math.max(0, sock.bytesWritten - baseline) : 0;
|
||||
// 不能用 res.getHeader('content-length'):经 writeHead(status, obj) 设的头不进可查询的头集合,
|
||||
// 读出来永远是 undefined(实测过,最初写成那样日志里全是 want=-)。改由写响应的地方直接标上。
|
||||
const want = res.wantBytes ?? '-';
|
||||
const ua = String(req.headers['user-agent'] || '-').replace(/["\n\r]/g, ' ').slice(0, 200);
|
||||
const line = [
|
||||
new Date().toISOString(),
|
||||
clientIp(req),
|
||||
req.method,
|
||||
(req.url || '-').slice(0, 300),
|
||||
status,
|
||||
`sent=${sent}`,
|
||||
`want=${want}`,
|
||||
ms + 'ms',
|
||||
res.writableFinished ? 'done' : 'aborted',
|
||||
'"' + ua + '"'
|
||||
].join(' ');
|
||||
logStream.write(line + '\n');
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.webmanifest': 'application/manifest+json'
|
||||
};
|
||||
|
||||
// 静态目录里混着源码、数据库、备份和调试产物,逐类挡掉。
|
||||
// vendor/ 是页面真实依赖(tailwind.css / vue.global.prod.js),不能挡。
|
||||
// shared/ 同样不能挡:payroll.js 是前端要 <script> 引的薪酬模块。
|
||||
// .build/ 是构建时工具链(带 node_modules),跑时用不到,必须挡。
|
||||
// test/ 与 tools/ 是测试网与基准生成器,只在开发机跑;basline JSON 里含全部场景明细,
|
||||
// 发到公网既没用也多一份暴露面(挂上测试层时实测这几个路径都是 200,逐个堵掉)。
|
||||
// package.json 会把脚本名与内部路径抖出去,一并挡。
|
||||
// 注意 .db 后面可能跟时间戳(回收站里的文件),所以不能只匹配结尾。
|
||||
const BLOCKED = /(^|\/)(server|\.trash|\.build|\.icons|design|png|test|tools|node_modules|\.git)(\/|$)|\.bak(\.|$)|\.db(\.|-|$)|\.sqlite|\.sql$|^\/verify_.*\.js$|^\/package(-lock)?\.json$/i;
|
||||
|
||||
// ===== 缓存策略 =====
|
||||
// 起因:静态文件原来一律发 no-cache,于是每次开页面都要在移动网络上重下
|
||||
// index.html(132KB) + vue.global.prod.js(164KB) + tailwind.css(23KB) ≈ 320KB。
|
||||
// 访问日志里手机端每次实传约 497ms,本机 0–1ms。那 497ms 就是失败窗口:链路一抖,
|
||||
// Vue 没到,页面直接掉进 boot-fail 卡片。少传一遍就是少一次失败机会。
|
||||
//
|
||||
// 分两档:
|
||||
// vendor/* 内容不变就永远别再问 → 一年期 immutable。
|
||||
// 失效靠 URL 上的内容指纹(?v=<hash>),发 index.html 时由服务端注入。
|
||||
// 不手写版本号:手写迟早会忘,忘了就是用户抱着旧 CSS 看错版式,比慢更难查。
|
||||
// 文件一变指纹就变,换来的新 URL 天然是一次 cache miss,升级什么都不用改。
|
||||
// 其余 仍是 no-cache,但带上 ETag。
|
||||
// no-cache 不等于不缓存,它只要求「用前先问一次」,正好配 ETag:
|
||||
// 内容没变就回 304 空响应,index.html 这 132KB 由此变成几百字节。
|
||||
const VENDOR_FILES = ['vendor/tailwind.css', 'vendor/vue.global.prod.js'];
|
||||
const VENDOR_SET = new Set(VENDOR_FILES.map(p => '/' + p));
|
||||
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
||||
|
||||
// ===== 压缩 =====
|
||||
// 这是本页面加载失败的真正大头,之前一直没做。
|
||||
// 访问日志里 vue.global.prod.js 一笔 167KB 走了 488ms(约 340KB/s),整页裸传 346KB
|
||||
// → 一秒多的传输窗口,链路在这期间抖一下 Vue 就到不了,页面掉进 boot-fail 卡片。
|
||||
// 实测这几个文件 gzip 后合计约 108KB,只剩三分之一,窗口跟着缩到三分之一。
|
||||
//
|
||||
// 日志里那些 "1ms" 不能当成"很快就到了":sent 统计的是写进内核 socket 缓冲区的字节,
|
||||
// 小文件一次写完就返回,close 立刻触发。只有超出缓冲区的大文件才会阻塞到客户端收完,
|
||||
// 488ms 才是真实的客户端下载耗时。所以别被小文件的 1ms 骗过去。
|
||||
const COMPRESSIBLE = /\.(html|js|mjs|css|json|svg|webmanifest)$/i;
|
||||
const MIN_COMPRESS = 1024; // 比这还小,压缩省的字节抵不上头部与 CPU 开销
|
||||
|
||||
const gzipAsync = promisify(gzip);
|
||||
const brotliAsync = promisify(brotliCompress);
|
||||
|
||||
const sha12 = buf => createHash('sha256').update(buf).digest('hex').slice(0, 12);
|
||||
const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/**
|
||||
* 只有「带 ?v= 的 vendor 请求」才发长缓存。
|
||||
* 裸 URL 一律 no-cache:页面里的引用都被打了指纹,不带 v 的地址页面根本不会用,
|
||||
* 给它一年期缓存是白担风险。
|
||||
*/
|
||||
function cacheFor(relPath, search) {
|
||||
return VENDOR_SET.has(relPath) && /[?&]v=/.test(search || '') ? IMMUTABLE : 'no-cache';
|
||||
}
|
||||
|
||||
// 绝对路径 → { key, buf, fp, variants }。按 mtime+size 失效,压缩结果跟着一起缓存,
|
||||
// 所以同一个文件只压一次。文件数是个位数,不需要 LRU。
|
||||
const assets = new Map();
|
||||
|
||||
/**
|
||||
* 读一个静态资源,算内容指纹;index.html 额外把 vendor 引用改写成带指纹的形式。
|
||||
*
|
||||
* 正则里可选的 (\?v=…) 是为了幂等:源文件已经带指纹时不会叠成 ?v=a?v=b。
|
||||
* index.html 里 boot-fail 的 VUE_SRC 常量写的是同一个路径,会被一起替换 ——
|
||||
* 这正是需要的:重试按钮必须去撞和 <script> 完全相同的缓存条目,否则它「重试成功」
|
||||
* 之后 reload 用的还是那份坏副本,用户会卡在按钮点不好的死循环里。
|
||||
*
|
||||
* 指纹必须按改写后的内容算,不能按源文件的 mtime/size 算:
|
||||
* 升级 Vue 时 index.html 源文件一个字节都没动,但它该发出的 ?v= 变了。
|
||||
* 若 ETag 沿用源文件特征,浏览器会拿到 304、继续用旧 ?v=,新版本永远到不了用户手上。
|
||||
* 所以缓存键把两个 vendor 指纹一起算进去。
|
||||
* 某个 vendor 读不到就跳过它(fp=null),页面退化成原来的无指纹引用,
|
||||
* 而不是整页发不出去 —— 少一层缓存优化可以接受,白屏不行。
|
||||
*/
|
||||
async function loadAsset(abs, relPath, st0) {
|
||||
const st = st0 || await stat(abs);
|
||||
const isIndex = relPath === '/index.html';
|
||||
|
||||
let fps = null;
|
||||
if (isIndex) {
|
||||
fps = [];
|
||||
for (const rel of VENDOR_FILES) {
|
||||
try { fps.push((await loadAsset(join(STATIC_DIR, rel), '/' + rel)).fp); }
|
||||
catch { fps.push(null); }
|
||||
}
|
||||
}
|
||||
|
||||
const key = `${st.mtimeMs}:${st.size}` + (fps ? ':' + fps.join(',') : '');
|
||||
const hit = assets.get(abs);
|
||||
if (hit && hit.key === key) return hit;
|
||||
|
||||
let buf = await readFile(abs);
|
||||
if (isIndex) {
|
||||
let html = buf.toString('utf8');
|
||||
VENDOR_FILES.forEach((rel, i) => {
|
||||
if (!fps[i]) return;
|
||||
html = html.replace(new RegExp(escapeRe(rel) + '(\\?v=[0-9a-f]+)?', 'g'), `${rel}?v=${fps[i]}`);
|
||||
});
|
||||
buf = Buffer.from(html, 'utf8');
|
||||
}
|
||||
|
||||
const entry = { key, buf, fp: sha12(buf), variants: new Map() };
|
||||
assets.set(abs, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某个编码下的响应体。压缩一次就存住,之后同一编码直接复用。
|
||||
*
|
||||
* ETag 带上编码后缀:同一份内容的 gzip 与未压缩版本必须是不同的 ETag,
|
||||
* 否则中间缓存有机会把压缩正文配上未压缩的 ETag 发给不支持压缩的客户端。
|
||||
*/
|
||||
async function variantOf(entry, enc) {
|
||||
if (!enc) return { buf: entry.buf, etag: `"${entry.fp}"` };
|
||||
const hit = entry.variants.get(enc);
|
||||
if (hit) return hit;
|
||||
const buf = enc === 'br'
|
||||
? await brotliAsync(entry.buf, {
|
||||
params: {
|
||||
[zlibConstants.BROTLI_PARAM_QUALITY]: 10,
|
||||
[zlibConstants.BROTLI_PARAM_SIZE_HINT]: entry.buf.length
|
||||
}
|
||||
})
|
||||
: await gzipAsync(entry.buf, { level: 9 });
|
||||
const v = { buf, etag: `"${entry.fp}-${enc}"` };
|
||||
entry.variants.set(enc, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 按 Accept-Encoding 选编码。只压文本类;显式 q=0 视为不支持。 */
|
||||
function pickEncoding(req, relPath) {
|
||||
if (!COMPRESSIBLE.test(relPath)) return null;
|
||||
const ae = String(req.headers['accept-encoding'] || '');
|
||||
const ok = name => new RegExp(`(^|,)\\s*${name}\\s*(;\\s*q=(?!0(\\.0*)?(,|$))[^,]*)?(,|$)`, 'i').test(ae);
|
||||
if (ok('br')) return 'br';
|
||||
if (ok('gzip')) return 'gzip';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 比对 If-None-Match。有中间设备会把强 ETag 降级成 W/"…",所以剥掉前缀再比。 */
|
||||
function etagMatches(header, etag) {
|
||||
if (!header) return false;
|
||||
return header.split(',').some(t => t.trim().replace(/^W\//, '') === etag);
|
||||
}
|
||||
|
||||
/** Node IncomingMessage → 标准 Request */
|
||||
async function toRequest(req) {
|
||||
const url = `http://${req.headers.host || 'localhost'}${req.url}`;
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
if (Array.isArray(v)) v.forEach(x => headers.append(k, x));
|
||||
else if (v !== undefined) headers.set(k, v);
|
||||
}
|
||||
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
||||
let body;
|
||||
if (hasBody) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const c of req) {
|
||||
size += c.length;
|
||||
if (size > 8 * 1024 * 1024) throw new Error('body too large');
|
||||
chunks.push(c);
|
||||
}
|
||||
body = Buffer.concat(chunks);
|
||||
}
|
||||
return new Request(url, { method: req.method, headers, body });
|
||||
}
|
||||
|
||||
/** 标准 Response → Node ServerResponse */
|
||||
async function sendResponse(res, response) {
|
||||
const headers = {};
|
||||
response.headers.forEach((v, k) => { headers[k] = v; });
|
||||
res.writeHead(response.status, headers);
|
||||
if (response.body) {
|
||||
const buf = Buffer.from(await response.arrayBuffer());
|
||||
res.wantBytes = buf.length;
|
||||
res.end(buf);
|
||||
} else res.end();
|
||||
}
|
||||
|
||||
async function serveStatic(req, res, pathname, search) {
|
||||
// 防目录穿越:normalize 后必须仍在 STATIC_DIR 内
|
||||
let rel = decodeURIComponent(pathname);
|
||||
if (rel === '/' || rel === '') rel = '/index.html';
|
||||
const target = resolve(join(STATIC_DIR, normalize(rel)));
|
||||
if (!target.startsWith(STATIC_DIR)) {
|
||||
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
return res.end('403 Forbidden');
|
||||
}
|
||||
// 不对外暴露:服务端源码与数据库、历史备份、回收站、验证脚本。
|
||||
// 这些都不是页面资源,泄出去只有坏处(旧 python http.server 就把它们全发了)。
|
||||
const relPath = target.slice(STATIC_DIR.length).replace(/\\/g, '/');
|
||||
if (BLOCKED.test(relPath)) {
|
||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
return res.end('404 Not Found');
|
||||
}
|
||||
try {
|
||||
const st = await stat(target);
|
||||
if (st.isDirectory()) return serveStatic(req, res, join(rel, 'index.html'), search);
|
||||
|
||||
const entry = await loadAsset(target, relPath, st);
|
||||
const enc = entry.buf.length >= MIN_COMPRESS ? pickEncoding(req, relPath) : null;
|
||||
const { buf, etag } = await variantOf(entry, enc);
|
||||
const cc = cacheFor(relPath, search);
|
||||
|
||||
// Vary 只对可压缩类型有意义;对图片等加上只会白白降低中间缓存命中率
|
||||
const common = { etag, 'cache-control': cc };
|
||||
if (COMPRESSIBLE.test(relPath)) common.vary = 'accept-encoding';
|
||||
|
||||
if (etagMatches(req.headers['if-none-match'], etag)) {
|
||||
res.wantBytes = 0; // 日志里 want=0 即代表命中 304,没传正文
|
||||
res.writeHead(304, common);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
res.wantBytes = buf.length;
|
||||
res.writeHead(200, {
|
||||
'content-type': MIME[extname(target).toLowerCase()] || 'application/octet-stream',
|
||||
'content-length': buf.length,
|
||||
...(enc ? { 'content-encoding': enc } : {}),
|
||||
...common
|
||||
});
|
||||
res.end(buf);
|
||||
} catch {
|
||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('404 Not Found');
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const startedAt = Date.now();
|
||||
// socket 必须现在存下:close 触发时 res.socket 已置空,到那时才读永远是 0。
|
||||
const sock = res.socket;
|
||||
const baseline = sock ? sock.bytesWritten : 0;
|
||||
// 挂 close 而不是 finish:finish 只在正常写完时触发,
|
||||
// 而「传到一半客户端断开」恰恰是要留证据的情况,close 两种都能覆盖。
|
||||
res.on('close', () => writeAccess(req, res, startedAt, sock, baseline));
|
||||
try {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
if (u.pathname.startsWith('/api/')) {
|
||||
const response = await handleApi(await toRequest(req), store, { allowRegister: ALLOW_REGISTER });
|
||||
return await sendResponse(res, response);
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
return res.end('405 Method Not Allowed');
|
||||
}
|
||||
await serveStatic(req, res, u.pathname, u.search);
|
||||
} catch (e) {
|
||||
console.error('[server]', e);
|
||||
if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify({ ok: false, error: '服务器内部错误' }));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, async () => {
|
||||
const n = await store.countUsers();
|
||||
// 预热:首个请求不该为「第一次压缩」买单。这几个文件是每次开页面都要的。
|
||||
// 失败不影响启动,真有请求时会再压一次。
|
||||
const warm = ['/index.html', '/sync.js', ...VENDOR_FILES.map(p => '/' + p)];
|
||||
let warmed = 0;
|
||||
for (const rel of warm) {
|
||||
try {
|
||||
const entry = await loadAsset(join(STATIC_DIR, rel), rel);
|
||||
await variantOf(entry, 'br');
|
||||
await variantOf(entry, 'gzip');
|
||||
warmed++;
|
||||
} catch { /* 缺文件就跳过 */ }
|
||||
}
|
||||
console.log(`工时记账服务已启动 http://${HOST}:${PORT}`);
|
||||
console.log(` 数据库 ${DB_PATH}`);
|
||||
console.log(` 静态目录 ${STATIC_DIR}`);
|
||||
console.log(` 压缩预热 ${warmed}/${warm.length} 个资源(br + gzip)`);
|
||||
console.log(` 注册策略 ${ALLOW_REGISTER ? '开放注册' : (n === 0 ? '等待创建首个账号' : '已关闭(库中已有账号)')}`);
|
||||
console.log(` 现有用户 ${n}`);
|
||||
console.log(` 访问日志 ${ACCESS_LOG || '已关闭'}`);
|
||||
});
|
||||
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(sig, () => {
|
||||
console.log(`\n收到 ${sig},正在关闭…`);
|
||||
server.close(() => { store.close(); logStream?.end(); process.exit(0); });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "work-hours-tracker-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "工时记账服务端:平台无关核心 + 可替换存储适配器(VPS node:sqlite / Cloudflare D1)",
|
||||
"main": "node-server.js",
|
||||
"scripts": {
|
||||
"start": "node --disable-warning=ExperimentalWarning node-server.js",
|
||||
"test": "node --disable-warning=ExperimentalWarning test/api.test.js",
|
||||
"schema:dump": "node scripts/dump-schema.js",
|
||||
"cf:dev": "wrangler dev",
|
||||
"cf:deploy": "wrangler deploy",
|
||||
"cf:migrate": "wrangler d1 execute work-hours --remote --file=./core/schema.sql"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// 服务端 API 自测:直接调 core/api.js,不起 HTTP 端口,跑得快也不占端口。
|
||||
// 存储用临时 SQLite 文件,跑完删掉。
|
||||
//
|
||||
// node --disable-warning=ExperimentalWarning test/api.test.js
|
||||
|
||||
import { handleApi } from '../core/api.js';
|
||||
import { SqliteStore } from '../adapters/sqlite-node.js';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'wht-test-'));
|
||||
const store = new SqliteStore(join(tmp, 'test.db'));
|
||||
|
||||
function section(t) { console.log(`\n── ${t}`); }
|
||||
function ok(name, cond, extra = '') {
|
||||
if (cond) { pass++; console.log(` ✅ ${name}`); }
|
||||
else { fail++; console.log(` ❌ ${name}${extra ? ' → ' + extra : ''}`); }
|
||||
}
|
||||
function eq(name, actual, expected) {
|
||||
const a = JSON.stringify(actual), e = JSON.stringify(expected);
|
||||
ok(name, a === e, `实得 ${a},应为 ${e}`);
|
||||
}
|
||||
|
||||
let TOKEN = null;
|
||||
async function call(method, path, body, token = TOKEN) {
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const req = new Request('http://t' + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
const res = await handleApi(req, store, {});
|
||||
let json = null;
|
||||
try { json = await res.json(); } catch {}
|
||||
return { status: res.status, body: json };
|
||||
}
|
||||
|
||||
const REC = (h, extra = {}) => ({
|
||||
status: 'work', shift_type: 'day', hours: h,
|
||||
contraband_found: 0, contraband_missed: 0, other_penalty: null, penalty_reason: '',
|
||||
...extra
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
section('健康检查与注册策略');
|
||||
{
|
||||
let r = await call('GET', '/api/health');
|
||||
eq('health 200', r.status, 200);
|
||||
eq('初始 0 用户', r.body.users, 0);
|
||||
|
||||
r = await call('POST', '/api/register', { username: 'ab', password: 'longenough1' });
|
||||
eq('用户名过短被拒', r.status, 400);
|
||||
|
||||
r = await call('POST', '/api/register', { username: 'kory', password: 'short' });
|
||||
eq('密码过短被拒', r.status, 400);
|
||||
|
||||
r = await call('POST', '/api/register', { username: 'kory', password: 'kory-pass-123' });
|
||||
eq('首个账号注册成功', r.status, 201);
|
||||
|
||||
r = await call('POST', '/api/register', { username: 'kory', password: 'kory-pass-123' });
|
||||
eq('重名/注册已关闭', r.status, 403); // 已有用户且未开放注册 → 403 先命中
|
||||
|
||||
r = await call('GET', '/api/health');
|
||||
eq('health 显示 1 用户', r.body.users, 1);
|
||||
}
|
||||
|
||||
section('登录与鉴权');
|
||||
{
|
||||
let r = await call('POST', '/api/login', { username: 'kory', password: 'wrong-pass-x' }, null);
|
||||
eq('错密码 401', r.status, 401);
|
||||
|
||||
r = await call('POST', '/api/login', { username: 'nobody', password: 'kory-pass-123' }, null);
|
||||
eq('不存在的用户也是 401(不泄漏用户名)', r.status, 401);
|
||||
|
||||
r = await call('POST', '/api/login', { username: 'kory', password: 'kory-pass-123' }, null);
|
||||
eq('登录成功', r.status, 200);
|
||||
ok('返回 token', typeof r.body.token === 'string' && r.body.token.length === 64);
|
||||
eq('初始 rev = 0', r.body.rev, 0);
|
||||
TOKEN = r.body.token;
|
||||
|
||||
r = await call('GET', '/api/data', undefined, null);
|
||||
eq('无 token 取数据 401', r.status, 401);
|
||||
|
||||
r = await call('GET', '/api/data', undefined, 'deadbeef');
|
||||
eq('伪造 token 401', r.status, 401);
|
||||
|
||||
r = await call('GET', '/api/me');
|
||||
eq('me 200', r.status, 200);
|
||||
eq('me 用户名', r.body.user.username, 'kory');
|
||||
}
|
||||
|
||||
section('推送与拉取');
|
||||
{
|
||||
let r = await call('POST', '/api/push', {
|
||||
records: { '2026-09-01': REC(8), '2026-09-02': REC(8), '2026-09-03': REC(6, { contraband_found: 1 }) },
|
||||
settings: { rate: 19.5, fullDays: 27 }
|
||||
});
|
||||
eq('push 200', r.status, 200);
|
||||
eq('rev 递增到 1', r.body.rev, 1);
|
||||
eq('applied = 4', r.body.applied, 4);
|
||||
|
||||
r = await call('GET', '/api/data');
|
||||
eq('拉到 3 天', Object.keys(r.body.records).length, 3);
|
||||
eq('9/3 工时 6h', r.body.records['2026-09-03'].hours, 6);
|
||||
eq('9/3 查获 1 件', r.body.records['2026-09-03'].contraband_found, 1);
|
||||
eq('设置 rate', r.body.settings.rate, 19.5);
|
||||
|
||||
// 增量:since=1 应该什么都没有
|
||||
r = await call('GET', '/api/changes?since=1');
|
||||
eq('since=1 无变更', Object.keys(r.body.records).length, 0);
|
||||
eq('since=1 设置无变更', r.body.settings, null);
|
||||
|
||||
// 再改一天
|
||||
r = await call('POST', '/api/push', { records: { '2026-09-04': REC(10, { contraband_missed: 2 }) } });
|
||||
eq('rev 递增到 2', r.body.rev, 2);
|
||||
|
||||
r = await call('GET', '/api/changes?since=1');
|
||||
eq('since=1 拿到 1 条新增', Object.keys(r.body.records).length, 1);
|
||||
eq('新增的是 9/4', Object.keys(r.body.records)[0], '2026-09-04');
|
||||
eq('changes 里 rev = 2', r.body.rev, 2);
|
||||
}
|
||||
|
||||
section('设置是合并而非覆盖');
|
||||
{
|
||||
let r = await call('POST', '/api/push', { settings: { rate: 21 } });
|
||||
eq('只推 rate', r.status, 200);
|
||||
r = await call('GET', '/api/data');
|
||||
eq('rate 已更新', r.body.settings.rate, 21);
|
||||
eq('fullDays 未被清掉', r.body.settings.fullDays, 27);
|
||||
}
|
||||
|
||||
section('删除用墓碑传播');
|
||||
{
|
||||
let r = await call('DELETE', '/api/record/2026-09-04');
|
||||
eq('删除 200', r.status, 200);
|
||||
const revAfterDelete = r.body.rev;
|
||||
|
||||
r = await call('GET', '/api/data');
|
||||
ok('fullDump 里已看不到 9/4', r.body.records['2026-09-04'] === undefined);
|
||||
eq('fullDump 剩 3 天', Object.keys(r.body.records).length, 3);
|
||||
|
||||
r = await call('GET', `/api/changes?since=${revAfterDelete - 1}`);
|
||||
eq('增量里能看到 9/4 墓碑', r.body.records['2026-09-04'].deleted, 1);
|
||||
|
||||
r = await call('DELETE', '/api/record/2026-9-4');
|
||||
eq('非法日期格式被拒', r.status, 400);
|
||||
}
|
||||
|
||||
section('月度奖惩:draft 不需要金额,final 必须有');
|
||||
{
|
||||
let r = await call('POST', '/api/push', { monthAdjust: { '2026-09': { status: 'draft', note: '还没算完' } } });
|
||||
eq('draft 无金额可存', r.status, 200);
|
||||
|
||||
r = await call('POST', '/api/push', { monthAdjust: { '2026-09': { status: 'final', note: '组长核定' } } });
|
||||
eq('final 缺金额被拒', r.status, 400);
|
||||
ok('错误信息点明原因', /终稿金额/.test(r.body.error), r.body.error);
|
||||
|
||||
r = await call('POST', '/api/push', {
|
||||
monthAdjust: { '2026-09': { status: 'final', finalAmount: -55, note: '扣漏查', finalizedAt: '2026-10-01T09:00:00.000Z' } }
|
||||
});
|
||||
eq('final 带金额成功', r.status, 200);
|
||||
|
||||
r = await call('GET', '/api/data');
|
||||
eq('终稿状态', r.body.adjust['2026-09'].status, 'final');
|
||||
eq('终稿金额支持负数', r.body.adjust['2026-09'].final_amount, -55);
|
||||
eq('定稿时间原样保留', r.body.adjust['2026-09'].finalized_at, '2026-10-01T09:00:00.000Z');
|
||||
|
||||
r = await call('POST', '/api/push', { monthAdjust: { '2026-13': { status: 'draft' } } });
|
||||
eq('非法月份被拒', r.status, 400);
|
||||
}
|
||||
|
||||
section('输入校验');
|
||||
{
|
||||
const bad = [
|
||||
['status 非法', { records: { '2026-09-05': { status: 'sleep' } } }],
|
||||
['shift 非法', { records: { '2026-09-05': { status: 'work', shift_type: 'noon', hours: 8 } } }],
|
||||
['hours 非数字', { records: { '2026-09-05': REC('abc') } }],
|
||||
['hours 超界', { records: { '2026-09-05': REC(99) } }],
|
||||
['查获件数为小数', { records: { '2026-09-05': REC(8, { contraband_found: 1.5 }) } }],
|
||||
['日期键格式错', { records: { '20260905': REC(8) } }],
|
||||
['defaultShift 非法', { settings: { defaultShift: 'noon' } }]
|
||||
];
|
||||
for (const [name, body] of bad) {
|
||||
const r = await call('POST', '/api/push', body);
|
||||
ok(name + ' → 400', r.status === 400, `实得 ${r.status} ${r.body && r.body.error}`);
|
||||
}
|
||||
|
||||
// 休息日必须把工时与奖惩清零,防脏数据参与结算
|
||||
let r = await call('POST', '/api/push', {
|
||||
records: { '2026-09-06': { status: 'rest', shift_type: 'day', hours: 8, contraband_found: 3 } }
|
||||
});
|
||||
eq('休息日可存', r.status, 200);
|
||||
r = await call('GET', '/api/data');
|
||||
eq('休息日工时归零', r.body.records['2026-09-06'].hours, 0);
|
||||
eq('休息日查获归零', r.body.records['2026-09-06'].contraband_found, 0);
|
||||
eq('休息日班次清空', r.body.records['2026-09-06'].shift_type, null);
|
||||
}
|
||||
|
||||
section('导出格式与前端 bundle 对齐');
|
||||
{
|
||||
const r = await call('GET', '/api/export');
|
||||
eq('export 200', r.status, 200);
|
||||
eq('app 标识', r.body.app, 'work-hours-tracker');
|
||||
eq('version 3', r.body.version, 3);
|
||||
ok('records 是前端结构(有 penalty_reason 无 rev)',
|
||||
r.body.records['2026-09-01'].penalty_reason !== undefined && r.body.records['2026-09-01'].rev === undefined);
|
||||
ok('monthAdjust 用 finalAmount 驼峰', r.body.monthAdjust['2026-09'].finalAmount === -55);
|
||||
}
|
||||
|
||||
section('多用户数据隔离');
|
||||
{
|
||||
// 开放注册开第二个号
|
||||
const req = new Request('http://t/api/register', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'other', password: 'other-pass-123' })
|
||||
});
|
||||
let res = await handleApi(req, store, { allowRegister: true });
|
||||
eq('第二个账号注册成功', res.status, 201);
|
||||
|
||||
let r = await call('POST', '/api/login', { username: 'other', password: 'other-pass-123' }, null);
|
||||
const otherToken = r.body.token;
|
||||
eq('第二个账号 rev = 0', r.body.rev, 0);
|
||||
|
||||
r = await call('GET', '/api/data', undefined, otherToken);
|
||||
eq('新账号看不到别人的记录', Object.keys(r.body.records).length, 0);
|
||||
|
||||
await call('POST', '/api/push', { records: { '2026-09-01': REC(12) } }, otherToken);
|
||||
r = await call('GET', '/api/data', undefined, otherToken);
|
||||
eq('新账号自己的 9/1 是 12h', r.body.records['2026-09-01'].hours, 12);
|
||||
r = await call('GET', '/api/data');
|
||||
eq('原账号 9/1 仍是 8h(未被串改)', r.body.records['2026-09-01'].hours, 8);
|
||||
}
|
||||
|
||||
section('登出后 token 立即失效');
|
||||
{
|
||||
let r = await call('POST', '/api/logout');
|
||||
eq('logout 200', r.status, 200);
|
||||
r = await call('GET', '/api/data');
|
||||
eq('登出后 401', r.status, 401);
|
||||
}
|
||||
|
||||
section('会话过期');
|
||||
{
|
||||
const { hashToken } = await import('../core/auth.js');
|
||||
const r0 = await call('POST', '/api/login', { username: 'kory', password: 'kory-pass-123' }, null);
|
||||
const tk = r0.body.token;
|
||||
// 手动把这条会话改成已过期
|
||||
store.db.prepare('UPDATE sessions SET expires_at = ? WHERE token_hash = ?')
|
||||
.run('2000-01-01T00:00:00.000Z', await hashToken(tk));
|
||||
const r = await call('GET', '/api/data', undefined, tk);
|
||||
eq('过期 token 401', r.status, 401);
|
||||
ok('提示需重新登录', /过期|失效/.test(r.body.error), r.body.error);
|
||||
}
|
||||
|
||||
section('404 与畸形请求体');
|
||||
{
|
||||
let r = await call('GET', '/api/nope');
|
||||
eq('未知接口 404', r.status, 404);
|
||||
|
||||
const req = new Request('http://t/api/login', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{not json'
|
||||
});
|
||||
const res = await handleApi(req, store, {});
|
||||
eq('畸形 JSON 400', res.status, 400);
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
store.close();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
console.log(`\n${'='.repeat(46)}`);
|
||||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||||
console.log('='.repeat(46));
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Work Hours Tracker (Node: static + sync API)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/.openclaw/workspace/work-hours-tracker/server
|
||||
ExecStart=/usr/bin/node --disable-warning=ExperimentalWarning node-server.js
|
||||
# 端口沿用 9522:localStorage 按「协议+域名+端口」隔离,
|
||||
# 换端口等于换 origin,浏览器里已有的打卡数据会全部看不见(而且旧端口没服务了,连导出都点不到)。
|
||||
# 保持 9522 → 现有数据原地可用,零迁移。
|
||||
Environment=PORT=9522
|
||||
Environment=HOST=0.0.0.0
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,38 @@
|
||||
// Cloudflare Workers 入口
|
||||
//
|
||||
// 与 node-server.js 是同一套 core/api.js,只是换了存储适配器(D1)与静态资源方式。
|
||||
// 部署前:
|
||||
// 1. wrangler d1 create work-hours # 拿到 database_id 填进 wrangler.toml
|
||||
// 2. npm run cf:migrate # 建表
|
||||
// 3. npm run cf:deploy
|
||||
//
|
||||
// 静态资源走 Workers Assets(wrangler.toml 里的 [assets]),
|
||||
// 所以这里只处理 /api/*,其余交回 env.ASSETS。
|
||||
|
||||
import { handleApi } from './core/api.js';
|
||||
import { D1Store } from './adapters/d1.js';
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
if (!env.DB) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: false, error: 'D1 未绑定:请在 wrangler.toml 配置 [[d1_databases]] binding = "DB"' }),
|
||||
{ status: 500, headers: { 'content-type': 'application/json; charset=utf-8' } }
|
||||
);
|
||||
}
|
||||
return handleApi(request, new D1Store(env.DB), {
|
||||
allowRegister: env.ALLOW_REGISTER === '1'
|
||||
});
|
||||
}
|
||||
|
||||
// 静态资源:绑定了 Assets 就交给它,否则给个明确提示而不是 404 让人猜
|
||||
if (env.ASSETS) return env.ASSETS.fetch(request);
|
||||
return new Response('静态资源未绑定(需在 wrangler.toml 配置 [assets])', {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
# Cloudflare Workers 部署配置
|
||||
#
|
||||
# 用前必做:
|
||||
# 1. wrangler d1 create work-hours
|
||||
# → 把输出里的 database_id 填到下面
|
||||
# 2. npm run cf:migrate # 等价 wrangler d1 execute work-hours --remote --file=./core/schema.sql
|
||||
# 3. npm run cf:deploy
|
||||
#
|
||||
# 注意:Workers 上的 core/api.js 完全不变,只有存储适配器从 node:sqlite 换成 D1。
|
||||
|
||||
name = "work-hours-tracker"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2025-01-01"
|
||||
|
||||
# 静态资源(index.html 等)。目录指向项目根,即 server/ 的上一层。
|
||||
[assets]
|
||||
directory = "../"
|
||||
binding = "ASSETS"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "work-hours"
|
||||
database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID"
|
||||
|
||||
[vars]
|
||||
# "1" 开放注册;留空/删除则只允许在「库里还没有任何用户」时注册首个账号
|
||||
ALLOW_REGISTER = ""
|
||||
Reference in New Issue
Block a user