// 数据层: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']]); } 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 '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); }