feat: 足迹 Footprints 个人留痕系统

桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA

同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署

多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
root
2026-10-16 13:53:15 +08:00
commit 42ca57abfd
107 changed files with 234676 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
// 附件:SHA-256 内容寻址去重存储
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { getDb, BLOB_DIR } from './db.mjs';
export function upload(user_id, { sha256, size, mime, file_name, data_b64 }) {
const d = getDb();
if (!/^[a-f0-9]{64}$/.test(sha256 || '')) throw new Error('sha256 格式不合法');
const buf = Buffer.from(data_b64 || '', 'base64');
const actual = crypto.createHash('sha256').update(buf).digest('hex');
if (actual !== sha256) throw new Error('内容哈希与声明不符');
if (size != null && buf.length !== Number(size)) throw new Error('大小与声明不符');
if (buf.length > 100 * 1024 * 1024) throw new Error('单文件不得超过 100MB');
const dir = path.join(BLOB_DIR, user_id);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, sha256);
if (!fs.existsSync(file)) fs.writeFileSync(file, buf);
const existing = d.prepare('SELECT 1 FROM attachments WHERE user_id=? AND sha256=?').get(user_id, sha256);
if (!existing) {
d.prepare('INSERT INTO attachments (user_id, sha256, size, mime, file_name) VALUES (?,?,?,?,?)')
.run(user_id, sha256, buf.length, mime ?? null, (file_name || '').slice(0, 255));
return { dedup: false, size: buf.length };
}
return { dedup: true, size: buf.length };
}
export function download(user_id, sha256) {
const d = getDb();
const row = d.prepare('SELECT * FROM attachments WHERE user_id=? AND sha256=?').get(user_id, sha256);
if (!row) return null;
const file = path.join(BLOB_DIR, user_id, sha256);
if (!fs.existsSync(file)) return null;
return { buf: fs.readFileSync(file), mime: row.mime, file_name: row.file_name, size: row.size };
}
+72
View File
@@ -0,0 +1,72 @@
// 认证:scrypt 口令散列 + Bearer token + 设备登记
import crypto from 'node:crypto';
import { getDb } from './db.mjs';
export function register({ username, password, device_name, invite_code }) {
const d = getDb();
// 多用户管控:邀请码(设置后必填)+ 用户数上限(0=不限)
const invite = process.env.INVITE_CODE;
if (invite && invite_code !== invite) throw new Error('邀请码无效');
const maxUsers = Number(process.env.MAX_USERS || 0);
if (maxUsers > 0) {
const cnt = d.prepare('SELECT COUNT(*) AS c FROM users').get().c;
if (cnt >= maxUsers) throw new Error('注册名额已满,请联系管理员');
}
if (!/^[a-zA-Z0-9_-]{3,32}$/.test(username || '')) throw new Error('用户名需为 3-32 位字母数字-_');
if (!password || password.length < 8) throw new Error('密码至少 8 位');
const exists = d.prepare('SELECT 1 FROM users WHERE username=?').get(username);
if (exists) throw new Error('用户名已存在');
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
const user_id = crypto.randomUUID();
d.prepare('INSERT INTO users (id, username, password_hash, salt) VALUES (?,?,?,?)')
.run(user_id, username, hash, salt);
return issueToken(user_id, device_name);
}
export function login({ username, password, device_name }) {
const d = getDb();
const row = d.prepare('SELECT * FROM users WHERE username=?').get(username || '');
if (!row) throw new Error('用户名或密码错误');
const hash = crypto.scryptSync(password || '', row.salt, 64);
const expect = Buffer.from(row.password_hash, 'hex');
if (hash.length !== expect.length || !crypto.timingSafeEqual(hash, expect)) {
throw new Error('用户名或密码错误');
}
return issueToken(row.id, device_name);
}
function issueToken(user_id, device_name) {
const d = getDb();
const name = (device_name || '未知设备').slice(0, 64);
let device = d.prepare('SELECT id FROM devices WHERE user_id=? AND name=?').get(user_id, name);
let device_id;
if (device) {
device_id = device.id;
d.prepare("UPDATE devices SET last_seen_at=datetime('now','localtime') WHERE id=?").run(device_id);
} else {
device_id = crypto.randomUUID();
d.prepare('INSERT INTO devices (id, user_id, name) VALUES (?,?,?)').run(device_id, user_id, name);
}
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 365 * 86400_000).toISOString().slice(0, 19).replace('T', ' ');
d.prepare('INSERT INTO tokens (token, user_id, device_id, expires_at) VALUES (?,?,?,?)')
.run(token, user_id, device_id, expires);
return { token, user_id, device_id, device_name: name, expires_at: expires };
}
export function authUser(req) {
const d = getDb();
const header = req.headers['authorization'] || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
if (!token) throw Object.assign(new Error('未提供凭据'), { code: 401 });
const row = d.prepare(`
SELECT t.user_id, t.device_id, t.expires_at FROM tokens t WHERE t.token=?`).get(token);
if (!row) throw Object.assign(new Error('凭据无效'), { code: 401 });
if (row.expires_at < new Date().toISOString().slice(0, 19).replace('T', ' ')) {
throw Object.assign(new Error('凭据已过期,请重新登录'), { code: 401 });
}
d.prepare("UPDATE devices SET last_seen_at=datetime('now','localtime') WHERE id=?").run(row.device_id);
return { user_id: row.user_id, device_id: row.device_id };
}
+91
View File
@@ -0,0 +1,91 @@
// 同步服务存储层:SQLite(node:sqlite 内置,零依赖)
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import path from 'node:path';
export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
export const BLOB_DIR = path.join(DATA_DIR, 'blobs');
const DB_PATH = path.join(DATA_DIR, 'sync.db');
let db = null;
export function getDb() {
if (db) return db;
fs.mkdirSync(BLOB_DIR, { recursive: true });
db = new DatabaseSync(DB_PATH);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA foreign_keys = ON;');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
last_seen_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(user_id, name)
);
CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
device_id TEXT NOT NULL REFERENCES devices(id),
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
expires_at TEXT NOT NULL
);
-- 每行最新状态(按 user + table + row_uuid 唯一,LWW 胜者)
CREATE TABLE IF NOT EXISTS current (
user_id TEXT NOT NULL,
table_name TEXT NOT NULL,
row_uuid TEXT NOT NULL,
op TEXT NOT NULL,
hlc TEXT NOT NULL,
ms INTEGER NOT NULL,
cnt INTEGER NOT NULL,
dev TEXT NOT NULL,
data TEXT,
updated_seq INTEGER,
PRIMARY KEY (user_id, table_name, row_uuid)
);
-- 全量变更历史(seq 单调递增,作为拉取游标基准)
CREATE TABLE IF NOT EXISTS changes (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
table_name TEXT NOT NULL,
row_uuid TEXT NOT NULL,
op TEXT NOT NULL,
hlc TEXT NOT NULL,
data TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_current_seq ON current(user_id, updated_seq);
CREATE INDEX IF NOT EXISTS idx_changes_user ON changes(user_id, seq);
-- 附件:内容寻址去重
CREATE TABLE IF NOT EXISTS attachments (
user_id TEXT NOT NULL,
sha256 TEXT NOT NULL,
size INTEGER NOT NULL,
mime TEXT,
file_name TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
PRIMARY KEY (user_id, sha256)
);
`);
return db;
}
// 允许同步的表(与客户端 schema 对应;footprint_dimension 以 uuid 对作普通行同步)
export const SYNC_TABLES = new Set([
'footprint', 'footprint_type', 'taxonomy', 'dimension', 'attachment',
'semester', 'course_grade', 'metric_def', 'saved_view', 'footprint_dimension'
]);
+127
View File
@@ -0,0 +1,127 @@
// Footprints 自建同步服务(零依赖:node:http + node:sqlite)
import http from 'node:http';
import { getDb } from './db.mjs';
import { register, login, authUser } from './auth.mjs';
import { push, pull } from './sync.mjs';
import { upload, download } from './attachments.mjs';
const PORT = Number(process.env.PORT || 8788);
const REGISTER_ENABLED = (process.env.REGISTER_ENABLED ?? 'true') === 'true';
const MAX_BODY = 160 * 1024 * 1024; // 160MB(附件 Base64 余量)
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const route = url.pathname;
const send = (code, ok, data) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(ok ? { ok: true, data } : { ok: false, error: data }));
};
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
try {
if (req.method === 'GET' && route === '/health') {
getDb();
return send(200, true, { status: 'ok', service: 'footprints-sync', time: new Date().toISOString() });
}
// ---- 认证 ----
if (req.method === 'POST' && (route === '/auth/register' || route === '/auth/login')) {
const body = await readJson(req);
if (route === '/auth/register') {
if (!REGISTER_ENABLED) return send(403, false, '注册已关闭');
return send(200, true, register(body));
}
return send(200, true, login(body));
}
// ---- 管理(X-Admin-Token,设置 ADMIN_TOKEN 环境变量后启用)----
if (req.method === 'GET' && route === '/admin/users') {
const expect = process.env.ADMIN_TOKEN;
if (!expect || req.headers['x-admin-token'] !== expect) {
return send(403, false, '管理接口未启用或令牌错误');
}
const db = getDb();
const users = db.prepare(`
SELECT u.id, u.username, u.created_at,
(SELECT COUNT(*) FROM devices dv WHERE dv.user_id = u.id) AS devices,
(SELECT COUNT(*) FROM current c WHERE c.user_id = u.id AND c.op = 'upsert') AS records,
(SELECT COUNT(*) FROM attachments a WHERE a.user_id = u.id) AS attachments,
(SELECT COALESCE(SUM(size), 0) FROM attachments a WHERE a.user_id = u.id) AS attachment_bytes
FROM users u ORDER BY u.created_at`).all();
return send(200, true, { total: users.length, users });
}
// ---- 同步(需鉴权)----
if (route.startsWith('/sync/') || route.startsWith('/auth/')) {
let auth;
try { auth = authUser(req); }
catch (e) { return send(e.code || 401, false, e.message); }
if (req.method === 'POST' && route === '/sync/push') {
const body = await readJson(req);
return send(200, true, push(auth.user_id, body.changes));
}
if (req.method === 'GET' && route === '/sync/pull') {
return send(200, true, pull(auth.user_id, url.searchParams.get('cursor'), url.searchParams.get('limit')));
}
if (req.method === 'POST' && route === '/sync/attachment') {
const body = await readJson(req);
return send(200, true, upload(auth.user_id, body));
}
if (req.method === 'GET' && route.startsWith('/sync/attachment/')) {
const sha = route.slice('/sync/attachment/'.length);
const file = download(auth.user_id, sha);
if (!file) return send(404, false, '附件不存在');
res.writeHead(200, {
'Content-Type': file.mime || 'application/octet-stream',
'Content-Length': file.buf.length,
'Content-Disposition': 'attachment; filename="blob"'
});
return res.end(file.buf);
}
if (req.method === 'GET' && route === '/sync/status') {
const db = getDb();
const changes = db.prepare('SELECT COUNT(*) AS c FROM changes WHERE user_id=?').get(auth.user_id).c;
const att = db.prepare('SELECT COUNT(*) AS c, COALESCE(SUM(size),0) AS bytes FROM attachments WHERE user_id=?').get(auth.user_id);
return send(200, true, {
user_id: auth.user_id,
device_id: auth.device_id,
total_changes: changes,
attachments: att.c,
attachment_bytes: att.bytes
});
}
}
return send(404, false, 'not found');
} catch (err) {
return send(400, false, err.message || String(err));
}
});
function readJson(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', c => {
size += c.length;
if (size > MAX_BODY) { reject(new Error('请求体过大')); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => {
try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); }
catch { reject(new Error('JSON 解析失败')); }
});
req.on('error', reject);
});
}
getDb();
server.listen(PORT, '0.0.0.0', () => {
console.log('[footprints-sync] listening on 0.0.0.0:' + PORT);
console.log('[footprints-sync] data dir:', process.env.DATA_DIR || 'data');
});
+89
View File
@@ -0,0 +1,89 @@
// 同步核心:push(LWW 仲裁)+ pull(seq 游标增量)
import { getDb, SYNC_TABLES } from './db.mjs';
function parseHlc(h) {
const m = /^([0-9]+):([0-9]+):(.+)$/.exec(String(h || ''));
if (!m) return null;
return { ms: Number(m[1]), cnt: Number(m[2]), dev: m[3] };
}
// a 是否严格新于 b:比较 (ms, cnt, dev)
function isNewer(a, b) {
if (a.ms !== b.ms) return a.ms > b.ms;
if (a.cnt !== b.cnt) return a.cnt > b.cnt;
return a.dev > b.dev;
}
export function push(user_id, changes) {
const d = getDb();
const stats = { accepted: 0, stale: 0, invalid: 0 };
if (!Array.isArray(changes)) throw new Error('changes 必须为数组');
if (changes.length > 5000) throw new Error('单次推送不得超过 5000 条');
for (const c of changes) {
const table = c.table;
const row_uuid = String(c.row_uuid || '');
const op = c.op;
const parsed = parseHlc(c.hlc);
if (!SYNC_TABLES.has(table) || !parsed || !['upsert', 'delete'].includes(op)) {
stats.invalid++;
continue;
}
if (op === 'upsert' && (c.data == null || typeof c.data !== 'object')) {
stats.invalid++;
continue;
}
const dataJson = op === 'upsert' ? JSON.stringify(c.data) : null;
const existing = d.prepare(
'SELECT ms, cnt, dev FROM current WHERE user_id=? AND table_name=? AND row_uuid=?'
).get(user_id, table, row_uuid);
if (existing) {
const e = { ms: existing.ms, cnt: existing.cnt, dev: existing.dev };
if (parsed.ms === e.ms && parsed.cnt === e.cnt && parsed.dev === e.dev) {
stats.accepted++; // 同一变更重放,幂等
continue;
}
if (!isNewer(parsed, e)) { stats.stale++; continue; }
d.prepare(`UPDATE current SET op=?, hlc=?, ms=?, cnt=?, dev=?, data=?,
updated_seq=(SELECT COALESCE(MAX(seq),0)+1 FROM changes) WHERE user_id=? AND table_name=? AND row_uuid=?`)
.run(op, c.hlc, parsed.ms, parsed.cnt, parsed.dev, dataJson, user_id, table, row_uuid);
d.prepare('INSERT INTO changes (user_id, table_name, row_uuid, op, hlc, data) VALUES (?,?,?,?,?,?)')
.run(user_id, table, row_uuid, op, c.hlc, dataJson);
stats.accepted++;
} else {
d.prepare(`INSERT INTO current (user_id, table_name, row_uuid, op, hlc, ms, cnt, dev, data,
updated_seq) VALUES (?,?,?,?,?,?,?,?,?,(SELECT COALESCE(MAX(seq),0)+1 FROM changes))`)
.run(user_id, table, row_uuid, op, c.hlc, parsed.ms, parsed.cnt, parsed.dev, dataJson);
d.prepare('INSERT INTO changes (user_id, table_name, row_uuid, op, hlc, data) VALUES (?,?,?,?,?,?)')
.run(user_id, table, row_uuid, op, c.hlc, dataJson);
stats.accepted++;
}
}
return stats;
}
export function pull(user_id, cursorRaw, limitRaw) {
const d = getDb();
const cursor = Math.max(0, Number(cursorRaw) || 0);
const limit = Math.min(2000, Math.max(1, Number(limitRaw) || 1000));
const rows = d.prepare(
'SELECT updated_seq AS seq, table_name, row_uuid, op, hlc, data FROM current WHERE user_id=? AND updated_seq > ? ORDER BY seq LIMIT ?'
).all(user_id, cursor, limit);
const changes = rows.map(r => ({
seq: r.seq,
table: r.table_name,
row_uuid: r.row_uuid,
op: r.op,
hlc: r.hlc,
data: r.data ? JSON.parse(r.data) : null
}));
const maxSeqRow = d.prepare('SELECT COALESCE(MAX(updated_seq),0) AS m FROM current WHERE user_id=?').get(user_id);
const hasMore = rows.length === limit;
return {
changes,
cursor: hasMore ? rows[rows.length - 1].seq : Math.max(cursor, maxSeqRow.m),
has_more: hasMore
};
}