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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
data/
.data/
node_modules/
*.md
+7
View File
@@ -0,0 +1,7 @@
# 追加到 /opt/caddy 的 Caddyfile(与 gitea.linyux.cn 等块并列)
# 前置:为 FootPR.linyux.cn 添加 A 记录指向服务器 IP,Caddy 自动签发证书
# 注:域名不区分大小写,配置统一使用小写 footpr.linyux.cn
footpr.linyux.cn {
reverse_proxy footprints-sync:8788
}
+19
View File
@@ -0,0 +1,19 @@
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8788
ENV DATA_DIR=/data
COPY package.json ./
COPY src ./src
RUN mkdir -p /data
EXPOSE 8788
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
CMD wget -qO- http://127.0.0.1:8788/health || exit 1
CMD ["node", "src/index.mjs"]
+82
View File
@@ -0,0 +1,82 @@
# Footprints 自建同步服务
个人留痕系统的多端同步服务端。零 npm 依赖(Node 内置 node:sqlite),SQLite 单文件存储,
专为单用户/小规模多设备设计。协议:行级 LWW(HLC 仲裁)+ seq 游标增量拉取。
## 部署(Docker)
### 方式一:已有 Caddy 容器网络(推荐,与你现有 gitea/halo 等部署同构)
# 1. 仓库上传到服务器(如 /opt/footprints),进入 sync-server 目录
# 2. 网络名已预设为 mynet(Caddy 共享网络,无需再改)
docker compose -f docker-compose.caddy.yml up -d --build
容器不映射宿主端口,Caddy 直接按服务名反代。Caddyfile 增加一块(与现有写法并列):
footpr.linyux.cn {
reverse_proxy footprints-sync:8788
}
前置:为 footpr.linyux.cn 添加 A 记录指向服务器 IP,Caddy 自动签发证书;重载 Caddy(`caddy reload` 或重启容器)。
### 方式二:独立部署(无 Caddy 网络时)
docker compose up -d --build
# 默认只绑定 127.0.0.1:8788,交给宿主上的反代;需要直连时改为 "8788:8788"
数据全部落在 `./data/`(SQLite 库 + 附件 Blob),备份 = 复制该目录。
- 首次部署 `REGISTER_ENABLED=true`,注册自己的账号后改为 `false` 再 `docker compose up -d` 关闭注册
- 验证:curl https://footpr.linyux.cn/health 应返回 {"ok":true,...}
## 多用户
不止一人使用时,服务端天然按账号隔离数据(变更/附件/设备均挂 user_id),无需分库:
- 邀请码:`INVITE_CODE` 设置后,注册请求必须携带 `invite_code` 字段——把邀请码发给你信任的人即可
- 名额:`MAX_USERS` 限制用户总数(0 = 不限),防滥用
- 管理:设置 `ADMIN_TOKEN` 后,`GET /admin/users`(请求头 `X-Admin-Token`)可查看全部用户、设备数、记录数与附件占用
- 客户端:各用户安装桌面应用或 PWA,登录自己的账号即得到独立档案;同一台电脑可切换多个账号(本地档案按账号隔离)
- 数据安全:账号间完全隔离;管理员也只能看到元数据(用户名/设备数/存储占用),看不到档案内容
## 安全要点
- 必须经 HTTPS 访问(Caddy 自动证书);裸 HTTP 暴露公网不可接受
- 数据(SQLite + 附件 Blob)全在 `./data/`,定期备份该目录即可
- 服务端不解释业务字段;表间引用由客户端以 uuid 表达
## API
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /health | 健康检查(Docker healthcheck 使用) |
| POST | /auth/register | 注册 {username, password, device_name},返回 {token, device_id} |
| POST | /auth/login | 登录,同上;同名设备复用 device_id |
| POST | /sync/push | 推送变更 {changes:[{table,row_uuid,op,hlc,data}]},返回 {accepted, stale, invalid} |
| GET | /sync/pull?cursor=N | 增量拉取,返回 {changes, cursor, has_more} |
| POST | /sync/attachment | 上传附件 {sha256,size,mime,file_name,data_b64},返回 {dedup} |
| GET | /sync/attachment/:sha256 | 下载附件(内容寻址,跨设备去重) |
| GET | /sync/status | 账号统计 |
鉴权:`Authorization: Bearer <token>`(/sync/* 全部需要)。
### 同步语义
- 服务端不解释业务字段,data 为不透明 JSON;业务表间引用由客户端以 uuid 表达
- 冲突:按 HLC(毫秒:计数:设备)三元组逐级比较,新者胜;旧推送计入 stale 并丢弃
- current 表存每行最新态,changes 表保留完整历史(seq 全局单调,即拉取游标)
- 附件按 (user, sha256) 去重,同一佐证多端引用仅存一份
## 目录
src/index.mjs HTTP 路由
src/db.mjs SQLite schema 与连接
src/auth.mjs 注册/登录/token(scrypt)
src/sync.mjs push(LWW)/ pull(seq 游标)
src/attachments.mjs 附件内容寻址存储
## 相关文件
- `docker-compose.caddy.yml`:与既有 Caddy 容器网络配合(推荐)
- `docker-compose.yml`:独立部署(本机回环端口)
- `Caddyfile.snippet`:Caddyfile 增量片段
+26
View File
@@ -0,0 +1,26 @@
# 与既有 Caddy 反代架构配合使用(推荐):
# 1. 把整个仓库上传到服务器(如 /opt/footprints)
# 2. Caddy 所在的 Docker 网络名已确认为 mynet(docker network ls)
# docker compose -f docker-compose.caddy.yml up -d --build
# 4. Caddyfile 增加反代块(见 README),重载 Caddy
# 5. 注册自己的账号后,把 REGISTER_ENABLED 改为 false 并重新 up -d
services:
footprints-sync:
build: .
container_name: footprints-sync
# 无需 ports 映射:Caddy 通过容器网络按服务名直连(footprints-sync:8788)
volumes:
- ./data:/data
environment:
- REGISTER_ENABLED=true
- INVITE_CODE=please-change-me # 多用户:注册须携带此邀请码(留空则不校验)
- MAX_USERS=20 # 用户数上限,0=不限
# - ADMIN_TOKEN=please-change-me # 设置后可用 GET /admin/users(请求头 X-Admin-Token)
restart: unless-stopped
networks:
- proxy
networks:
proxy:
name: mynet # 服务器上已确认的 Caddy 共享网络
external: true
+16
View File
@@ -0,0 +1,16 @@
# 通用独立部署:直接暴露宿主端口 8788(建议置于反代/内网之后)
# 若你的服务器上已有 Caddy 反代架构,请改用 docker-compose.caddy.yml
services:
footprints-sync:
build: .
container_name: footprints-sync
ports:
- "127.0.0.1:8788:8788" # 只绑定本机回环,交给反代;确认无反代时可改为 "8788:8788"
volumes:
- ./data:/data
environment:
- REGISTER_ENABLED=true # 注册自己的账号后改为 false,再 docker compose up -d
- INVITE_CODE=please-change-me # 多用户:注册须携带此邀请码(留空则不校验)
- MAX_USERS=20 # 用户数上限,0=不限
# - ADMIN_TOKEN=please-change-me # 设置后可用 GET /admin/users(请求头 X-Admin-Token)
restart: unless-stopped
+12
View File
@@ -0,0 +1,12 @@
{
"name": "footprints-sync-server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.mjs"
},
"engines": {
"node": ">=24"
}
}
+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
};
}
+8
View File
@@ -0,0 +1,8 @@
# 本地启动同步服务(演示用)
$env:PORT = '8788'
$env:DATA_DIR = Join-Path $PSScriptRoot 'data'
$env:INVITE_CODE = 'footpr-2026'
$env:ADMIN_TOKEN = 'admin-demo'
$env:REGISTER_ENABLED = 'true'
Set-Location $PSScriptRoot
node src/index.mjs