Files
FootPrint/app/server/db.mjs
T
2026-10-17 09:57:43 +08:00

1347 lines
64 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');
}
// ---------- Wire 协议(各端互通:uuid 引用 FK,LWW 仲裁) ----------
function parseHlc(h) {
const m = /^([0-9]+):([0-9]+):(.+)$/.exec(String(h || ''));
return m ? { ms: Number(m[1]), cnt: Number(m[2]), dev: m[3] } : null;
}
function hlcNewer(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;
}
// 表 → [列, FK引用目标表]
const WIRE_TABLES = {
footprint: { fks: { type_id: 'footprint_type', parent_id: 'footprint' } },
footprint_type: { fks: {} },
taxonomy: { fks: {} },
dimension: { fks: {} },
grade_band: { fks: {} },
metric_def: { fks: {} },
semester: { fks: {} },
course_grade: { fks: { semester_id: 'semester' } },
saved_view: { fks: {} },
attachment: { fks: { owner_id: 'footprint' } },
footprint_dimension: { fks: { footprint_id: 'footprint', dimension_id: 'dimension' } }
};
// 老库迁移:补列 + 回填 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'], ['thumb_rel_path', 'TEXT'], ['remote_url', 'TEXT'], ['remote_thumb', '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);
}
// ---------- 全局固定内置 uuid(各端一致;Rust 端 ensure_builtin 使用同一映射) ----------
export const BUILTIN_UUID = {
'类型:学业': '018f0000-0000-7000-8000-000000000001',
'类型:竞赛': '018f0000-0000-7000-8000-000000000002',
'类型:科研': '018f0000-0000-7000-8000-000000000003',
'类型:实习实践': '018f0000-0000-7000-8000-000000000004',
'类型:学生工作': '018f0000-0000-7000-8000-000000000005',
'类型:志愿公益': '018f0000-0000-7000-8000-000000000006',
'类型:文体活动': '018f0000-0000-7000-8000-000000000007',
'类型:证书技能': '018f0000-0000-7000-8000-000000000008',
'类型:荣誉惩处': '018f0000-0000-7000-8000-000000000009',
'级别:国际级': '018f0000-0000-7000-8000-000000000011',
'级别:国家级': '018f0000-0000-7000-8000-000000000012',
'级别:省市级': '018f0000-0000-7000-8000-000000000013',
'级别:区县级': '018f0000-0000-7000-8000-000000000014',
'级别:校级': '018f0000-0000-7000-8000-000000000015',
'级别:院级': '018f0000-0000-7000-8000-000000000016',
'角色:主导': '018f0000-0000-7000-8000-000000000017',
'角色:核心': '018f0000-0000-7000-8000-000000000018',
'角色:参加': '018f0000-0000-7000-8000-000000000019',
'维度:学业深造': '018f0000-0000-7000-8000-000000000021',
'维度:职业发展': '018f0000-0000-7000-8000-000000000022',
'维度:综合素养': '018f0000-0000-7000-8000-000000000023',
'维度:兴趣生活': '018f0000-0000-7000-8000-000000000024',
'梯度:优秀': '018f0000-0000-7000-8000-000000000031',
'梯度:良好': '018f0000-0000-7000-8000-000000000032',
'梯度:中等': '018f0000-0000-7000-8000-000000000033',
'梯度:及格': '018f0000-0000-7000-8000-000000000034',
'梯度:不及格': '018f0000-0000-7000-8000-000000000035',
'指标:volunteer_hours': '018f0000-0000-7000-8000-000000000041'
};
// 内置 uuid 按名称对齐(存量库与新库统一;origin=builtin)
function alignBuiltinUuids(db) {
const fix = (table, key) => {
const uuid = BUILTIN_UUID[key];
if (!uuid) return;
db.prepare('UPDATE ' + table + ' SET uuid=? WHERE name=? AND uuid<>?').run(uuid, key.split(':')[1], uuid);
};
for (const key of Object.keys(BUILTIN_UUID)) {
const [kind, name] = key.split(':');
const uuid = BUILTIN_UUID[key];
if (kind === '类型') db.prepare('UPDATE footprint_type SET uuid=? WHERE name=? AND uuid<>?').run(uuid, name, uuid);
else if (kind === '级别') db.prepare("UPDATE taxonomy SET uuid=? WHERE kind='level' AND value=? AND uuid<>?").run(uuid, name, uuid);
else if (kind === '角色') db.prepare("UPDATE taxonomy SET uuid=? WHERE kind='role' AND value=? AND uuid<>?").run(uuid, name, uuid);
else if (kind === '维度') db.prepare('UPDATE dimension SET uuid=? WHERE name=? AND uuid<>?').run(uuid, name, uuid);
else if (kind === '梯度') db.prepare('UPDATE grade_band SET uuid=? WHERE label=? AND uuid<>?').run(uuid, name, uuid);
else if (kind === '指标') db.prepare('UPDATE metric_def SET uuid=? WHERE key=? AND uuid<>?').run(uuid, name, uuid);
}
}
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 seed = (table, cols, rows) => {
if (db.prepare('SELECT COUNT(*) AS c FROM ' + table + ' WHERE deleted_at IS NULL').get().c > 0) return;
const ins = db.prepare('INSERT INTO ' + table + ' (' + cols.join(',') + ') VALUES (' + cols.map(() => '?').join(',') + ')');
for (const row of rows) {
const [uuidKey, ...rest] = row;
ins.run(...rest, BUILTIN_UUID[uuidKey], '0:0:builtin', 'builtin');
}
};
const tpl = (o) => JSON.stringify(o);
seed('footprint_type', ['name', 'icon', 'color', 'sort', 'form_template', 'is_builtin', 'uuid', 'hlc', 'origin'], [
['类型:学业', '学业', 'graduation-cap', '#3056d3', 10, tpl([{ key: 'semester_label', label: '学期', widget: 'text' }]), 1],
['类型:竞赛', '竞赛', 'trophy', '#7c3aed', 20, tpl([{ key: 'my_work', label: '我的分工', widget: 'textarea', required: true }, { key: 'deliverable', label: '最终产品', widget: 'text' }, { key: 'team_members', label: '队伍成员', widget: 'text' }]), 1],
['类型:科研', '科研', 'flask-conical', '#0891b2', 30, tpl([{ key: 'authors', label: '作者排序', widget: 'text' }, { key: 'outcome', label: '成果形式', widget: 'select', options: ['论文', '专利', '软件著作权', '研究报告'] }]), 1],
['类型:实习实践', '实习实践', 'briefcase', '#0f766e', 40, tpl([{ key: 'position', label: '岗位', widget: 'text' }, { key: 'mentor', label: '带教人', widget: 'text' }]), 1],
['类型:学生工作', '学生工作', 'network', '#d97706', 50, tpl([{ key: 'duties', label: '职责', widget: 'textarea' }, { key: 'term', label: '任期', widget: 'text' }]), 1],
['类型:志愿公益', '志愿公益', 'heart-handshake', '#16a34a', 60, tpl([{ key: 'hours', label: '志愿时长', widget: 'number', unit: '小时', metric: 'volunteer_hours' }, { key: 'contact', label: '组织负责人', widget: 'text' }]), 1],
['类型:文体活动', '文体活动', 'palette', '#db2777', 70, tpl([{ key: 'participation', label: '参与形式', widget: 'text' }]), 1],
['类型:证书技能', '证书技能', 'file-badge', '#64748b', 80, tpl([{ key: 'cert_no', label: '证书编号', widget: 'text' }, { key: 'score', label: '成绩', widget: 'text' }]), 1],
['类型:荣誉惩处', '荣誉惩处', 'award', '#b45309', 90, tpl([{ key: 'issued_by', label: '授予 / 决定单位', widget: 'text' }]), 1]
]);
seed('taxonomy', ['kind', 'value', 'color', 'sort', 'is_builtin', 'uuid', 'hlc', 'origin'], [
['级别:国际级', 'level', '国际级', '#7c3aed', 10, 1],
['级别:国家级', 'level', '国家级', '#dc2626', 20, 1],
['级别:省市级', 'level', '省市级', '#ea580c', 30, 1],
['级别:区县级', 'level', '区县级', '#2563eb', 40, 1],
['级别:校级', 'level', '校级', '#16a34a', 50, 1],
['级别:院级', 'level', '院级', '#0891b2', 60, 1],
['角色:主导', 'role', '主导', '#3056d3', 10, 1],
['角色:核心', 'role', '核心', '#0891b2', 20, 1],
['角色:参加', 'role', '参加', '#64748b', 30, 1]
]); // 注意:seed 行参数顺序见 sort 列映射
// taxonomy 的 seed 行参数顺序与列序不同,单独处理(kind,value,color,sort)
if (db.prepare('SELECT COUNT(*) AS c FROM taxonomy WHERE deleted_at IS NULL').get().c === 0) {
const ins2 = db.prepare('INSERT INTO taxonomy (kind, value, color, sort, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,1,?,?,?)');
for (const [uuidKey, kind, value, color, sort] of [
['级别:国际级', 'level', '国际级', '#7c3aed', 10],
['级别:国家级', 'level', '国家级', '#dc2626', 20],
['级别:省市级', 'level', '省市级', '#ea580c', 30],
['级别:区县级', 'level', '区县级', '#2563eb', 40],
['级别:校级', 'level', '校级', '#16a34a', 50],
['级别:院级', 'level', '院级', '#0891b2', 60],
['角色:主导', 'role', '主导', '#3056d3', 10],
['角色:核心', 'role', '核心', '#0891b2', 20],
['角色:参加', 'role', '参加', '#64748b', 30]
]) ins2.run(kind, value, color, sort, BUILTIN_UUID[uuidKey], '0:0:builtin', 'builtin');
}
seed('dimension', ['name', 'color', 'sort', 'uuid', 'hlc', 'origin'], [
['维度:学业深造', '学业深造', '#3056d3', 10],
['维度:职业发展', '职业发展', '#0891b2', 20],
['维度:综合素养', '综合素养', '#16a34a', 30],
['维度:兴趣生活', '兴趣生活', '#db2777', 40]
]);
seed('grade_band', ['label', 'color', 'min', 'max', 'sort', 'uuid', 'hlc', 'origin'], [
['梯度:优秀', '优秀', '#16803c', 90, 100, 10],
['梯度:良好', '良好', '#0891b2', 80, 90, 20],
['梯度:中等', '中等', '#d97706', 70, 80, 30],
['梯度:及格', '及格', '#ea580c', 60, 70, 40],
['梯度:不及格', '不及格', '#dc2626', 0, 60, 50]
]);
seed('metric_def', ['key', 'label', 'unit', 'agg', 'section', 'uuid', 'hlc', 'origin'], [
['指标:volunteer_hours', 'volunteer_hours', '志愿时长', '小时', 'sum', '志愿服务']
]);
}
function openDb(dbPath) {
fs.mkdirSync(path.join(path.dirname(dbPath), 'attachments'), { recursive: true });
const d = new DatabaseSync(dbPath);
d.exec('PRAGMA foreign_keys = ON;');
preMigrateColumns(d);
d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8'));
migrateSync(d);
ensureBuiltin(d);
alignBuiltinUuids(d);
return d;
}
// 按数据库路径创建一套应用命令(多用户/服务器模式:每个用户一个独立档案库)
// ---------- 演示数据(demo 账号播种 / 本地 seed 共用) ----------
// 行格式: [类型, 标题, 角色, 级别, 获奖等级, 主办方, 开始, 结束, 状态, 描述, 结果, 标签, 维度, extras, metrics]
export const DEMO_ROWS = [
['竞赛', '2025 全国大学生数学建模竞赛', '主导', '国家级', '省市级二等奖', '中国工业与应用数学学会', '2025-09-05', '2025-09-08', 'finished',
'担任队长,负责模型建立与论文主笔;协调三人分工与时间安排', '重庆赛区二等奖', ['建模', '团队协作'], ['学业深造'],
{ my_work: '模型建立与论文主笔', deliverable: '参赛论文《基于优化模型的共享单车调度研究》', team_members: '张三、李四、王五' }, {}],
['竞赛', '第十六届蓝桥杯程序设计大赛', '参加', '省市级', '省市级二等奖', '工业和信息化部人才交流中心', '2025-04-12', '2025-06-20', 'finished',
'备战算法与数据结构,完成省赛', '重庆赛区 C/C++ B 组二等奖', ['算法'], ['职业发展', '学业深造'],
{ my_work: '独立参赛', deliverable: '省赛获奖证书' }, {}],
['竞赛', '全国大学生信息安全竞赛', '核心', '国家级', null, '教育部高等学校网络空间安全专业教学指导委员会', '2025-05-01', '2025-08-15', 'submitted',
'负责 Web 漏洞挖掘与利用脚本编写,参与作品报告撰写', '已提交作品,等待国赛评审', ['安全', 'CTF'], ['职业发展'],
{ my_work: 'Web 漏洞挖掘与利用脚本编写', deliverable: '参赛作品与报告' }, {}],
['志愿公益', '校图书馆迎新志愿服务', '参加', '校级', null, '校图书馆', '2025-09-01', '2025-09-05', 'finished',
'新生入馆引导与借阅咨询,共服务 5 天', '获馆方感谢信', ['志愿'], ['综合素养'],
{ hours: 24, contact: '李老师' }, { volunteer_hours: 24 }],
['志愿公益', '社区敬老院慰问活动', '参加', '区县级', null, '所在社区居委会', '2025-03-15', '2025-03-15', 'finished',
'陪伴老人、协助整理活动室', '', ['志愿', '公益'], ['综合素养'],
{ hours: 6 }, { volunteer_hours: 6 }],
['学生工作', '院学生会宣传部干事', '参加', '院级', null, '院学生会', '2024-09-01', '2025-06-30', 'finished',
'负责活动推文撰写与排版,全年产出推文 20 篇', '获优秀干事', ['宣传'], ['综合素养'],
{ duties: '推文撰写与排版、活动现场摄影', term: '2024.09 - 2025.06' }, {}],
['学生工作', '院学生会宣传部副部长', '主导', '院级', null, '院学生会', '2025-09-01', null, 'ongoing',
'统筹部门工作,管理 8 名干事,主导院级活动宣传方案', '', ['管理'], ['综合素养', '职业发展'],
{ duties: '部门统筹、活动宣传方案设计', term: '2025.09 - 至今' }, {}],
['荣誉惩处', '校级二等奖学金', '参加', '校级', null, '重庆城市科技学院', '2025-10-15', '2025-10-15', 'finished',
'2024-2025 学年综合测评前列', '二等奖学金', ['奖学金'], ['学业深造'],
{ issued_by: '重庆城市科技学院' }, {}],
['荣誉惩处', '优秀共青团员', '参加', '校级', null, '校团委', '2025-05-04', '2025-05-04', 'finished',
'五四表彰', '优秀共青团员', ['表彰'], ['综合素养'],
{ issued_by: '共青团重庆城市科技学院委员会' }, {}],
['证书技能', '大学英语六级', '参加', '国家级', null, '教育部教育考试院', '2025-06-14', '2025-08-20', 'finished',
'笔试 528 分', 'CET-6 528 分', ['英语'], ['学业深造', '职业发展'],
{ cert_no: '510001251200123', score: '528' }, {}],
['证书技能', '计算机技术与软件专业技术资格(中级)', '参加', '国家级', null, '工业和信息化部', '2025-11-08', null, 'ongoing',
'备考软件设计师', '', ['软考'], ['职业发展'],
{ score: '' }, {}],
['学业', '2024-2025 学年秋季学期', '参加', null, null, null, '2024-09-01', '2025-01-15', 'finished',
'学期绩点 3.72,专业排名 12/156', '', ['绩点'], ['学业深造'],
{ semester_label: '2024-2025-1' }, {}],
['学业', '2024-2025 学年春季学期', '参加', null, null, null, '2025-02-24', '2025-07-05', 'finished',
'学期绩点 3.85,专业排名 9/156', '', ['绩点'], ['学业深造'],
{ semester_label: '2024-2025-2' }, {}],
['文体活动', '校运动会开幕式方阵', '参加', '校级', null, '校体育运动委员会', '2025-04-18', '2025-04-20', 'finished',
'学院方阵队员,参与排练与开幕式表演', '', ['文体'], ['兴趣生活'],
{ participation: '方阵队员' }, {}],
['科研', '大学生创新创业训练计划项目', '主导', '校级', null, '校创新创业学院', '2025-03-01', null, 'ongoing',
'主持校级大创项目「基于流量特征的入侵检测研究」,带 2 名成员', '中期检查通过', ['科研', '安全'], ['学业深造', '职业发展'],
{ authors: '主持人', outcome: '研究报告' }, {}],
['实习实践', '某安全公司渗透测试实习生', '参加', null, null, '某网络安全科技有限公司', '2025-07-01', '2025-08-25', 'finished',
'参与企业授权渗透测试项目 3 个,编写测试报告', '获实习优秀评价', ['实习', '安全'], ['职业发展'],
{ position: '渗透测试实习生', mentor: '陈工' }, {}]
];
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点][] }
export const DEMO_SEMESTERS = [
{ term: '2023-2024-1', gpa: 3.42, rank: 38, total: 156, courses: [
['高等数学(上)', 5, 78, 3.0], ['大学英语(一)', 3, 85, 3.7], ['程序设计基础', 4, 91, 4.0],
['思想道德与法治', 2, 88, 3.9], ['体育(一)', 1, 82, 3.5]
]},
{ term: '2023-2024-2', gpa: 3.61, rank: 29, total: 156, courses: [
['高等数学(下)', 5, 84, 3.7], ['线性代数', 3, 76, 2.9], ['数据结构', 4, 90, 4.0],
['大学英语(二)', 3, 87, 3.8], ['中国近现代史纲要', 2, 84, 3.5], ['体育(二)', 1, 86, 3.8]
]},
{ term: '2024-2025-1', gpa: 3.78, rank: 18, total: 156, courses: [
['概率论与数理统计', 3, 88, 3.9], ['操作系统', 4, 92, 4.0], ['计算机网络', 4, 86, 3.8],
['数据库原理', 3, 94, 4.0], ['马克思主义基本原理', 2, 81, 3.3]
]},
{ term: '2024-2025-2', gpa: 3.85, rank: 12, total: 156, courses: [
['软件工程', 3, 90, 4.0], ['信息安全导论', 3, 95, 4.0], ['密码学基础', 3, 89, 3.9],
['Web 应用开发', 2, 96, 4.0], ['数字逻辑', 3, 79, 3.1], ['体育(四)', 1, 90, 4.0]
]}
];
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);
// 级联清理附件:墓碑(同步到其他端)+ 磁盘原图/缩略图
const atts = d.prepare("SELECT * FROM attachment WHERE owner_type='footprint' AND owner_id=? AND deleted_at IS NULL").all(payload.id);
for (const a of atts) {
softDelete(d, 'attachment', a.id);
for (const rel of [a.rel_path, a.thumb_rel_path]) {
if (rel) { try { fs.unlinkSync(path.join(DATA_DIR, rel)); } catch {} }
}
}
});
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);
// 服务端缩略图(上传时由客户端 canvas 生成 480px WebP;无则 NULL,读取时回退原图)
let thumbRel = null;
if (p.thumb_b64) {
const tb = Buffer.from(p.thumb_b64, 'base64');
if (tb.length) {
thumbRel = path.join('attachments', String(p.owner_id), crypto.randomUUID() + '_thumb.webp');
const tAbs = path.join(DATA_DIR, thumbRel);
fs.mkdirSync(path.dirname(tAbs), { recursive: true });
fs.writeFileSync(tAbs, tb);
thumbRel = thumbRel.split(path.sep).join('/');
}
}
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, thumb_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('/'), thumbRel, 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 {}
if (row.thumb_rel_path) { try { fs.unlinkSync(path.join(DATA_DIR, row.thumb_rel_path)); } 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 'attachment_thumb': {
// 缩略图读取:有服务端缩略图用之(20KB 级 WebP),老附件回退原图
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
if (!row) throw new Error('附件不存在');
const rel = row.thumb_rel_path || row.rel_path;
const data = fs.readFileSync(path.join(DATA_DIR, rel));
return { data_b64: data.toString('base64'), mime: row.thumb_rel_path ? 'image/webp' : row.mime, file_name: row.file_name };
}
case 'attachment_set_remote': {
// 图床(Lsky/COS)直传成功后回填远程地址;同步到所有端
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
if (!row) throw new Error('附件不存在');
withTx(d, () => {
const h = hlcNext(d);
d.prepare('UPDATE attachment SET remote_url=COALESCE(?, remote_url), remote_thumb=COALESCE(?, remote_thumb), hlc=? WHERE id=?')
.run(payload.remote_url ?? null, payload.remote_thumb ?? null, h, row.id);
touchOplog(d, 'attachment', row.uuid, 'upsert', h);
});
return { ok: true };
}
case 'attachment_set_thumb': {
// 客户端上传原始二进制后回填缩略图(本地元数据,不进 oplog)
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
if (!row) throw new Error('附件不存在');
const tb = Buffer.from(payload.thumb_b64 || '', 'base64');
if (!tb.length) throw new Error('缩略图为空');
const rel = path.join('attachments', String(row.owner_id), crypto.randomUUID() + '_thumb.webp');
const abs = path.join(DATA_DIR, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, tb);
d.prepare('UPDATE attachment SET thumb_rel_path=? WHERE id=?')
.run(rel.split(path.sep).join('/'), row.id);
return { ok: true };
}
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 };
}
case 'demo_seed': {
// 演示数据播种(仅当档案库为空时;走真实命令层,附带同步元数据与 oplog)
const fpCnt = d.prepare('SELECT COUNT(*) AS c FROM footprint WHERE deleted_at IS NULL').get().c;
const semCnt = d.prepare('SELECT COUNT(*) AS c FROM semester WHERE deleted_at IS NULL').get().c;
if (fpCnt > 0 || semCnt > 0) return { seeded: false, footprints: fpCnt, semesters: semCnt };
const typeIds = Object.fromEntries(d.prepare('SELECT id, name FROM footprint_type').all().map((r) => [r.name, r.id]));
const dimIds = Object.fromEntries(d.prepare('SELECT id, name FROM dimension').all().map((r) => [r.name, r.id]));
let n = 0;
for (const [t, title, role, level, grade, organizer, start, end, status, desc, result, tags, dimNames, extras, metrics] of DEMO_ROWS) {
const typeId = typeIds[t];
if (!typeId) continue;
handleCommand('footprint_upsert', { item: {
type_id: typeId, title, role, level, award_grade: grade, organizer,
start_date: start, end_date: end, status, description: desc, result,
tags, metrics, extras,
dimension_ids: (dimNames || []).map((nm) => dimIds[nm]).filter(Boolean),
}});
n++;
}
let sems = 0;
for (const s of DEMO_SEMESTERS) {
const r = handleCommand('semester_upsert', { term: s.term, gpa: s.gpa, rank: s.rank, total_students: s.total });
const sid = r.item.id;
for (const [name, credit, score, gp] of s.courses) {
handleCommand('course_upsert', { semester_id: sid, course_name: name, credit, score, grade_point: gp });
}
sems++;
}
return { seeded: true, footprints: n, semesters: sems };
}
case 'sync_apply_wire':
return applyWireBatch(payload.changes || []);
case 'sync_pull_wire':
return pullWire(payload.cursor, payload.limit);
default:
throw new Error('未知命令:' + cmd);
}
}
// 工厂闭合
// ---------- Wire 协议:出站序列化 / 入站应用 ----------
const SKIP_COLS = ['id', 'uuid', 'hlc', 'origin', 'created_at', 'updated_at', 'search_text'];
function uuidOf(table, id) {
if (id == null) return null;
const r = db.prepare('SELECT uuid FROM ' + table + ' WHERE id=?').get(id);
return r ? r.uuid : null;
}
function idOfUuid(table, uuid, mayCreate) {
if (uuid == null) return null;
const r = db.prepare('SELECT id FROM ' + table + ' WHERE uuid=?').get(uuid);
if (r) return r.id;
if (!mayCreate) return null;
const h2 = hlcNext(db);
if (table === 'footprint_type') {
const rr = db.prepare("INSERT INTO footprint_type (name, icon, color, sort, form_template, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,?,0,?,?,?)")
.run('同步类型 ' + uuid.slice(0, 8), 'tag', '#64748b', 500, '[]', uuid, h2, 'sync');
return Number(rr.lastInsertRowid);
}
if (table === 'dimension') {
const rr = db.prepare("INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)")
.run('同步维度 ' + uuid.slice(0, 8), '#64748b', 500, uuid, h2, 'sync');
return Number(rr.lastInsertRowid);
}
if (table === 'grade_band') {
const rr = db.prepare("INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)")
.run('同步梯度 ' + uuid.slice(0, 8), '#64748b', 60, 70, 500, uuid, h2, 'sync');
return Number(rr.lastInsertRowid);
}
if (table === 'metric_def') {
const rr = db.prepare("INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)")
.run('sync_' + uuid.slice(0, 8), '同步指标 ' + uuid.slice(0, 8), null, 'sum', null, uuid, h2, 'sync');
return Number(rr.lastInsertRowid);
}
if (table === 'taxonomy') {
const rr = db.prepare("INSERT INTO taxonomy (kind, value, color, sort, uuid, hlc, origin) VALUES (?,?,?,0,?,?,?,?)")
.run('level', '同步条目 ' + uuid.slice(0, 8), '#64748b', uuid, h2, 'sync');
return Number(rr.lastInsertRowid);
}
return null;
}
function serializeRowOf(table) {
const spec = WIRE_TABLES[table];
return (row) => {
const out = { uuid: row.uuid, hlc: row.hlc, deleted_at: row.deleted_at || null };
for (const k of Object.keys(row)) {
if (SKIP_COLS.includes(k)) continue;
if (spec.fks[k] !== undefined) { out[k.replace('_id', '_uuid')] = uuidOf(spec.fks[k], row[k]); continue; }
out[k] = row[k];
}
return out;
};
}
function softDeleteById(table, id) {
db.prepare('UPDATE ' + table + " SET deleted_at=datetime('now','localtime') WHERE id=?").run(id);
}
function applyWire(change) {
const spec = WIRE_TABLES[change.table];
if (!spec) return { accepted: 0, stale: 0, invalid: 1 };
const rowUuid = String(change.row_uuid || '');
const incoming = parseHlc(change.hlc);
if (!incoming || (change.op !== 'upsert' && change.op !== 'delete')) return { accepted: 0, stale: 0, invalid: 1 };
const existing = db.prepare('SELECT id, hlc FROM ' + change.table + ' WHERE uuid=?').get(rowUuid);
if (existing) {
const e = parseHlc(existing.hlc);
if (e && !hlcNewer(incoming, e)) return { accepted: 0, stale: 1, invalid: 0 };
}
let res;
try {
res = withTx(db, () => {
if (change.op === 'delete') {
if (existing) softDeleteById(change.table, existing.id);
touchOplog(db, change.table, rowUuid, 'delete', change.hlc);
return 1;
}
const data = change.data || {};
const fv = {};
for (const fk of Object.keys(spec.fks)) {
const uuidVal = data[fk.replace('_id', '_uuid')];
fv[fk] = idOfUuid(spec.fks[fk], uuidVal, true);
}
const cols = [];
const vals = [];
for (const k of Object.keys(data)) {
if (['uuid', 'hlc', 'deleted_at'].includes(k)) continue;
if (k.endsWith('_uuid') && spec.fks[k.replace('_uuid', '_id')] !== undefined) continue;
cols.push(k);
vals.push(data[k]);
}
for (const fk of Object.keys(fv)) { cols.push(fk); vals.push(fv[fk]); }
cols.push('uuid', 'hlc', 'origin');
vals.push(rowUuid, change.hlc, 'sync');
if (change.table === 'footprint') {
cols.push('search_text');
vals.push(buildSearchText(String(data.title || ''), String(data.description || ''), String(data.result || ''), data.tags || [], data.extras || {}));
}
if (existing) {
const setSql = cols.map(c => c + '=?').join(', ');
db.prepare('UPDATE ' + change.table + ' SET ' + setSql + ' WHERE id=?').run(...vals, existing.id);
} else {
db.prepare('INSERT INTO ' + change.table + ' (' + cols.join(',') + ') VALUES (' + cols.map(() => '?').join(',') + ')').run(...vals);
}
touchOplog(db, change.table, rowUuid, 'upsert', change.hlc);
return 1;
});
} catch (ex) {
throw ex;
}
return res === 1 ? { accepted: 1, stale: 0, invalid: 0 } : { accepted: 0, stale: 0, invalid: 0 };
}
function applyWireBatch(changes) {
let accepted = 0; let stale = 0; let invalid = 0;
for (const ch of (changes || [])) {
const r = applyWire(ch);
accepted += r.accepted; stale += r.stale; invalid += r.invalid;
}
return { accepted, stale, invalid };
}
function pullWire(cursorRaw, limitRaw) {
const cursor = Math.max(0, Number(cursorRaw) || 0);
const limit = Math.min(500, Math.max(1, Number(limitRaw) || 200));
const opRows = db.prepare('SELECT id, table_name, row_uuid, op, hlc FROM sync_oplog WHERE id>? ORDER BY id LIMIT ?').all(cursor, limit);
const changes = [];
for (const op of opRows) {
if (!WIRE_TABLES[op.table_name]) continue;
if (op.op === 'delete') {
changes.push({ seq: op.id, table: op.table_name, row_uuid: op.row_uuid, op: 'delete', hlc: op.hlc, data: null });
continue;
}
const row = db.prepare('SELECT * FROM ' + op.table_name + ' WHERE uuid=?').get(op.row_uuid);
if (!row) continue;
changes.push({ seq: op.id, table: op.table_name, row_uuid: op.row_uuid, op: 'upsert', hlc: op.hlc, data: serializeRowOf(op.table_name)(row) });
}
const maxSeq = db.prepare('SELECT COALESCE(MAX(id),0) AS m FROM sync_oplog').get().m;
const hasMore = opRows.length === limit;
return {
changes,
cursor: hasMore && opRows.length ? opRows[opRows.length - 1].id : Math.max(cursor, maxSeq),
has_more: hasMore
};
}
return { handleCommand, getDb, pullWire, applyWireBatch };
}
// ---------- 默认单例(开发桥接 / 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);
}