feat: 足迹 Footprints 个人留痕系统
桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA 同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署 多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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'
|
||||
]);
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user