Files
FootPrint/app/server/db.mjs
T
root a668647f35 fix(audit): profile_upsert top-key merge, scoring badge recordIds, zc item contract
- profile_upsert now merges top-level keys (info/resume/zongce are independent sub-documents); previously any page saving its own slice silently wiped the other two (profile save lost zongce rules, assessment save lost resume, resume save lost zongce)
- ZcItem gains recordIds: aggregated cap items (wcap/ccap/kcap/gcap/hcap) expose the records they aggregate, so the evidence gallery scored/unscored badge works (matchedIds was always empty before)
- add regression coverage notes; www rebuilt
2026-10-17 19:13:58 +08:00

1493 lines
76 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';
import { loadCert, CERT_BY_TITLE } from './demo-certs.mjs';
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('footprint_type', [['profile', 'TEXT']]);
addCols('attachment', [['sha256', 'TEXT'], ['thumb_rel_path', 'TEXT'], ['remote_url', 'TEXT'], ['remote_thumb', 'TEXT'], ['remote_key', 'TEXT'], ['remote_thumb_key', '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', 'gp', 'gp_top', 'uuid', 'hlc', 'origin'], [
['梯度:优秀', '优秀', '#16803c', 90, 101, 10, 4.0, 5.0],
['梯度:良好', '良好', '#0891b2', 80, 90, 20, 3.0, 3.9],
['梯度:中等', '中等', '#d97706', 70, 80, 30, 2.0, 2.9],
['梯度:及格', '及格', '#ea580c', 60, 70, 40, 1.0, 1.9],
['梯度:不及格', '不及格', '#dc2626', 0, 60, 50, 0, 0]
]);
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'));
// 个人信息表兜底(确保任何挂载形态下都存在)
d.exec("CREATE TABLE IF NOT EXISTS profile (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT, content TEXT NOT NULL DEFAULT '{}', deleted_at TEXT, origin TEXT, hlc TEXT)");
// 绩点公式:梯度带携带每档绩点(gp 起)与满档绩点(gp_top,档内线性折算),各校规则不同可自行修改
const hasGp = d.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('grade_band') WHERE name='gp'").get().c;
if (Number(hasGp) === 0) d.exec("ALTER TABLE grade_band ADD COLUMN gp REAL");
const hasGpTop = d.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('grade_band') WHERE name='gp_top'").get().c;
if (Number(hasGpTop) === 0) d.exec("ALTER TABLE grade_band ADD COLUMN gp_top REAL");
// 课程类别(权重系数)
const hasCat = d.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('course_grade') WHERE name='category'").get().c;
if (Number(hasCat) === 0) d.exec("ALTER TABLE course_grade ADD COLUMN category TEXT");
// 应用设置(绩点权重等)
d.exec("CREATE TABLE IF NOT EXISTS app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
migrateSync(d);
ensureBuiltin(d);
alignBuiltinUuids(d);
return d;
}
// 按数据库路径创建一套应用命令(多用户/服务器模式:每个用户一个独立档案库)
// ---------- 演示数据(demo 账号播种 / 本地 seed 共用) ----------
// 行格式: [类型, 标题, 角色, 级别, 获奖等级, 主办方, 开始, 结束, 状态, 描述, 结果, 标签, 维度, extras, metrics]
export const DEMO_ROWS = [
// ---- 大一(2023-2024 学年):学生工作 / 志愿 / 文体 / 证书 ----
['学生工作', '院学生会宣传部干事', '干事', '院级', null, '院学生会', '2023-09-01', '2024-06-30', 'finished',
'负责活动推文撰写与海报设计,全年产出推文 20 篇', '获优秀干事', ['宣传'], ['综合素养'],
{ duties: '推文撰写、海报设计、活动现场摄影', term: '2023.09 - 2024.06' }, {}],
['志愿公益', '校迎新志愿服务', '参加', '校级', null, '校青年志愿者联合会', '2023-09-01', '2023-09-03', 'finished',
'新生入馆引导与借阅咨询', '获馆方感谢信', ['志愿'], ['综合素养'],
{ hours: 24 }, { volunteer_hours: 24 }],
['志愿公益', '无偿献血', '参加', null, null, '市中心血站', '2024-03-15', '2024-03-15', 'finished',
'全血献血 400ml', '', ['公益'], ['综合素养'],
{ hours: 2 }, { volunteer_hours: 2 }],
['文体活动', '校运动会男子3000米', '参加', '校级', '三等奖', '校体育运动委员会', '2024-04-18', '2024-04-20', 'finished',
'坚持训练一个月,决赛第三名', '', ['文体'], ['兴趣生活'],
{ participation: '男子3000米决赛' }, {}],
['证书技能', '大学英语四级(CET-4)', '参加', '国家级', null, '教育部教育考试院', '2024-06-15', '2024-08-20', 'finished',
'笔试 562 分', 'CET-4 562 分', ['英语'], ['学业深造', '职业发展'],
{ score: '562' }, {}],
// ---- 大二(2024-2025 学年):竞赛 / 科研 / 荣誉 / 证书 ----
['竞赛', '全国大学生数学建模竞赛', '主导', '省市级', '一等奖', '中国工业与应用数学学会', '2024-09-05', '2024-09-08', 'finished',
'担任队长,负责模型建立与论文主笔;协调三人分工', '重庆赛区一等奖', ['建模', '团队协作'], ['学业深造'],
{ my_work: '模型建立与论文主笔', team_members: '张三、李四' }, {}],
['竞赛', '第十六届蓝桥杯程序设计大赛', '参加', '省市级', '二等奖', '工业和信息化部人才交流中心', '2025-04-12', '2025-06-01', 'finished',
'备战算法与数据结构,完成省赛', '重庆赛区 C/C++ B 组二等奖', ['算法'], ['职业发展'],
{ my_work: '独立参赛' }, {}],
['竞赛', '全国大学生信息安全竞赛', '核心', '国家级', '三等奖', '网络空间安全专业教指委', '2025-05-01', '2025-08-15', 'finished',
'负责 Web 漏洞挖掘与利用脚本编写', '全国总决赛三等奖', ['安全', 'CTF'], ['职业发展'],
{ my_work: 'Web 漏洞挖掘与利用脚本' }, {}],
['科研', '大学生创新创业训练计划项目', '主导', '校级', null, '校创新创业学院', '2025-03-01', null, 'ongoing',
'主持校级大创项目「基于流量特征的入侵检测研究」,带 2 名成员', '中期检查通过', ['科研', '安全'], ['学业深造'],
{ authors: '主持人', outcome: '研究报告' }, {}],
['文体活动', '校园十佳歌手大赛', '参加', '校级', '二等奖', '校团委', '2024-12-01', '2024-12-15', 'finished',
'初赛复赛决赛三轮', '校园十佳歌手', ['文体'], ['兴趣生活'],
{}, {}],
['志愿公益', '社区敬老院慰问活动', '参加', '区县级', null, '所在社区居委会', '2025-03-15', '2025-03-15', 'finished',
'陪伴老人、协助整理活动室', '', ['志愿', '公益'], ['综合素养'],
{ hours: 6 }, { volunteer_hours: 6 }],
['学生工作', '校学生会学习部干事', '干事', '校级', null, '校学生会', '2024-09-01', null, 'ongoing',
'负责学风建设活动组织与学业帮扶配对', '', ['学风'], ['综合素养'],
{ duties: '学风活动组织、学业帮扶配对', term: '2024.09 - 至今' }, {}],
['荣誉惩处', '优秀共青团员', '参加', '校级', null, '校团委', '2025-05-04', '2025-05-04', 'finished',
'五四表彰', '优秀共青团员', ['表彰'], ['综合素养'],
{}, {}],
['荣誉惩处', '综合测评奖学金', '参加', '校级', null, '云川理工学院', '2025-10-15', '2025-10-15', 'finished',
'2024-2025 学年综合测评前列(细则规定综测奖学金不加分,用于验证排除逻辑)', '二等奖学金', ['奖学金'], ['学业深造'],
{}, {}],
['证书技能', '全国计算机等级考试二级', '参加', '国家级', null, '教育部考试中心', '2025-03-22', '2025-05-10', 'finished',
'MS Office 高级应用', '二级合格证书', ['计算机'], ['职业发展'],
{}, {}],
['证书技能', '机动车驾驶证(C1)', '参加', null, null, '市公安局交通管理局', '2024-07-01', '2024-08-10', 'finished',
'科目一到科目四一次通过', 'C1 驾驶证', ['驾照'], ['生活技能'],
{}, {}],
['证书技能', '计算机技术与软件专业技术资格(软件设计师)', '参加', '国家级', null, '工业和信息化部', '2025-11-08', null, 'ongoing',
'备考软件设计师(中级)', '', ['软考'], ['职业发展'],
{}, {}],
['竞赛', 'ACM-ICPC 国际大学生程序设计竞赛', '核心', '国家级', '银奖', '中国大学生程序设计竞赛组委会', '2025-10-18', '2025-10-19', 'finished',
'赛区内团队排名前 5%,与队友分工负责图论与动态规划、数据结构与搜索方向', '亚洲区域赛银奖', ['算法', '团队协作'], ['学业深造'],
{ my_work: '图论建模与状态转移设计', team_members: '王五、赵六' }, {}],
['竞赛', '中国大学生计算机设计大赛', '主导', '国家级', '二等奖', '中国大学生计算机设计大赛组织委员会', '2025-05-10', '2025-07-30', 'finished',
'作品《基于流量特征的入侵检测系统》,负责整体方案设计与检测算法实现', '全国总决赛二等奖', ['计算机', '安全'], ['学业深造'],
{ my_work: '方案设计、检测算法实现', team_members: '张三、李四' }, {}],
['竞赛', '第三届"强网杯"全国大学生网络安全挑战赛', '核心', '国家级', '二等奖', '战略支援部队信息工程大学', '2024-11-09', '2024-11-11', 'finished',
'CTF 攻防模式,负责 Web 渗透与 RSA 共模攻击类密码题', '半决赛二等奖', ['CTF', '密码', '安全'], ['职业发展'],
{ my_work: 'Web 渗透与密码工具编写', team_members: '张三、王五' }, {}],
['竞赛', '"挑战杯"全国大学生课外学术科技作品竞赛(省赛)', '核心', '省市级', '三等奖', '共青团重庆市委员会', '2025-03-15', '2025-06-20', 'finished',
'作品「基于流量特征的入侵检测研究」,负责研究报告撰写与答辩', '省赛三等奖', ['科研', '学术'], ['学业深造'],
{ my_work: '研究报告撰写' }, {}],
['竞赛', '全国大学生英语竞赛', '参加', '省市级', '三等奖', '高等学校大学外语教学研究会', '2024-04-14', '2024-04-14', 'finished',
'C 类笔试,词汇与阅读优势明显', 'C类省级三等奖', ['英语'], ['学业深造'],
{ score: '88' }, {}],
['竞赛', '院"新生杯"程序设计竞赛', '参加', '二级学院', '一等奖', '计算机网络学院', '2023-11-18', '2023-11-18', 'finished',
'大一入门竞赛,C 语言实现图书管理系统', '院级一等奖', ['编程'], ['学业深造'],
{}, {}],
['竞赛', '全国大学生密码技术竞赛', '参加', '国家级', null, '中国密码学会', '2025-11-20', null, 'ongoing',
'组队备战国赛(SM9 聚群签名方向)', '', ['密码', '学术'], ['学业深造'],
{}, {}],
['实习实践', '某安全公司渗透测试实习生', '参加', null, null, '某网络安全科技有限公司', '2025-07-01', '2025-08-25', 'finished',
'参与企业授权渗透测试项目 3 个,编写测试报告', '获实习优秀评价', ['实习', '安全'], ['职业发展'],
{ position: '渗透测试实习生', mentor: '陈工' }, {}]
];
// 演示简历配置(仅 demo 账号有展示内容;新用户简历为空白)
const DEMO_RESUME = {
version: 2, template: 'tech', accent: '#3056d3', fontScale: 1, density: 'normal', onePage: false,
sections: [
{ key: 'info', title: '基本信息', enabled: true, kind: 'info' },
{ key: 'education', title: '教育经历', enabled: true, kind: 'edu' },
{ key: 'skills', title: '核心技能', enabled: true, kind: 'text', text: '程序设计:熟悉 Python 与 C/C++,掌握常用数据结构与算法,能独立完成中小型工具与脚本开发。\n网络安全:熟悉常见 Web 漏洞原理(SQL 注入、XSS、CSRF 等)与防护方案,掌握 Burp Suite、Nmap、Wireshark 等常用工具。\n网络基础:熟悉 TCP/IP 协议栈与交换路由基础配置,理解 VLAN、NAT、ACL 等核心技术。\n运维基础:熟悉 Linux 常用操作与 Shell 脚本,了解 Docker 容器化部署与 Nginx 服务配置。' },
{ key: 'projects', title: '项目经历', enabled: true, kind: 'list', types: ['竞赛', '科研', '实习实践'] },
{ key: 'certs', title: '证书与荣誉', enabled: true, kind: 'list', types: ['荣誉惩处', '证书技能'] },
{ key: 'campus', title: '在校经历', enabled: true, kind: 'list', types: ['学生工作', '志愿公益'] },
{ key: 'activity', title: '文体活动', enabled: false, kind: 'list', types: ['文体活动'] },
{ key: 'other', title: '其他经历', enabled: false, kind: 'list' },
{ key: 'self', title: '自我评价', enabled: true, kind: 'text', text: '性格沉稳踏实,做事有计划性,习惯提前预判风险并准备预案;学习能力强,能快速上手新技术与工具;责任心强,注重细节与条理,凡事有始有终,能独立推进任务并在压力下保持稳定输出。' }
]
};
// 演示个人档案(虚构人设:云川理工学院 · 信息安全 · 林晓阳)
export const DEMO_PROFILE = {
info: {
name: '林晓阳', gender: '男', birth: '2004-06', nation: '汉族', native: '四川成都',
political: '共青团员', school: '云川理工学院', college: '信息工程学院', major: '信息安全',
className: '信安2201', studentId: '2023110245', degree: '本科', eduLength: '四年',
eduStart: '2023-09', eduEnd: '2027-06',
majorCourses: '计算机网络、数据库技术、操作系统、信息安全导论、密码学基础、网络安全、云计算与虚拟化技术',
phone: '138****8000', email: 'linxy.demo@example.com', homepage: 'https://blog.example.com/linxy',
photoUrl: '', selfIntro: '性格沉稳踏实,做事有计划性,习惯提前预判风险并准备预案;对网络安全方向有持续热情,动手能力强,能独立完成从方案设计到落地验证的完整流程;责任心强,注重细节与条理,是可靠的技术执行者。'
},
resume: DEMO_RESUME
};
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点, 类别][] }
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点, 类别][] }
// 演示成绩与成绩梯度公式一致:分段线性 gp(90→4.0 至 100→5.0,80→3.0 至 89→3.9 …),
// 学期 GPA 为分类加权(必修 1.2 / 专业选修 1.1),与 grades_overview 的综合算法结果吻合。
export const DEMO_SEMESTERS = [
{ term: '2023-2024-1', gpa: 4.13, rank: 48, total: 156, courses: [
['高等数学(上)', 5, 78, 2.8, '必修课'], ['大学英语(一)', 3, 85, 3.5, '必修课'], ['程序设计基础', 4, 91, 4.1, '必修课'],
['思想道德与法治', 2, 88, 3.8, '必修课'], ['体育(一)', 1, 82, 3.2, '必修课']
]},
{ term: '2023-2024-2', gpa: 4.15, rank: 36, total: 156, courses: [
['高等数学(下)', 5, 84, 3.4, '必修课'], ['线性代数', 3, 76, 2.6, '必修课'], ['数据结构', 4, 90, 4.0, '必修课'],
['大学英语(二)', 3, 87, 3.7, '必修课'], ['中国近现代史纲要', 2, 84, 3.4, '必修课'], ['体育(二)', 1, 86, 3.6, '必修课']
]},
{ term: '2024-2025-1', gpa: 4.63, rank: 11, total: 156, courses: [
['概率论与数理统计', 3, 88, 3.8, '必修课'], ['操作系统', 4, 92, 4.18, '必修课'], ['计算机网络', 4, 86, 3.6, '必修课'],
['数据库原理', 3, 94, 4.36, '必修课'], ['马克思主义基本原理', 2, 81, 3.1, '必修课'], ['体育(三)', 1, 88, 3.8, '必修课']
]},
{ term: '2024-2025-2', gpa: 4.65, rank: 7, total: 156, courses: [
['软件工程', 3, 90, 4.0, '必修课'], ['信息安全导论', 3, 95, 4.45, '必修课'], ['密码学基础', 3, 89, 3.9, '必修课'],
['Web 应用开发', 2, 96, 4.55, '专业选修课'], ['数字逻辑', 3, 79, 2.9, '必修课'], ['体育(四)', 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 || '[]'), profile: t.profile ?? null, 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=?, profile=?, closed=?, hlc=? WHERE id=?')
.run(p.name, p.icon, p.color, p.sort, tpl, p.profile ?? null, 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, profile, closed, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
.run(p.name, p.icon, p.color, p.sort, tpl, p.profile ?? null, 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, gp: hit.gp ?? null, gpTop: hit.gp_top ?? null } : 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 }));
let weights = { 必修课: 1.2, 专业选修课: 1.1, 人文素质选修课: 1.0, 其他: 1.0 };
try {
const wr = d.prepare("SELECT value FROM app_settings WHERE key='gpa_formula'").get();
if (wr) weights = { ...weights, ...JSON.parse(wr.value).weights };
} catch {}
return {
semesters,
bands,
weights,
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=?, category=?, hlc=? WHERE id=?')
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, p.category ?? 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, category, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?)')
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, p.category ?? 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=?, gp=?, gp_top=?, hlc=? WHERE id=?')
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, p.gp ?? null, p.gpTop ?? null, 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, gp, gp_top, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?,?)')
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, p.gp ?? null, p.gpTop ?? null, 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), remote_key=COALESCE(?, remote_key), remote_thumb_key=COALESCE(?, remote_thumb_key), hlc=? WHERE id=?')
.run(payload.remote_url ?? null, payload.remote_thumb ?? null, payload.remote_key ?? null, payload.remote_thumb_key ?? 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 'gpa_settings_get': {
const row = d.prepare("SELECT value FROM app_settings WHERE key='gpa_formula'").get();
let weights = { 必修课: 1.2, 专业选修课: 1.1, 人文素质选修课: 1.0, 其他: 1.0 };
if (row) { try { weights = { ...weights, ...JSON.parse(row.value).weights }; } catch {} }
return { weights };
}
case 'gpa_settings_upsert': {
const p = payload;
const weights = p.weights ?? {};
d.prepare("INSERT INTO app_settings (key, value) VALUES ('gpa_formula', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
.run(JSON.stringify({ weights }));
return { ok: true, weights };
}
case 'profile_get': {
// 个人信息(单行 JSON):供个人信息页与简历生成使用
const row = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get();
let content = {};
if (row) { try { content = JSON.parse(row.content || '{}'); } catch { content = {}; } }
return { content };
}
case 'profile_upsert': {
const p = payload;
// 单行语义:更新现有行或插入首行
// 顶层 key 合并:info / resume / zongce 是相互独立的子文档,各页面只提交自己负责的
// key(ProfilePage 只有 info、AssessmentPage 有 info+zongce…),整行替换会让其他
// key 神秘消失;按顶层 key 合并后互不影响,key 内部整体替换(仍是全量提交该子文档)。
const existingRow = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get();
let prev = {};
try { prev = JSON.parse(existingRow?.content || '{}'); } catch { prev = {}; }
const merged = { ...prev, ...(p.content ?? {}) };
const content = JSON.stringify(merged);
const existing = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get();
withTx(d, () => {
const h = hlcNext(d);
if (existing) {
d.prepare('UPDATE profile SET content=?, hlc=? WHERE id=?').run(content, h, existing.id);
touchOplog(d, 'profile', existing.uuid, 'upsert', h);
} else {
const uuid = uuidv7();
d.prepare('INSERT INTO profile (content, uuid, hlc, origin) VALUES (?,?,?,?)')
.run(content, uuid, h, getDeviceId(d));
touchOplog(d, 'profile', uuid, 'upsert', h);
}
});
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 };
// 个人档案(简历与个人信息页展示用)
handleCommand('profile_upsert', { content: DEMO_PROFILE });
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;
const fr = 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++;
// 演示附件:按标题匹配对应证书图片(可预览、可进综测证明材料)
const certKey = CERT_BY_TITLE.find((p2) => p2[0].test(String(title)))?.[1];
if (certKey && status === 'finished') {
const cert = loadCert(certKey);
if (cert) {
try {
const att = handleCommand('attachment_add', { owner_id: fr.id, ...cert });
console.log('[demo-seed] 证书附件已挂到「' + title + '」:', att.item?.id || att.item?.uuid || '?');
} catch (e) { console.log('[demo-seed] 证书附件失败 ' + title + ':', e.message); }
}
}
}
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, category] of s.courses) {
handleCommand('course_upsert', { semester_id: sid, course_name: name, credit, score, grade_point: gp, category });
}
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, close: () => { try { db.close(); } catch {} } };
}
// ---------- 默认单例(开发桥接 / 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);
}