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