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