Files
FootPrint/app/server/db.mjs
T

936 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 数据层:dev-bridge 与 seed 共用
// v2:多端同步地基 —— uuid v7 / 软删除墓碑 / HLC / 变更日志(自建服务方案)
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', '.data');
export const ATT_DIR = path.join(DATA_DIR, 'attachments');
export const DB_PATH = path.join(DATA_DIR, 'footprints.db');
function getMeta(d, key) {
const r = d.prepare('SELECT value FROM sync_state WHERE key=?').get(key);
return r ? r.value : null;
}
function setMeta(d, key, value) {
d.prepare("INSERT INTO sync_state (key, value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
.run(key, String(value));
}
function getDeviceId(d) {
let id = getMeta(d, 'device_id');
if (!id) {
id = crypto.randomBytes(6).toString('hex');
setMeta(d, 'device_id', id);
}
return id;
}
// UUID v7(RFC 9562):48bit 毫秒时间戳 + 随机位,时间有序,索引友好
export function uuidv7() {
const ts = Date.now();
const b = Buffer.alloc(16);
b.writeUIntBE(ts % 281474976710656, 0, 6);
crypto.randomBytes(10).copy(b, 6);
b[6] = (b[6] & 0x0f) | 0x70; // version 7
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
const h = b.toString('hex');
return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20, 32);
}
// 混合逻辑时钟:毫秒:计数:设备 —— 时钟回拨免疫,同设备单调,跨设备可比较
export function hlcNext(d) {
const ms = Date.now();
const lastMs = Number(getMeta(d, 'hlc_ms') || 0);
const lastCnt = Number(getMeta(d, 'hlc_counter') || 0);
let newMs, newCnt;
if (ms > lastMs) { newMs = ms; newCnt = 0; }
else { newMs = lastMs; newCnt = lastCnt + 1; }
setMeta(d, 'hlc_ms', String(newMs));
setMeta(d, 'hlc_counter', String(newCnt));
return newMs + ':' + newCnt + ':' + getDeviceId(d);
}
function touchOplog(d, table, rowUuid, op, hlc) {
d.prepare('INSERT INTO sync_oplog (table_name, row_uuid, op, hlc) VALUES (?,?,?,?)')
.run(table, rowUuid, op, hlc);
}
function withTx(d, fn) {
d.exec('BEGIN');
try { const r = fn(); d.exec('COMMIT'); return r; }
catch (e) { d.exec('ROLLBACK'); throw e; }
}
function getUuid(d, table, id) {
const r = d.prepare('SELECT uuid FROM ' + table + ' WHERE id=?').get(id);
return r ? r.uuid : null;
}
export function buildSearchText(title, description, result, tags, extras) {
const extraFlat = Object.entries(extras || {}).map(([k, v]) => {
if (v == null) return '';
if (typeof v === 'object') return Object.values(v).filter(Boolean).join(' ');
return String(v);
}).filter(Boolean);
return [title, description || '', result || '', (tags || []).join(' '), ...extraFlat]
.filter(Boolean).join('\n');
}
// 老库迁移:补列 + 回填 uuid/hlc/origin(幂等)
const SYNC_TABLES = ['footprint', 'footprint_type', 'taxonomy', 'dimension', 'attachment',
'semester', 'course_grade', 'metric_def', 'saved_view', 'grade_band'];
const SYNC_COLS = [['uuid', 'TEXT'], ['deleted_at', 'TEXT'], ['origin', 'TEXT'], ['hlc', 'TEXT']];
function preMigrateColumns(d) {
const addCols = (t, cols) => {
const exists = d.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(t);
if (!exists) return; // 全新库:表由 schema.sql 创建,自带同步列
const have = new Set(d.prepare('PRAGMA table_info(' + t + ')').all().map(c => c.name));
for (const [name, ddl] of cols) {
if (!have.has(name)) d.exec('ALTER TABLE ' + t + ' ADD COLUMN ' + name + ' ' + ddl);
}
};
for (const t of SYNC_TABLES) addCols(t, SYNC_COLS);
addCols('attachment', [['sha256', 'TEXT']]);
}
export function migrateSync(d) {
const addCols = (t, cols) => {
const have = new Set(d.prepare('PRAGMA table_info(' + t + ')').all().map(c => c.name));
for (const [name, ddl] of cols) {
if (!have.has(name)) d.exec('ALTER TABLE ' + t + ' ADD COLUMN ' + name + ' ' + ddl);
}
};
for (const t of SYNC_TABLES) {
addCols(t, [['uuid', 'TEXT'], ['deleted_at', 'TEXT'], ['origin', 'TEXT'], ['hlc', 'TEXT']]);
}
addCols('attachment', [['sha256', 'TEXT']]);
for (const t of SYNC_TABLES) {
const rows = d.prepare('SELECT id FROM ' + t + ' WHERE uuid IS NULL').all();
for (const row of rows) {
d.prepare('UPDATE ' + t + ' SET uuid=?, hlc=?, origin=? WHERE id=?')
.run(uuidv7(), hlcNext(d), 'migrate', row.id);
}
}
}
function softDelete(d, table, id) {
const uuid = getUuid(d, table, id);
if (!uuid) return;
const h = hlcNext(d);
d.prepare('UPDATE ' + table + " SET deleted_at=datetime('now','localtime'), hlc=? WHERE id=?").run(h, id);
touchOplog(d, table, uuid, 'delete', h);
}
const BUILTIN_TYPES = [
['学业', 'graduation-cap', '#3056d3', 10, JSON.stringify([
{ key: 'semester_label', label: '学期', widget: 'text', placeholder: '如:2024-2025-1' }
])],
['竞赛', 'trophy', '#7c3aed', 20, JSON.stringify([
{ key: 'my_work', label: '我的分工', widget: 'textarea', required: true },
{ key: 'deliverable', label: '最终产品', widget: 'text', placeholder: '作品 / 论文 / 系统名称' },
{ key: 'team_members', label: '队伍成员', widget: 'text' }
])],
['科研', 'flask-conical', '#0891b2', 30, JSON.stringify([
{ key: 'authors', label: '作者排序', widget: 'text', placeholder: '如:第 2 作者' },
{ key: 'outcome', label: '成果形式', widget: 'select', options: ['论文', '专利', '软件著作权', '研究报告'] }
])],
['实习实践', 'briefcase', '#0f766e', 40, JSON.stringify([
{ key: 'position', label: '岗位', widget: 'text' },
{ key: 'mentor', label: '带教人', widget: 'text' }
])],
['学生工作', 'network', '#d97706', 50, JSON.stringify([
{ key: 'duties', label: '职责', widget: 'textarea' },
{ key: 'term', label: '任期', widget: 'text', placeholder: '2024.09 - 2025.06' }
])],
['志愿公益', 'heart-handshake', '#16a34a', 60, JSON.stringify([
{ key: 'hours', label: '志愿时长', widget: 'number', unit: '小时', metric: 'volunteer_hours' },
{ key: 'contact', label: '组织负责人', widget: 'text' }
])],
['文体活动', 'palette', '#db2777', 70, JSON.stringify([
{ key: 'participation', label: '参与形式', widget: 'text', placeholder: '如:方阵队员 / 主唱' }
])],
['证书技能', 'file-badge', '#64748b', 80, JSON.stringify([
{ key: 'cert_no', label: '证书编号', widget: 'text' },
{ key: 'score', label: '成绩', widget: 'text', placeholder: '如:580 / 良好' }
])],
['荣誉惩处', 'award', '#b45309', 90, JSON.stringify([
{ key: 'issued_by', label: '授予 / 决定单位', widget: 'text' }
])]
];
const BUILTIN_LEVELS = [
['国际级', '#7c3aed', 10], ['国家级', '#dc2626', 20], ['省市级', '#ea580c', 30],
['区县级', '#2563eb', 40], ['校级', '#16a34a', 50], ['院级', '#0891b2', 60]
];
const BUILTIN_ROLES = [
['主导', '#3056d3', 10], ['核心', '#0891b2', 20], ['参加', '#64748b', 30]
];
const BUILTIN_DIMENSIONS = [
['学业深造', '#3056d3', 10], ['职业发展', '#0891b2', 20],
['综合素养', '#16a34a', 30], ['兴趣生活', '#db2777', 40]
];
function ensureBuiltin(db) {
const cnt = db.prepare('SELECT COUNT(*) AS c FROM footprint_type WHERE deleted_at IS NULL').get().c;
if (cnt === 0) {
const ins = db.prepare(
'INSERT INTO footprint_type (name, icon, color, sort, form_template, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,?,1,?,?,?)');
for (const [name, icon, color, sort, tpl] of BUILTIN_TYPES) {
const h = hlcNext(db);
ins.run(name, icon, color, sort, tpl, uuidv7(), h, getDeviceId(db));
}
}
const tcnt = db.prepare('SELECT COUNT(*) AS c FROM taxonomy WHERE deleted_at IS NULL').get().c;
if (tcnt === 0) {
const ins = db.prepare('INSERT INTO taxonomy (kind, value, color, sort, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,1,?,?,?)');
for (const [v, c, s] of BUILTIN_LEVELS) {
const h = hlcNext(db);
ins.run('level', v, c, s, uuidv7(), h, getDeviceId(db));
}
for (const [v, c, s] of BUILTIN_ROLES) {
const h = hlcNext(db);
ins.run('role', v, c, s, uuidv7(), h, getDeviceId(db));
}
}
const dcnt = db.prepare('SELECT COUNT(*) AS c FROM dimension WHERE deleted_at IS NULL').get().c;
if (dcnt === 0) {
const ins = db.prepare('INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)');
for (const [v, c, s] of BUILTIN_DIMENSIONS) {
const h = hlcNext(db);
ins.run(v, c, s, uuidv7(), h, getDeviceId(db));
}
}
const mcnt = db.prepare('SELECT COUNT(*) AS c FROM metric_def WHERE deleted_at IS NULL').get().c;
if (mcnt === 0) {
const h = hlcNext(db);
db.prepare('INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
.run('volunteer_hours', '志愿时长', '小时', 'sum', '志愿服务', uuidv7(), h, getDeviceId(db));
}
const bcnt = db.prepare('SELECT COUNT(*) AS c FROM grade_band WHERE deleted_at IS NULL').get().c;
if (bcnt === 0) {
const bands = [
['优秀', '#16803c', 90, 100, 10],
['良好', '#0891b2', 80, 90, 20],
['中等', '#d97706', 70, 80, 30],
['及格', '#ea580c', 60, 70, 40],
['不及格', '#dc2626', 0, 60, 50]
];
const ins = db.prepare('INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)');
for (const [label, color, min, max, sort] of bands) {
const h = hlcNext(db);
ins.run(label, color, min, max, sort, uuidv7(), h, getDeviceId(db));
}
}
}
// ---------- 查询构建 ----------
function openDb(dbPath) {
fs.mkdirSync(path.join(path.dirname(dbPath), 'attachments'), { recursive: true });
const d = new DatabaseSync(dbPath);
d.exec('PRAGMA foreign_keys = ON;');
// 先补列(老库无 uuid/hlc 等列时,schema 中的索引会引用失败),再执行 schema,最后回填
preMigrateColumns(d);
d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8'));
migrateSync(d);
ensureBuiltin(d);
return d;
}
// 按数据库路径创建一套应用命令(多用户/服务器模式:每个用户一个独立档案库)
export function createAppCommands(dbPath) {
const db = openDb(dbPath);
const DATA_DIR = path.dirname(dbPath); // 工厂内遮蔽顶层默认值:附件按用户目录
function getDb() {
return db;
}
// ---------- 同步基础设施 ----------
const SORTS = {
date_desc: "COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) DESC, fp.id DESC",
date_asc: "COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) ASC, fp.id ASC",
updated_desc: 'fp.updated_at DESC, fp.id DESC',
weight_desc: "fp.weight DESC, COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) DESC"
};
function buildWhere(query) {
const conds = [];
const params = [];
const joins = [];
if (query.q) {
// 中文检索策略:长度>=3 的词走 FTS5 trigram(子串匹配),更短的词回退 LIKE
const ftsTerms = [];
for (const raw of String(query.q).trim().split(/\s+/).filter(Boolean)) {
const clean = raw.replace(/["\\]/g, ' ');
if (!clean.trim()) continue;
if ([...clean].length >= 3) {
ftsTerms.push('"' + clean + '"');
} else {
conds.push('fp.search_text LIKE ?');
params.push('%' + clean + '%');
}
}
if (ftsTerms.length) {
joins.push('JOIN footprint_fts ON footprint_fts.rowid = fp.id');
conds.push('footprint_fts MATCH ?');
params.push(ftsTerms.join(' AND '));
}
}
if (query.types?.length) {
conds.push('fp.type_id IN (' + query.types.map(() => '?').join(',') + ')');
params.push(...query.types);
}
if (query.levels?.length) {
conds.push('fp.level IN (' + query.levels.map(() => '?').join(',') + ')');
params.push(...query.levels);
}
if (query.roles?.length) {
conds.push('fp.role IN (' + query.roles.map(() => '?').join(',') + ')');
params.push(...query.roles);
}
if (query.statuses?.length) {
conds.push('fp.status IN (' + query.statuses.map(() => '?').join(',') + ')');
params.push(...query.statuses);
}
if (query.dims?.length) {
conds.push('fp.id IN (SELECT footprint_id FROM footprint_dimension WHERE dimension_id IN ('
+ query.dims.map(() => '?').join(',') + '))');
params.push(...query.dims);
}
if (query.year) {
conds.push("strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = ?");
params.push(String(query.year));
}
if (query.polarity) {
conds.push('fp.polarity = ?');
params.push(query.polarity);
}
if (query.has_attachment) {
conds.push("EXISTS (SELECT 1 FROM attachment a WHERE a.owner_type='footprint' AND a.owner_id=fp.id AND a.deleted_at IS NULL)");
}
if (query.tag) {
conds.push('fp.tags LIKE ?');
params.push('%"' + query.tag + '"%');
}
return { joins, conds, params };
}
const BASE_SELECT = `SELECT fp.*, ft.name AS type_name, ft.color AS type_color, ft.icon AS type_icon,
(SELECT COUNT(*) FROM attachment a WHERE a.owner_type='footprint' AND a.owner_id=fp.id AND a.deleted_at IS NULL) AS attachment_count,
(SELECT GROUP_CONCAT(dimension_id) FROM footprint_dimension fd WHERE fd.footprint_id=fp.id) AS dim_ids
FROM footprint fp JOIN footprint_type ft ON ft.id = fp.type_id`;
function mapRow(r) {
let metrics = {}, extras = {}, tags = [];
try { metrics = JSON.parse(r.metrics || '{}'); } catch {}
try { extras = JSON.parse(r.extras || '{}'); } catch {}
try { tags = JSON.parse(r.tags || '[]'); } catch {}
return {
...r,
metrics, extras, tags,
dimensions: r.dim_ids ? String(r.dim_ids).split(',').map(Number) : [],
dim_ids: undefined
};
}
function listFootprints(d, query) {
const page = Math.max(1, query.page || 1);
const pageSize = Math.min(2000, Math.max(1, query.page_size || 20));
const { joins, conds, params } = buildWhere(query);
const base = ' WHERE fp.deleted_at IS NULL';
const where = conds.length ? base + ' AND ' + conds.join(' AND ') : base;
const order = SORTS[query.sort] || SORTS.date_desc;
const total = d.prepare(
'SELECT COUNT(*) AS c FROM footprint fp ' + joins.join(' ') + where
).get(...params).c;
const rows = d.prepare(
BASE_SELECT + ' ' + joins.join(' ') + where + ' ORDER BY ' + order +
' LIMIT ' + pageSize + ' OFFSET ' + (page - 1) * pageSize
).all(...params).map(mapRow);
return { items: rows, total, page, page_size: pageSize };
}
// ---------- 命令处理 ----------
function handleCommand(cmd, payload) {
const d = getDb();
switch (cmd) {
case 'app_info': {
const pending = d.prepare('SELECT COUNT(*) AS c FROM sync_oplog WHERE synced_at IS NULL').get().c;
return {
app_name: 'Footprints 足迹',
version: '0.2.0',
db_path: DB_PATH,
attachment_dir: ATT_DIR,
schema_ok: true,
device_id: getDeviceId(d),
pending_ops: pending,
last_sync_at: getMeta(d, 'last_sync_at')
};
}
case 'sync_status':
return {
mode: 'local',
device_id: getDeviceId(d),
pending_ops: d.prepare('SELECT COUNT(*) AS c FROM sync_oplog WHERE synced_at IS NULL').get().c,
last_sync_at: getMeta(d, 'last_sync_at')
};
case 'type_list': {
const items = d.prepare(`
SELECT t.*, (SELECT COUNT(*) FROM footprint f WHERE f.type_id = t.id AND f.deleted_at IS NULL) AS usage_count
FROM footprint_type t WHERE t.deleted_at IS NULL ORDER BY t.sort, t.id`).all()
.map(t => ({ ...t, form_template: JSON.parse(t.form_template || '[]'), is_builtin: !!t.is_builtin, closed: !!t.closed }));
return { items };
}
case 'type_upsert': {
const p = payload;
const tpl = JSON.stringify(p.form_template || []);
let id = p.id;
if (id) {
withTx(d, () => {
const h = hlcNext(d);
d.prepare('UPDATE footprint_type SET name=?, icon=?, color=?, sort=?, form_template=?, closed=?, hlc=? WHERE id=?')
.run(p.name, p.icon, p.color, p.sort, tpl, p.closed ? 1 : 0, h, id);
touchOplog(d, 'footprint_type', getUuid(d, 'footprint_type', id), 'upsert', h);
});
} else {
withTx(d, () => {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare(
'INSERT INTO footprint_type (name, icon, color, sort, form_template, closed, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?)')
.run(p.name, p.icon, p.color, p.sort, tpl, p.closed ? 1 : 0, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'footprint_type', uuid, 'upsert', h);
});
}
return { item: { id } };
}
case 'type_delete': {
const used = d.prepare('SELECT COUNT(*) AS c FROM footprint WHERE type_id=? AND deleted_at IS NULL').get(payload.id).c;
if (used > 0) throw new Error('该类型下仍有 ' + used + ' 条记录,请先迁移后再删除');
const row = d.prepare('SELECT uuid, is_builtin FROM footprint_type WHERE id=?').get(payload.id);
if (!row) return { ok: true };
if (row.is_builtin) throw new Error('内置类型不可删除');
withTx(d, () => softDelete(d, 'footprint_type', payload.id));
return { ok: true };
}
case 'taxonomy_list': {
const sql = payload.kind
? "SELECT * FROM taxonomy WHERE kind=? AND deleted_at IS NULL ORDER BY sort, id"
: "SELECT * FROM taxonomy WHERE deleted_at IS NULL ORDER BY kind, sort, id";
const rows = payload.kind ? d.prepare(sql).all(payload.kind) : d.prepare(sql).all();
return { items: rows.map(r => ({ ...r, is_builtin: !!r.is_builtin })) };
}
case 'taxonomy_upsert': {
const p = payload;
let id = p.id;
if (id) {
withTx(d, () => {
const h = hlcNext(d);
d.prepare('UPDATE taxonomy SET kind=?, value=?, color=?, sort=?, hlc=? WHERE id=?')
.run(p.kind, p.value, p.color ?? null, p.sort ?? 100, h, id);
touchOplog(d, 'taxonomy', getUuid(d, 'taxonomy', id), 'upsert', h);
});
} else {
withTx(d, () => {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO taxonomy (kind, value, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)')
.run(p.kind, p.value, p.color ?? null, p.sort ?? 100, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'taxonomy', uuid, 'upsert', h);
});
}
return { item: { id } };
}
case 'taxonomy_delete': {
const row = d.prepare('SELECT is_builtin FROM taxonomy WHERE id=?').get(payload.id);
if (!row) return { ok: true };
if (row.is_builtin) throw new Error('内置条目不可删除');
withTx(d, () => softDelete(d, 'taxonomy', payload.id));
return { ok: true };
}
case 'dimension_list':
return { items: d.prepare('SELECT * FROM dimension WHERE deleted_at IS NULL ORDER BY sort, id').all() };
case 'dimension_upsert': {
const p = payload;
let id = p.id;
if (id) {
withTx(d, () => {
const h = hlcNext(d);
d.prepare('UPDATE dimension SET name=?, color=?, sort=?, hlc=? WHERE id=?')
.run(p.name, p.color ?? null, p.sort ?? 100, h, id);
touchOplog(d, 'dimension', getUuid(d, 'dimension', id), 'upsert', h);
});
} else {
withTx(d, () => {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)')
.run(p.name, p.color ?? null, p.sort ?? 100, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'dimension', uuid, 'upsert', h);
});
}
return { item: { id } };
}
case 'dimension_delete': {
withTx(d, () => softDelete(d, 'dimension', payload.id));
return { ok: true };
}
case 'footprint_list':
return listFootprints(d, payload.query || {});
case 'footprint_get': {
const row = d.prepare(BASE_SELECT + ' AND fp.id=?').get(payload.id);
if (!row) throw new Error('记录不存在');
const attachments = d.prepare(
"SELECT * FROM attachment WHERE owner_type='footprint' AND owner_id=? AND deleted_at IS NULL ORDER BY id"
).all(payload.id);
return { item: { ...mapRow(row), attachments } };
}
case 'footprint_upsert': {
const it = payload.item;
if (!it.type_id) throw new Error('缺少记录类型');
if (!it.title || !it.title.trim()) throw new Error('缺少标题');
const search = buildSearchText(it.title.trim(), it.description, it.result, it.tags, it.extras);
const metrics = JSON.stringify(it.metrics || {});
const extras = JSON.stringify(it.extras || {});
const tags = JSON.stringify(it.tags || []);
const nowLocal = "datetime('now','localtime')";
let id = it.id;
withTx(d, () => {
if (id) {
const h = hlcNext(d);
d.prepare(`UPDATE footprint SET type_id=?, title=?, role=?, level=?, polarity=?, award_grade=?, rank=?,
organizer=?, start_date=?, end_date=?, status=?, description=?, result=?, metrics=?, extras=?,
group_id=?, parent_id=?, tags=?, weight=?, search_text=?, hlc=?, updated_at=${nowLocal} WHERE id=?`)
.run(it.type_id, it.title.trim(), it.role ?? null, it.level ?? null, it.polarity || 'positive',
it.award_grade ?? null, it.rank ?? null, it.organizer ?? null, it.start_date ?? null, it.end_date ?? null,
it.status || 'draft', it.description ?? '', it.result ?? '', metrics, extras,
it.group_id ?? null, it.parent_id ?? null, tags, it.weight ?? 3, search, h, id);
touchOplog(d, 'footprint', getUuid(d, 'footprint', id), 'upsert', h);
} else {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare(`INSERT INTO footprint (type_id, title, role, level, polarity, award_grade, rank,
organizer, start_date, end_date, status, description, result, metrics, extras, group_id, parent_id,
tags, weight, search_text, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
.run(it.type_id, it.title.trim(), it.role ?? null, it.level ?? null, it.polarity || 'positive',
it.award_grade ?? null, it.rank ?? null, it.organizer ?? null, it.start_date ?? null, it.end_date ?? null,
it.status || 'draft', it.description ?? '', it.result ?? '', metrics, extras,
it.group_id ?? null, it.parent_id ?? null, tags, it.weight ?? 3, search, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'footprint', uuid, 'upsert', h);
}
if (Array.isArray(it.dimension_ids)) {
d.prepare('DELETE FROM footprint_dimension WHERE footprint_id=?').run(id);
const ins = d.prepare('INSERT OR IGNORE INTO footprint_dimension (footprint_id, dimension_id) VALUES (?,?)');
for (const dim of it.dimension_ids) ins.run(id, dim);
}
});
return { id };
}
case 'footprint_delete': {
const row = d.prepare('SELECT uuid FROM footprint WHERE id=?').get(payload.id);
if (!row) return { ok: true };
withTx(d, () => {
// FTS 内容表需同步移除:先物理删除 FTS 行,墓碑保留在主表
d.prepare("INSERT INTO footprint_fts(footprint_fts, rowid, search_text) SELECT 'delete', id, search_text FROM footprint WHERE id=?")
.run(payload.id);
softDelete(d, 'footprint', payload.id);
});
return { ok: true };
}
case 'timeline': {
const res = listFootprints(d, { ...(payload.query || {}), page_size: 2000, sort: 'date_desc' });
const groups = [];
const byYear = new Map();
for (const row of res.items) {
const date = (row.start_date && row.start_date.slice(0, 10)) || row.created_at.slice(0, 10);
const year = Number(date.slice(0, 4));
const month = Number(date.slice(5, 7));
if (!byYear.has(year)) {
const g = { year, count: 0, months: [] };
byYear.set(year, g);
groups.push(g);
}
const g = byYear.get(year);
g.count++;
let m = g.months.find(x => x.month === month);
if (!m) { m = { month, items: [] }; g.months.push(m); }
m.items.push(row);
}
groups.sort((a, b) => b.year - a.year);
for (const g of groups) g.months.sort((a, b) => a.month - b.month);
return { groups, total: res.total };
}
case 'stats_overview': {
const year = payload.year || null;
const yearCond = year
? " AND strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = '" + String(year).replace(/[^0-9]/g, '') + "'"
: '';
const yearCondRaw = year
? " AND strftime('%Y', COALESCE(NULLIF(start_date,''), substr(created_at,1,10))) = '" + String(year).replace(/[^0-9]/g, '') + "'"
: '';
const DEL = 'fp.deleted_at IS NULL';
const DEL_RAW = 'deleted_at IS NULL';
const total = d.prepare('SELECT COUNT(*) AS c FROM footprint fp WHERE ' + DEL + yearCond).get().c;
const curYear = year || new Date().getFullYear();
const totalThisYear = d.prepare(
"SELECT COUNT(*) AS c FROM footprint fp WHERE strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = ?"
).get(String(curYear)).c;
const attTotal = d.prepare(
"SELECT COUNT(*) AS c FROM attachment a JOIN footprint fp ON fp.id = a.owner_id AND a.owner_type='footprint' WHERE a.deleted_at IS NULL AND " + DEL + yearCond
).get().c;
const byType = d.prepare(`SELECT ft.name AS label, ft.color, COUNT(*) AS count
FROM footprint fp JOIN footprint_type ft ON ft.id=fp.type_id WHERE ${DEL}${yearCond}
GROUP BY fp.type_id ORDER BY count DESC`).all();
const byLevel = d.prepare(`SELECT COALESCE(fp.level,'未设置') AS label, COUNT(*) AS count
FROM footprint fp WHERE ${DEL}${yearCond} GROUP BY fp.level ORDER BY count DESC`).all();
const byRole = d.prepare(`SELECT COALESCE(fp.role,'未设置') AS label, COUNT(*) AS count
FROM footprint fp WHERE ${DEL}${yearCond} GROUP BY fp.role ORDER BY count DESC`).all();
const byStatus = d.prepare(`SELECT fp.status AS label, COUNT(*) AS count
FROM footprint fp WHERE ${DEL}${yearCond} GROUP BY fp.status`).all();
const monthlyRows = d.prepare(`SELECT substr(COALESCE(NULLIF(start_date,''), substr(created_at,1,10)),1,7) AS ym, COUNT(*) AS count
FROM footprint WHERE ${DEL_RAW}${yearCondRaw} GROUP BY ym ORDER BY ym`).all();
const monthlyMap = new Map(monthlyRows.map(r => [r.ym, r.count]));
const monthly = [];
const now = new Date();
for (let i = 11; i >= 0; i--) {
const dt = new Date(now.getFullYear(), now.getMonth() - i, 1);
const ym = dt.getFullYear() + '-' + String(dt.getMonth() + 1).padStart(2, '0');
monthly.push({ ym, count: monthlyMap.get(ym) ?? 0 });
}
const metrics = [];
for (const m of d.prepare('SELECT * FROM metric_def WHERE deleted_at IS NULL').all()) {
const path = '$."' + m.key + '"';
let value = 0;
if (m.agg === 'sum') {
const r = d.prepare(`SELECT SUM(CAST(json_extract(fp.metrics, ?) AS REAL)) AS v
FROM footprint fp WHERE json_extract(fp.metrics, ?) IS NOT NULL AND ${DEL}${yearCond}`).get(path, path);
value = r.v ?? 0;
} else if (m.agg === 'count') {
value = d.prepare(`SELECT COUNT(*) AS c FROM footprint fp WHERE json_extract(fp.metrics, ?) IS NOT NULL AND ${DEL}${yearCond}`).get(path).c;
} else if (m.agg === 'distinct') {
value = d.prepare(`SELECT COUNT(DISTINCT json_extract(fp.metrics, ?)) AS c FROM footprint fp WHERE json_extract(fp.metrics, ?) IS NOT NULL AND ${DEL}${yearCond}`).get(path, path).c;
}
metrics.push({ key: m.key, label: m.label, unit: m.unit, agg: m.agg, value });
}
const years = d.prepare(`SELECT DISTINCT CAST(strftime('%Y', COALESCE(NULLIF(start_date,''), substr(created_at,1,10))) AS INTEGER) AS y
FROM footprint WHERE ${DEL_RAW} ORDER BY y DESC`).all().map(r => r.y);
return {
total, total_this_year: totalThisYear, attachment_total: attTotal,
by_type: byType, by_level: byLevel, by_role: byRole, by_status: byStatus,
monthly, metrics, years
};
}
case 'metric_list':
return { items: d.prepare('SELECT * FROM metric_def WHERE deleted_at IS NULL ORDER BY id').all() };
case 'metric_upsert': {
const p = payload;
const exist = d.prepare('SELECT id, uuid FROM metric_def WHERE key=? AND deleted_at IS NULL').get(p.key);
withTx(d, () => {
if (exist) {
const h = hlcNext(d);
d.prepare('UPDATE metric_def SET label=?, unit=?, agg=?, section=?, hlc=? WHERE id=?')
.run(p.label, p.unit ?? null, p.agg || 'sum', p.section ?? null, h, exist.id);
touchOplog(d, 'metric_def', exist.uuid, 'upsert', h);
} else {
const h = hlcNext(d);
const uuid = uuidv7();
d.prepare('INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
.run(p.key, p.label, p.unit ?? null, p.agg || 'sum', p.section ?? null, uuid, h, getDeviceId(d));
touchOplog(d, 'metric_def', uuid, 'upsert', h);
}
});
return { item: p };
}
case 'metric_delete': {
const row = d.prepare('SELECT id FROM metric_def WHERE key=?').get(payload.key);
if (row) withTx(d, () => softDelete(d, 'metric_def', row.id));
return { ok: true };
}
// ---------- 学年学科成绩 ----------
case 'grades_overview': {
const DEL = 'deleted_at IS NULL';
const bands = d.prepare('SELECT * FROM grade_band WHERE ' + DEL + ' ORDER BY sort, id').all();
const bandOf = (score) => {
if (score == null) return null;
let hit = bands.find(b => score >= b.min && score < b.max);
if (!hit) hit = bands.find(b => score >= b.min && score <= b.max);
return hit ? { id: hit.id, label: hit.label, color: hit.color } : null;
};
const semesters = d.prepare('SELECT * FROM semester WHERE ' + DEL + ' ORDER BY term DESC').all();
const activeSemIds = new Set(semesters.map(s => s.id));
const courses = d.prepare('SELECT * FROM course_grade WHERE ' + DEL + ' ORDER BY id').all()
.filter(c => activeSemIds.has(c.semester_id));
for (const s of semesters) {
s.courses = courses.filter(c => c.semester_id === s.id).map(c => ({ ...c, band: bandOf(c.score) }));
const scored = s.courses.filter(c => c.score != null);
const creditsAll = s.courses.reduce((a, c) => a + (c.credit || 0), 0);
const creditsScored = scored.reduce((a, c) => a + (c.credit || 0), 0);
s.stats = {
credits: Math.round(creditsAll * 10) / 10,
courses: s.courses.length,
avg: creditsScored > 0 ? Math.round(scored.reduce((a, c) => a + c.score * c.credit, 0) / creditsScored * 100) / 100 : null
};
}
const allScored = courses.filter(c => c.score != null);
const creditsAll = allScored.reduce((a, c) => a + (c.credit || 0), 0);
const overallAvg = creditsAll > 0
? Math.round(allScored.reduce((a, c) => a + c.score * c.credit, 0) / creditsAll * 100) / 100
: null;
const tierDist = bands.map(b => ({
label: b.label, color: b.color,
count: allScored.filter(c => { const x = bandOf(c.score); return x && x.id === b.id; }).length
}));
const gpaTrend = [...semesters]
.filter(s => s.gpa != null)
.sort((a, b) => a.term.localeCompare(b.term))
.map(s => ({ term: s.term, gpa: s.gpa, avg: s.stats.avg }));
return {
semesters,
bands,
overall: {
avg: overallAvg,
credits: Math.round(courses.reduce((a, c) => a + (c.credit || 0), 0) * 10) / 10,
courses: courses.length,
gpa_trend: gpaTrend,
tier_dist: tierDist
}
};
}
case 'semester_upsert': {
const p = payload;
if (!p.term || !String(p.term).trim()) throw new Error('缺少学期名称');
const term = String(p.term).trim();
let id = p.id;
withTx(d, () => {
if (id) {
const dup = d.prepare('SELECT id FROM semester WHERE term=? AND deleted_at IS NULL AND id != ?').get(term, id);
if (dup) throw new Error('学期已存在:' + term);
const h = hlcNext(d);
d.prepare('UPDATE semester SET term=?, gpa=?, rank=?, total_students=?, hlc=? WHERE id=?')
.run(term, p.gpa ?? null, p.rank ?? null, p.total_students ?? null, h, id);
touchOplog(d, 'semester', getUuid(d, 'semester', id), 'upsert', h);
} else {
const dup = d.prepare('SELECT 1 FROM semester WHERE term=? AND deleted_at IS NULL').get(term);
if (dup) throw new Error('学期已存在:' + term);
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO semester (term, gpa, rank, total_students, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)')
.run(term, p.gpa ?? null, p.rank ?? null, p.total_students ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'semester', uuid, 'upsert', h);
}
});
return { item: { id } };
}
case 'semester_delete': {
withTx(d, () => {
softDelete(d, 'semester', payload.id);
// 级联墓碑该学期下全部课程,避免孤儿课程污染总览
const courses = d.prepare('SELECT id FROM course_grade WHERE semester_id=? AND deleted_at IS NULL').all(payload.id);
for (const c of courses) softDelete(d, 'course_grade', c.id);
});
return { ok: true };
}
case 'course_upsert': {
const p = payload;
if (!p.semester_id) throw new Error('缺少学期');
if (!p.course_name || !String(p.course_name).trim()) throw new Error('缺少课程名');
if (p.score != null && (p.score < 0 || p.score > 100)) throw new Error('分数需在 0-100 之间');
if (p.grade_point != null && p.grade_point < 0) throw new Error('绩点不能为负');
const name = String(p.course_name).trim();
let id = p.id;
withTx(d, () => {
if (id) {
const h = hlcNext(d);
d.prepare('UPDATE course_grade SET semester_id=?, course_name=?, credit=?, score=?, grade_point=?, hlc=? WHERE id=?')
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, h, id);
touchOplog(d, 'course_grade', getUuid(d, 'course_grade', id), 'upsert', h);
} else {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'course_grade', uuid, 'upsert', h);
}
});
return { item: { id } };
}
case 'course_delete': {
withTx(d, () => softDelete(d, 'course_grade', payload.id));
return { ok: true };
}
case 'band_upsert': {
const p = payload;
if (!p.label || !String(p.label).trim()) throw new Error('缺少等级名称');
if (p.min == null || p.max == null || p.min >= p.max) throw new Error('分数区间不合法(需 min < max)');
const label = String(p.label).trim();
let id = p.id;
withTx(d, () => {
if (id) {
const h = hlcNext(d);
d.prepare('UPDATE grade_band SET label=?, color=?, min=?, max=?, sort=?, hlc=? WHERE id=?')
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, h, id);
touchOplog(d, 'grade_band', getUuid(d, 'grade_band', id), 'upsert', h);
} else {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'grade_band', uuid, 'upsert', h);
}
});
return { item: { id } };
}
case 'band_delete': {
withTx(d, () => softDelete(d, 'grade_band', payload.id));
return { ok: true };
}
case 'attachment_add': {
const p = payload;
const buf = Buffer.from(p.data_b64 || '', 'base64');
const sha = crypto.createHash('sha256').update(buf).digest('hex');
const safe = p.file_name.replace(/[\\/:*?"<>|]/g, '_');
const rel = path.join('attachments', String(p.owner_id), crypto.randomUUID() + '_' + safe);
const abs = path.join(DATA_DIR, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, buf);
const uuid = uuidv7();
let item = null;
withTx(d, () => {
const h = hlcNext(d);
const r = d.prepare(
"INSERT INTO attachment (owner_type, owner_id, kind, file_name, mime, size, rel_path, sha256, uuid, hlc, origin) VALUES ('footprint',?,?,?,?,?,?,?,?,?,?)"
).run(p.owner_id, null, p.file_name, p.mime ?? null, buf.length, rel.split(path.sep).join('/'), sha, uuid, h, getDeviceId(d));
item = d.prepare('SELECT * FROM attachment WHERE id=?').get(Number(r.lastInsertRowid));
touchOplog(d, 'attachment', uuid, 'upsert', h);
});
return { item };
}
case 'attachment_list':
return {
items: d.prepare(
"SELECT * FROM attachment WHERE owner_type='footprint' AND owner_id=? AND deleted_at IS NULL ORDER BY id"
).all(payload.owner_id)
};
case 'attachment_delete': {
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
if (row) {
withTx(d, () => softDelete(d, 'attachment', payload.id));
const abs = path.join(DATA_DIR, row.rel_path);
try { fs.unlinkSync(abs); } catch {}
}
return { ok: true };
}
case 'attachment_read': {
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
if (!row) throw new Error('附件不存在');
const data = fs.readFileSync(path.join(DATA_DIR, row.rel_path));
return { data_b64: data.toString('base64'), mime: row.mime, file_name: row.file_name };
}
case 'view_list':
return { items: d.prepare('SELECT * FROM saved_view WHERE deleted_at IS NULL ORDER BY id').all().map(v => ({
...v, filter: JSON.parse(v.filter || '{}'), sort: JSON.parse(v.sort || '{}'), is_default: !!v.is_default
})) };
case 'view_upsert': {
const p = payload;
let id = p.id;
withTx(d, () => {
if (id) {
const h = hlcNext(d);
d.prepare("UPDATE saved_view SET name=?, filter=?, sort=?, is_default=?, hlc=?, updated_at=datetime('now','localtime') WHERE id=?")
.run(p.name, JSON.stringify(p.filter || {}), JSON.stringify(p.sort || {}), p.is_default ? 1 : 0, h, id);
touchOplog(d, 'saved_view', getUuid(d, 'saved_view', id), 'upsert', h);
} else {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO saved_view (name, filter, sort, is_default, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)')
.run(p.name, JSON.stringify(p.filter || {}), JSON.stringify(p.sort || {}), p.is_default ? 1 : 0, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'saved_view', uuid, 'upsert', h);
}
});
return { item: { id } };
}
case 'view_delete': {
const row = d.prepare('SELECT id FROM saved_view WHERE id=?').get(payload.id);
if (row) withTx(d, () => softDelete(d, 'saved_view', row.id));
return { ok: true };
}
default:
throw new Error('未知命令:' + cmd);
}
}
// 工厂闭合
return { handleCommand, getDb };
}
// ---------- 默认单例(开发桥接 / seed 兼容) ----------
let defaultCommands = null;
function getDefault() {
if (!defaultCommands) defaultCommands = createAppCommands(DB_PATH);
return defaultCommands;
}
export function getDb() {
return getDefault().getDb();
}
export function handleCommand(cmd, payload) {
return getDefault().handleCommand(cmd, payload);
}