1393 lines
67 KiB
JavaScript
1393 lines
67 KiB
JavaScript
// 数据层:dev-bridge 与 seed 共用
|
||
// v2:多端同步地基 —— uuid v7 / 软删除墓碑 / HLC / 变更日志(自建服务方案)
|
||
import { DatabaseSync } from 'node:sqlite';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import crypto from 'node:crypto';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
export const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', '.data');
|
||
export const ATT_DIR = path.join(DATA_DIR, 'attachments');
|
||
export const DB_PATH = path.join(DATA_DIR, 'footprints.db');
|
||
|
||
function getMeta(d, key) {
|
||
const r = d.prepare('SELECT value FROM sync_state WHERE key=?').get(key);
|
||
return r ? r.value : null;
|
||
}
|
||
|
||
function setMeta(d, key, value) {
|
||
d.prepare("INSERT INTO sync_state (key, value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
|
||
.run(key, String(value));
|
||
}
|
||
|
||
function getDeviceId(d) {
|
||
let id = getMeta(d, 'device_id');
|
||
if (!id) {
|
||
id = crypto.randomBytes(6).toString('hex');
|
||
setMeta(d, 'device_id', id);
|
||
}
|
||
return id;
|
||
}
|
||
|
||
// UUID v7(RFC 9562):48bit 毫秒时间戳 + 随机位,时间有序,索引友好
|
||
export function uuidv7() {
|
||
const ts = Date.now();
|
||
const b = Buffer.alloc(16);
|
||
b.writeUIntBE(ts % 281474976710656, 0, 6);
|
||
crypto.randomBytes(10).copy(b, 6);
|
||
b[6] = (b[6] & 0x0f) | 0x70; // version 7
|
||
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
|
||
const h = b.toString('hex');
|
||
return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20, 32);
|
||
}
|
||
|
||
// 混合逻辑时钟:毫秒:计数:设备 —— 时钟回拨免疫,同设备单调,跨设备可比较
|
||
export function hlcNext(d) {
|
||
const ms = Date.now();
|
||
const lastMs = Number(getMeta(d, 'hlc_ms') || 0);
|
||
const lastCnt = Number(getMeta(d, 'hlc_counter') || 0);
|
||
let newMs, newCnt;
|
||
if (ms > lastMs) { newMs = ms; newCnt = 0; }
|
||
else { newMs = lastMs; newCnt = lastCnt + 1; }
|
||
setMeta(d, 'hlc_ms', String(newMs));
|
||
setMeta(d, 'hlc_counter', String(newCnt));
|
||
return newMs + ':' + newCnt + ':' + getDeviceId(d);
|
||
}
|
||
|
||
function touchOplog(d, table, rowUuid, op, hlc) {
|
||
d.prepare('INSERT INTO sync_oplog (table_name, row_uuid, op, hlc) VALUES (?,?,?,?)')
|
||
.run(table, rowUuid, op, hlc);
|
||
}
|
||
|
||
function withTx(d, fn) {
|
||
d.exec('BEGIN');
|
||
try { const r = fn(); d.exec('COMMIT'); return r; }
|
||
catch (e) { d.exec('ROLLBACK'); throw e; }
|
||
}
|
||
|
||
function getUuid(d, table, id) {
|
||
const r = d.prepare('SELECT uuid FROM ' + table + ' WHERE id=?').get(id);
|
||
return r ? r.uuid : null;
|
||
}
|
||
|
||
export function buildSearchText(title, description, result, tags, extras) {
|
||
const extraFlat = Object.entries(extras || {}).map(([k, v]) => {
|
||
if (v == null) return '';
|
||
if (typeof v === 'object') return Object.values(v).filter(Boolean).join(' ');
|
||
return String(v);
|
||
}).filter(Boolean);
|
||
return [title, description || '', result || '', (tags || []).join(' '), ...extraFlat]
|
||
.filter(Boolean).join('\n');
|
||
}
|
||
|
||
// ---------- Wire 协议(各端互通:uuid 引用 FK,LWW 仲裁) ----------
|
||
|
||
function parseHlc(h) {
|
||
const m = /^([0-9]+):([0-9]+):(.+)$/.exec(String(h || ''));
|
||
return m ? { ms: Number(m[1]), cnt: Number(m[2]), dev: m[3] } : null;
|
||
}
|
||
|
||
function hlcNewer(a, b) {
|
||
if (a.ms !== b.ms) return a.ms > b.ms;
|
||
if (a.cnt !== b.cnt) return a.cnt > b.cnt;
|
||
return a.dev > b.dev;
|
||
}
|
||
|
||
// 表 → [列, FK引用目标表]
|
||
const WIRE_TABLES = {
|
||
footprint: { fks: { type_id: 'footprint_type', parent_id: 'footprint' } },
|
||
footprint_type: { fks: {} },
|
||
taxonomy: { fks: {} },
|
||
dimension: { fks: {} },
|
||
grade_band: { fks: {} },
|
||
metric_def: { fks: {} },
|
||
semester: { fks: {} },
|
||
course_grade: { fks: { semester_id: 'semester' } },
|
||
saved_view: { fks: {} },
|
||
attachment: { fks: { owner_id: 'footprint' } },
|
||
footprint_dimension: { fks: { footprint_id: 'footprint', dimension_id: 'dimension' } }
|
||
};
|
||
|
||
// 老库迁移:补列 + 回填 uuid/hlc/origin(幂等)
|
||
const SYNC_TABLES = ['footprint', 'footprint_type', 'taxonomy', 'dimension', 'attachment',
|
||
'semester', 'course_grade', 'metric_def', 'saved_view', 'grade_band'];
|
||
|
||
const SYNC_COLS = [['uuid', 'TEXT'], ['deleted_at', 'TEXT'], ['origin', 'TEXT'], ['hlc', 'TEXT']];
|
||
|
||
function preMigrateColumns(d) {
|
||
const addCols = (t, cols) => {
|
||
const exists = d.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(t);
|
||
if (!exists) return; // 全新库:表由 schema.sql 创建,自带同步列
|
||
const have = new Set(d.prepare('PRAGMA table_info(' + t + ')').all().map(c => c.name));
|
||
for (const [name, ddl] of cols) {
|
||
if (!have.has(name)) d.exec('ALTER TABLE ' + t + ' ADD COLUMN ' + name + ' ' + ddl);
|
||
}
|
||
};
|
||
for (const t of SYNC_TABLES) addCols(t, SYNC_COLS);
|
||
addCols('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', 'uuid', 'hlc', 'origin'], [
|
||
['梯度:优秀', '优秀', '#16803c', 90, 100, 10],
|
||
['梯度:良好', '良好', '#0891b2', 80, 90, 20],
|
||
['梯度:中等', '中等', '#d97706', 70, 80, 30],
|
||
['梯度:及格', '及格', '#ea580c', 60, 70, 40],
|
||
['梯度:不及格', '不及格', '#dc2626', 0, 60, 50]
|
||
]);
|
||
|
||
seed('metric_def', ['key', 'label', 'unit', 'agg', 'section', 'uuid', 'hlc', 'origin'], [
|
||
['指标:volunteer_hours', 'volunteer_hours', '志愿时长', '小时', 'sum', '志愿服务']
|
||
]);
|
||
}
|
||
|
||
function openDb(dbPath) {
|
||
fs.mkdirSync(path.join(path.dirname(dbPath), 'attachments'), { recursive: true });
|
||
const d = new DatabaseSync(dbPath);
|
||
d.exec('PRAGMA foreign_keys = ON;');
|
||
preMigrateColumns(d);
|
||
d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8'));
|
||
// 个人信息表兜底(确保任何挂载形态下都存在)
|
||
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)");
|
||
migrateSync(d);
|
||
ensureBuiltin(d);
|
||
alignBuiltinUuids(d);
|
||
return d;
|
||
}
|
||
|
||
// 按数据库路径创建一套应用命令(多用户/服务器模式:每个用户一个独立档案库)
|
||
|
||
// ---------- 演示数据(demo 账号播种 / 本地 seed 共用) ----------
|
||
// 行格式: [类型, 标题, 角色, 级别, 获奖等级, 主办方, 开始, 结束, 状态, 描述, 结果, 标签, 维度, extras, metrics]
|
||
export const DEMO_ROWS = [
|
||
['竞赛', '2025 全国大学生数学建模竞赛', '主导', '国家级', '省市级二等奖', '中国工业与应用数学学会', '2025-09-05', '2025-09-08', 'finished',
|
||
'担任队长,负责模型建立与论文主笔;协调三人分工与时间安排', '重庆赛区二等奖', ['建模', '团队协作'], ['学业深造'],
|
||
{ my_work: '模型建立与论文主笔', deliverable: '参赛论文《基于优化模型的共享单车调度研究》', team_members: '张三、李四、王五' }, {}],
|
||
['竞赛', '第十六届蓝桥杯程序设计大赛', '参加', '省市级', '省市级二等奖', '工业和信息化部人才交流中心', '2025-04-12', '2025-06-20', 'finished',
|
||
'备战算法与数据结构,完成省赛', '重庆赛区 C/C++ B 组二等奖', ['算法'], ['职业发展', '学业深造'],
|
||
{ my_work: '独立参赛', deliverable: '省赛获奖证书' }, {}],
|
||
['竞赛', '全国大学生信息安全竞赛', '核心', '国家级', null, '教育部高等学校网络空间安全专业教学指导委员会', '2025-05-01', '2025-08-15', 'submitted',
|
||
'负责 Web 漏洞挖掘与利用脚本编写,参与作品报告撰写', '已提交作品,等待国赛评审', ['安全', 'CTF'], ['职业发展'],
|
||
{ my_work: 'Web 漏洞挖掘与利用脚本编写', deliverable: '参赛作品与报告' }, {}],
|
||
['志愿公益', '校图书馆迎新志愿服务', '参加', '校级', null, '校图书馆', '2025-09-01', '2025-09-05', 'finished',
|
||
'新生入馆引导与借阅咨询,共服务 5 天', '获馆方感谢信', ['志愿'], ['综合素养'],
|
||
{ hours: 24, contact: '李老师' }, { volunteer_hours: 24 }],
|
||
['志愿公益', '社区敬老院慰问活动', '参加', '区县级', null, '所在社区居委会', '2025-03-15', '2025-03-15', 'finished',
|
||
'陪伴老人、协助整理活动室', '', ['志愿', '公益'], ['综合素养'],
|
||
{ hours: 6 }, { volunteer_hours: 6 }],
|
||
['学生工作', '院学生会宣传部干事', '参加', '院级', null, '院学生会', '2024-09-01', '2025-06-30', 'finished',
|
||
'负责活动推文撰写与排版,全年产出推文 20 篇', '获优秀干事', ['宣传'], ['综合素养'],
|
||
{ duties: '推文撰写与排版、活动现场摄影', term: '2024.09 - 2025.06' }, {}],
|
||
['学生工作', '院学生会宣传部副部长', '主导', '院级', null, '院学生会', '2025-09-01', null, 'ongoing',
|
||
'统筹部门工作,管理 8 名干事,主导院级活动宣传方案', '', ['管理'], ['综合素养', '职业发展'],
|
||
{ duties: '部门统筹、活动宣传方案设计', term: '2025.09 - 至今' }, {}],
|
||
['荣誉惩处', '校级二等奖学金', '参加', '校级', null, '云川理工学院', '2025-10-15', '2025-10-15', 'finished',
|
||
'2024-2025 学年综合测评前列', '二等奖学金', ['奖学金'], ['学业深造'],
|
||
{ issued_by: '云川理工学院' }, {}],
|
||
['荣誉惩处', '优秀共青团员', '参加', '校级', null, '校团委', '2025-05-04', '2025-05-04', 'finished',
|
||
'五四表彰', '优秀共青团员', ['表彰'], ['综合素养'],
|
||
{ issued_by: '共青团重庆城市科技学院委员会' }, {}],
|
||
['证书技能', '大学英语六级', '参加', '国家级', null, '教育部教育考试院', '2025-06-14', '2025-08-20', 'finished',
|
||
'笔试 528 分', 'CET-6 528 分', ['英语'], ['学业深造', '职业发展'],
|
||
{ cert_no: '510001251200123', score: '528' }, {}],
|
||
['证书技能', '计算机技术与软件专业技术资格(中级)', '参加', '国家级', null, '工业和信息化部', '2025-11-08', null, 'ongoing',
|
||
'备考软件设计师', '', ['软考'], ['职业发展'],
|
||
{ score: '' }, {}],
|
||
['学业', '2024-2025 学年秋季学期', '参加', null, null, null, '2024-09-01', '2025-01-15', 'finished',
|
||
'学期绩点 3.72,专业排名 12/156', '', ['绩点'], ['学业深造'],
|
||
{ semester_label: '2024-2025-1' }, {}],
|
||
['学业', '2024-2025 学年春季学期', '参加', null, null, null, '2025-02-24', '2025-07-05', 'finished',
|
||
'学期绩点 3.85,专业排名 9/156', '', ['绩点'], ['学业深造'],
|
||
{ semester_label: '2024-2025-2' }, {}],
|
||
['文体活动', '校运动会开幕式方阵', '参加', '校级', null, '校体育运动委员会', '2025-04-18', '2025-04-20', 'finished',
|
||
'学院方阵队员,参与排练与开幕式表演', '', ['文体'], ['兴趣生活'],
|
||
{ participation: '方阵队员' }, {}],
|
||
['科研', '大学生创新创业训练计划项目', '主导', '校级', null, '校创新创业学院', '2025-03-01', null, 'ongoing',
|
||
'主持校级大创项目「基于流量特征的入侵检测研究」,带 2 名成员', '中期检查通过', ['科研', '安全'], ['学业深造', '职业发展'],
|
||
{ authors: '主持人', outcome: '研究报告' }, {}],
|
||
['实习实践', '某安全公司渗透测试实习生', '参加', null, null, '某网络安全科技有限公司', '2025-07-01', '2025-08-25', 'finished',
|
||
'参与企业授权渗透测试项目 3 个,编写测试报告', '获实习优秀评价', ['实习', '安全'], ['职业发展'],
|
||
{ position: '渗透测试实习生', mentor: '陈工' }, {}]
|
||
];
|
||
// 演示个人档案(虚构人设:云川理工学院 · 信息安全 · 林晓阳)
|
||
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: '性格沉稳踏实,做事有计划性,习惯提前预判风险并准备预案;对网络安全方向有持续热情,动手能力强,能独立完成从方案设计到落地验证的完整流程;责任心强,注重细节与条理,是可靠的技术执行者。'
|
||
}
|
||
};
|
||
|
||
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点][] }
|
||
export const DEMO_SEMESTERS = [
|
||
{ term: '2023-2024-1', gpa: 3.42, rank: 38, total: 156, courses: [
|
||
['高等数学(上)', 5, 78, 3.0], ['大学英语(一)', 3, 85, 3.7], ['程序设计基础', 4, 91, 4.0],
|
||
['思想道德与法治', 2, 88, 3.9], ['体育(一)', 1, 82, 3.5]
|
||
]},
|
||
{ term: '2023-2024-2', gpa: 3.61, rank: 29, total: 156, courses: [
|
||
['高等数学(下)', 5, 84, 3.7], ['线性代数', 3, 76, 2.9], ['数据结构', 4, 90, 4.0],
|
||
['大学英语(二)', 3, 87, 3.8], ['中国近现代史纲要', 2, 84, 3.5], ['体育(二)', 1, 86, 3.8]
|
||
]},
|
||
{ term: '2024-2025-1', gpa: 3.78, rank: 18, total: 156, courses: [
|
||
['概率论与数理统计', 3, 88, 3.9], ['操作系统', 4, 92, 4.0], ['计算机网络', 4, 86, 3.8],
|
||
['数据库原理', 3, 94, 4.0], ['马克思主义基本原理', 2, 81, 3.3]
|
||
]},
|
||
{ term: '2024-2025-2', gpa: 3.85, rank: 12, total: 156, courses: [
|
||
['软件工程', 3, 90, 4.0], ['信息安全导论', 3, 95, 4.0], ['密码学基础', 3, 89, 3.9],
|
||
['Web 应用开发', 2, 96, 4.0], ['数字逻辑', 3, 79, 3.1], ['体育(四)', 1, 90, 4.0]
|
||
]}
|
||
];
|
||
|
||
export function createAppCommands(dbPath) {
|
||
const db = openDb(dbPath);
|
||
const DATA_DIR = path.dirname(dbPath); // 工厂内遮蔽顶层默认值:附件按用户目录
|
||
function getDb() {
|
||
return db;
|
||
}
|
||
|
||
|
||
// ---------- 同步基础设施 ----------
|
||
|
||
|
||
const SORTS = {
|
||
date_desc: "COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) DESC, fp.id DESC",
|
||
date_asc: "COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) ASC, fp.id ASC",
|
||
updated_desc: 'fp.updated_at DESC, fp.id DESC',
|
||
weight_desc: "fp.weight DESC, COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) DESC"
|
||
};
|
||
|
||
function buildWhere(query) {
|
||
const conds = [];
|
||
const params = [];
|
||
const joins = [];
|
||
if (query.q) {
|
||
// 中文检索策略:长度>=3 的词走 FTS5 trigram(子串匹配),更短的词回退 LIKE
|
||
const ftsTerms = [];
|
||
for (const raw of String(query.q).trim().split(/\s+/).filter(Boolean)) {
|
||
const clean = raw.replace(/["\\]/g, ' ');
|
||
if (!clean.trim()) continue;
|
||
if ([...clean].length >= 3) {
|
||
ftsTerms.push('"' + clean + '"');
|
||
} else {
|
||
conds.push('fp.search_text LIKE ?');
|
||
params.push('%' + clean + '%');
|
||
}
|
||
}
|
||
if (ftsTerms.length) {
|
||
joins.push('JOIN footprint_fts ON footprint_fts.rowid = fp.id');
|
||
conds.push('footprint_fts MATCH ?');
|
||
params.push(ftsTerms.join(' AND '));
|
||
}
|
||
}
|
||
if (query.types?.length) {
|
||
conds.push('fp.type_id IN (' + query.types.map(() => '?').join(',') + ')');
|
||
params.push(...query.types);
|
||
}
|
||
if (query.levels?.length) {
|
||
conds.push('fp.level IN (' + query.levels.map(() => '?').join(',') + ')');
|
||
params.push(...query.levels);
|
||
}
|
||
if (query.roles?.length) {
|
||
conds.push('fp.role IN (' + query.roles.map(() => '?').join(',') + ')');
|
||
params.push(...query.roles);
|
||
}
|
||
if (query.statuses?.length) {
|
||
conds.push('fp.status IN (' + query.statuses.map(() => '?').join(',') + ')');
|
||
params.push(...query.statuses);
|
||
}
|
||
if (query.dims?.length) {
|
||
conds.push('fp.id IN (SELECT footprint_id FROM footprint_dimension WHERE dimension_id IN ('
|
||
+ query.dims.map(() => '?').join(',') + '))');
|
||
params.push(...query.dims);
|
||
}
|
||
if (query.year) {
|
||
conds.push("strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = ?");
|
||
params.push(String(query.year));
|
||
}
|
||
if (query.polarity) {
|
||
conds.push('fp.polarity = ?');
|
||
params.push(query.polarity);
|
||
}
|
||
if (query.has_attachment) {
|
||
conds.push("EXISTS (SELECT 1 FROM attachment a WHERE a.owner_type='footprint' AND a.owner_id=fp.id AND a.deleted_at IS NULL)");
|
||
}
|
||
if (query.tag) {
|
||
conds.push('fp.tags LIKE ?');
|
||
params.push('%"' + query.tag + '"%');
|
||
}
|
||
return { joins, conds, params };
|
||
}
|
||
|
||
const BASE_SELECT = `SELECT fp.*, ft.name AS type_name, ft.color AS type_color, ft.icon AS type_icon,
|
||
(SELECT COUNT(*) FROM attachment a WHERE a.owner_type='footprint' AND a.owner_id=fp.id AND a.deleted_at IS NULL) AS attachment_count,
|
||
(SELECT GROUP_CONCAT(dimension_id) FROM footprint_dimension fd WHERE fd.footprint_id=fp.id) AS dim_ids
|
||
FROM footprint fp JOIN footprint_type ft ON ft.id = fp.type_id`;
|
||
|
||
function mapRow(r) {
|
||
let metrics = {}, extras = {}, tags = [];
|
||
try { metrics = JSON.parse(r.metrics || '{}'); } catch {}
|
||
try { extras = JSON.parse(r.extras || '{}'); } catch {}
|
||
try { tags = JSON.parse(r.tags || '[]'); } catch {}
|
||
return {
|
||
...r,
|
||
metrics, extras, tags,
|
||
dimensions: r.dim_ids ? String(r.dim_ids).split(',').map(Number) : [],
|
||
dim_ids: undefined
|
||
};
|
||
}
|
||
|
||
function listFootprints(d, query) {
|
||
const page = Math.max(1, query.page || 1);
|
||
const pageSize = Math.min(2000, Math.max(1, query.page_size || 20));
|
||
const { joins, conds, params } = buildWhere(query);
|
||
const base = ' WHERE fp.deleted_at IS NULL';
|
||
const where = conds.length ? base + ' AND ' + conds.join(' AND ') : base;
|
||
const order = SORTS[query.sort] || SORTS.date_desc;
|
||
|
||
const total = d.prepare(
|
||
'SELECT COUNT(*) AS c FROM footprint fp ' + joins.join(' ') + where
|
||
).get(...params).c;
|
||
|
||
const rows = d.prepare(
|
||
BASE_SELECT + ' ' + joins.join(' ') + where + ' ORDER BY ' + order +
|
||
' LIMIT ' + pageSize + ' OFFSET ' + (page - 1) * pageSize
|
||
).all(...params).map(mapRow);
|
||
|
||
return { items: rows, total, page, page_size: pageSize };
|
||
}
|
||
|
||
// ---------- 命令处理 ----------
|
||
function handleCommand(cmd, payload) {
|
||
const d = getDb();
|
||
|
||
switch (cmd) {
|
||
case 'app_info': {
|
||
const pending = d.prepare('SELECT COUNT(*) AS c FROM sync_oplog WHERE synced_at IS NULL').get().c;
|
||
return {
|
||
app_name: 'Footprints 足迹',
|
||
version: '0.2.0',
|
||
db_path: DB_PATH,
|
||
attachment_dir: ATT_DIR,
|
||
schema_ok: true,
|
||
device_id: getDeviceId(d),
|
||
pending_ops: pending,
|
||
last_sync_at: getMeta(d, 'last_sync_at')
|
||
};
|
||
}
|
||
|
||
case 'sync_status':
|
||
return {
|
||
mode: 'local',
|
||
device_id: getDeviceId(d),
|
||
pending_ops: d.prepare('SELECT COUNT(*) AS c FROM sync_oplog WHERE synced_at IS NULL').get().c,
|
||
last_sync_at: getMeta(d, 'last_sync_at')
|
||
};
|
||
|
||
case 'type_list': {
|
||
const items = d.prepare(`
|
||
SELECT t.*, (SELECT COUNT(*) FROM footprint f WHERE f.type_id = t.id AND f.deleted_at IS NULL) AS usage_count
|
||
FROM footprint_type t WHERE t.deleted_at IS NULL ORDER BY t.sort, t.id`).all()
|
||
.map(t => ({ ...t, form_template: JSON.parse(t.form_template || '[]'), 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 } : null;
|
||
};
|
||
const semesters = d.prepare('SELECT * FROM semester WHERE ' + DEL + ' ORDER BY term DESC').all();
|
||
const activeSemIds = new Set(semesters.map(s => s.id));
|
||
const courses = d.prepare('SELECT * FROM course_grade WHERE ' + DEL + ' ORDER BY id').all()
|
||
.filter(c => activeSemIds.has(c.semester_id));
|
||
for (const s of semesters) {
|
||
s.courses = courses.filter(c => c.semester_id === s.id).map(c => ({ ...c, band: bandOf(c.score) }));
|
||
const scored = s.courses.filter(c => c.score != null);
|
||
const creditsAll = s.courses.reduce((a, c) => a + (c.credit || 0), 0);
|
||
const creditsScored = scored.reduce((a, c) => a + (c.credit || 0), 0);
|
||
s.stats = {
|
||
credits: Math.round(creditsAll * 10) / 10,
|
||
courses: s.courses.length,
|
||
avg: creditsScored > 0 ? Math.round(scored.reduce((a, c) => a + c.score * c.credit, 0) / creditsScored * 100) / 100 : null
|
||
};
|
||
}
|
||
const allScored = courses.filter(c => c.score != null);
|
||
const creditsAll = allScored.reduce((a, c) => a + (c.credit || 0), 0);
|
||
const overallAvg = creditsAll > 0
|
||
? Math.round(allScored.reduce((a, c) => a + c.score * c.credit, 0) / creditsAll * 100) / 100
|
||
: null;
|
||
const tierDist = bands.map(b => ({
|
||
label: b.label, color: b.color,
|
||
count: allScored.filter(c => { const x = bandOf(c.score); return x && x.id === b.id; }).length
|
||
}));
|
||
const gpaTrend = [...semesters]
|
||
.filter(s => s.gpa != null)
|
||
.sort((a, b) => a.term.localeCompare(b.term))
|
||
.map(s => ({ term: s.term, gpa: s.gpa, avg: s.stats.avg }));
|
||
return {
|
||
semesters,
|
||
bands,
|
||
overall: {
|
||
avg: overallAvg,
|
||
credits: Math.round(courses.reduce((a, c) => a + (c.credit || 0), 0) * 10) / 10,
|
||
courses: courses.length,
|
||
gpa_trend: gpaTrend,
|
||
tier_dist: tierDist
|
||
}
|
||
};
|
||
}
|
||
|
||
case 'semester_upsert': {
|
||
const p = payload;
|
||
if (!p.term || !String(p.term).trim()) throw new Error('缺少学期名称');
|
||
const term = String(p.term).trim();
|
||
let id = p.id;
|
||
withTx(d, () => {
|
||
if (id) {
|
||
const dup = d.prepare('SELECT id FROM semester WHERE term=? AND deleted_at IS NULL AND id != ?').get(term, id);
|
||
if (dup) throw new Error('学期已存在:' + term);
|
||
const h = hlcNext(d);
|
||
d.prepare('UPDATE semester SET term=?, gpa=?, rank=?, total_students=?, hlc=? WHERE id=?')
|
||
.run(term, p.gpa ?? null, p.rank ?? null, p.total_students ?? null, h, id);
|
||
touchOplog(d, 'semester', getUuid(d, 'semester', id), 'upsert', h);
|
||
} else {
|
||
const dup = d.prepare('SELECT 1 FROM semester WHERE term=? AND deleted_at IS NULL').get(term);
|
||
if (dup) throw new Error('学期已存在:' + term);
|
||
const h = hlcNext(d);
|
||
const uuid = uuidv7();
|
||
id = Number(d.prepare('INSERT INTO semester (term, gpa, rank, total_students, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)')
|
||
.run(term, p.gpa ?? null, p.rank ?? null, p.total_students ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
|
||
touchOplog(d, 'semester', uuid, 'upsert', h);
|
||
}
|
||
});
|
||
return { item: { id } };
|
||
}
|
||
|
||
case 'semester_delete': {
|
||
withTx(d, () => {
|
||
softDelete(d, 'semester', payload.id);
|
||
// 级联墓碑该学期下全部课程,避免孤儿课程污染总览
|
||
const courses = d.prepare('SELECT id FROM course_grade WHERE semester_id=? AND deleted_at IS NULL').all(payload.id);
|
||
for (const c of courses) softDelete(d, 'course_grade', c.id);
|
||
});
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'course_upsert': {
|
||
const p = payload;
|
||
if (!p.semester_id) throw new Error('缺少学期');
|
||
if (!p.course_name || !String(p.course_name).trim()) throw new Error('缺少课程名');
|
||
if (p.score != null && (p.score < 0 || p.score > 100)) throw new Error('分数需在 0-100 之间');
|
||
if (p.grade_point != null && p.grade_point < 0) throw new Error('绩点不能为负');
|
||
const name = String(p.course_name).trim();
|
||
let id = p.id;
|
||
withTx(d, () => {
|
||
if (id) {
|
||
const h = hlcNext(d);
|
||
d.prepare('UPDATE course_grade SET semester_id=?, course_name=?, credit=?, score=?, grade_point=?, hlc=? WHERE id=?')
|
||
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, h, id);
|
||
touchOplog(d, 'course_grade', getUuid(d, 'course_grade', id), 'upsert', h);
|
||
} else {
|
||
const h = hlcNext(d);
|
||
const uuid = uuidv7();
|
||
id = Number(d.prepare('INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
|
||
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
|
||
touchOplog(d, 'course_grade', uuid, 'upsert', h);
|
||
}
|
||
});
|
||
return { item: { id } };
|
||
}
|
||
|
||
case 'course_delete': {
|
||
withTx(d, () => softDelete(d, 'course_grade', payload.id));
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'band_upsert': {
|
||
const p = payload;
|
||
if (!p.label || !String(p.label).trim()) throw new Error('缺少等级名称');
|
||
if (p.min == null || p.max == null || p.min >= p.max) throw new Error('分数区间不合法(需 min < max)');
|
||
const label = String(p.label).trim();
|
||
let id = p.id;
|
||
withTx(d, () => {
|
||
if (id) {
|
||
const h = hlcNext(d);
|
||
d.prepare('UPDATE grade_band SET label=?, color=?, min=?, max=?, sort=?, hlc=? WHERE id=?')
|
||
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, h, id);
|
||
touchOplog(d, 'grade_band', getUuid(d, 'grade_band', id), 'upsert', h);
|
||
} else {
|
||
const h = hlcNext(d);
|
||
const uuid = uuidv7();
|
||
id = Number(d.prepare('INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
|
||
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, uuid, h, getDeviceId(d)).lastInsertRowid);
|
||
touchOplog(d, 'grade_band', uuid, 'upsert', h);
|
||
}
|
||
});
|
||
return { item: { id } };
|
||
}
|
||
|
||
case 'band_delete': {
|
||
withTx(d, () => softDelete(d, 'grade_band', payload.id));
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'attachment_add': {
|
||
const p = payload;
|
||
const buf = Buffer.from(p.data_b64 || '', 'base64');
|
||
const sha = crypto.createHash('sha256').update(buf).digest('hex');
|
||
const safe = p.file_name.replace(/[\\/:*?"<>|]/g, '_');
|
||
const rel = path.join('attachments', String(p.owner_id), crypto.randomUUID() + '_' + safe);
|
||
const abs = path.join(DATA_DIR, rel);
|
||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||
fs.writeFileSync(abs, buf);
|
||
// 服务端缩略图(上传时由客户端 canvas 生成 480px WebP;无则 NULL,读取时回退原图)
|
||
let thumbRel = null;
|
||
if (p.thumb_b64) {
|
||
const tb = Buffer.from(p.thumb_b64, 'base64');
|
||
if (tb.length) {
|
||
thumbRel = path.join('attachments', String(p.owner_id), crypto.randomUUID() + '_thumb.webp');
|
||
const tAbs = path.join(DATA_DIR, thumbRel);
|
||
fs.mkdirSync(path.dirname(tAbs), { recursive: true });
|
||
fs.writeFileSync(tAbs, tb);
|
||
thumbRel = thumbRel.split(path.sep).join('/');
|
||
}
|
||
}
|
||
const uuid = uuidv7();
|
||
let item = null;
|
||
withTx(d, () => {
|
||
const h = hlcNext(d);
|
||
const r = d.prepare(
|
||
"INSERT INTO attachment (owner_type, owner_id, kind, file_name, mime, size, rel_path, thumb_rel_path, sha256, uuid, hlc, origin) VALUES ('footprint',?,?,?,?,?,?,?,?,?,?,?)"
|
||
).run(p.owner_id, null, p.file_name, p.mime ?? null, buf.length, rel.split(path.sep).join('/'), thumbRel, sha, uuid, h, getDeviceId(d));
|
||
item = d.prepare('SELECT * FROM attachment WHERE id=?').get(Number(r.lastInsertRowid));
|
||
touchOplog(d, 'attachment', uuid, 'upsert', h);
|
||
});
|
||
return { item };
|
||
}
|
||
|
||
case 'attachment_list':
|
||
return {
|
||
items: d.prepare(
|
||
"SELECT * FROM attachment WHERE owner_type='footprint' AND owner_id=? AND deleted_at IS NULL ORDER BY id"
|
||
).all(payload.owner_id)
|
||
};
|
||
|
||
case 'attachment_delete': {
|
||
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
|
||
if (row) {
|
||
withTx(d, () => softDelete(d, 'attachment', payload.id));
|
||
const abs = path.join(DATA_DIR, row.rel_path);
|
||
try { fs.unlinkSync(abs); } catch {}
|
||
if (row.thumb_rel_path) { try { fs.unlinkSync(path.join(DATA_DIR, row.thumb_rel_path)); } catch {} }
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'attachment_read': {
|
||
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
|
||
if (!row) throw new Error('附件不存在');
|
||
const data = fs.readFileSync(path.join(DATA_DIR, row.rel_path));
|
||
return { data_b64: data.toString('base64'), mime: row.mime, file_name: row.file_name };
|
||
}
|
||
|
||
case 'attachment_thumb': {
|
||
// 缩略图读取:有服务端缩略图用之(20KB 级 WebP),老附件回退原图
|
||
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
|
||
if (!row) throw new Error('附件不存在');
|
||
const rel = row.thumb_rel_path || row.rel_path;
|
||
const data = fs.readFileSync(path.join(DATA_DIR, rel));
|
||
return { data_b64: data.toString('base64'), mime: row.thumb_rel_path ? 'image/webp' : row.mime, file_name: row.file_name };
|
||
}
|
||
|
||
case 'attachment_set_remote': {
|
||
// 图床(Lsky/COS)直传成功后回填远程地址;同步到所有端
|
||
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
|
||
if (!row) throw new Error('附件不存在');
|
||
withTx(d, () => {
|
||
const h = hlcNext(d);
|
||
d.prepare('UPDATE attachment SET remote_url=COALESCE(?, remote_url), remote_thumb=COALESCE(?, remote_thumb), 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 '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;
|
||
// 单行语义:更新现有行或插入首行
|
||
const content = JSON.stringify(p.content ?? {});
|
||
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;
|
||
handleCommand('footprint_upsert', { item: {
|
||
type_id: typeId, title, role, level, award_grade: grade, organizer,
|
||
start_date: start, end_date: end, status, description: desc, result,
|
||
tags, metrics, extras,
|
||
dimension_ids: (dimNames || []).map((nm) => dimIds[nm]).filter(Boolean),
|
||
}});
|
||
n++;
|
||
}
|
||
let sems = 0;
|
||
for (const s of DEMO_SEMESTERS) {
|
||
const r = handleCommand('semester_upsert', { term: s.term, gpa: s.gpa, rank: s.rank, total_students: s.total });
|
||
const sid = r.item.id;
|
||
for (const [name, credit, score, gp] of s.courses) {
|
||
handleCommand('course_upsert', { semester_id: sid, course_name: name, credit, score, grade_point: gp });
|
||
}
|
||
sems++;
|
||
}
|
||
return { seeded: true, footprints: n, semesters: sems };
|
||
}
|
||
case 'sync_apply_wire':
|
||
return applyWireBatch(payload.changes || []);
|
||
|
||
case 'sync_pull_wire':
|
||
return pullWire(payload.cursor, payload.limit);
|
||
|
||
default:
|
||
throw new Error('未知命令:' + cmd);
|
||
}
|
||
}
|
||
|
||
// 工厂闭合
|
||
// ---------- Wire 协议:出站序列化 / 入站应用 ----------
|
||
|
||
const SKIP_COLS = ['id', 'uuid', 'hlc', 'origin', 'created_at', 'updated_at', 'search_text'];
|
||
|
||
function uuidOf(table, id) {
|
||
if (id == null) return null;
|
||
const r = db.prepare('SELECT uuid FROM ' + table + ' WHERE id=?').get(id);
|
||
return r ? r.uuid : null;
|
||
}
|
||
|
||
function idOfUuid(table, uuid, mayCreate) {
|
||
if (uuid == null) return null;
|
||
const r = db.prepare('SELECT id FROM ' + table + ' WHERE uuid=?').get(uuid);
|
||
if (r) return r.id;
|
||
if (!mayCreate) return null;
|
||
const h2 = hlcNext(db);
|
||
if (table === 'footprint_type') {
|
||
const rr = db.prepare("INSERT INTO footprint_type (name, icon, color, sort, form_template, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,?,0,?,?,?)")
|
||
.run('同步类型 ' + uuid.slice(0, 8), 'tag', '#64748b', 500, '[]', uuid, h2, 'sync');
|
||
return Number(rr.lastInsertRowid);
|
||
}
|
||
if (table === 'dimension') {
|
||
const rr = db.prepare("INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)")
|
||
.run('同步维度 ' + uuid.slice(0, 8), '#64748b', 500, uuid, h2, 'sync');
|
||
return Number(rr.lastInsertRowid);
|
||
}
|
||
if (table === 'grade_band') {
|
||
const rr = db.prepare("INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)")
|
||
.run('同步梯度 ' + uuid.slice(0, 8), '#64748b', 60, 70, 500, uuid, h2, 'sync');
|
||
return Number(rr.lastInsertRowid);
|
||
}
|
||
if (table === 'metric_def') {
|
||
const rr = db.prepare("INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)")
|
||
.run('sync_' + uuid.slice(0, 8), '同步指标 ' + uuid.slice(0, 8), null, 'sum', null, uuid, h2, 'sync');
|
||
return Number(rr.lastInsertRowid);
|
||
}
|
||
if (table === 'taxonomy') {
|
||
const rr = db.prepare("INSERT INTO taxonomy (kind, value, color, sort, uuid, hlc, origin) VALUES (?,?,?,0,?,?,?,?)")
|
||
.run('level', '同步条目 ' + uuid.slice(0, 8), '#64748b', uuid, h2, 'sync');
|
||
return Number(rr.lastInsertRowid);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function serializeRowOf(table) {
|
||
const spec = WIRE_TABLES[table];
|
||
return (row) => {
|
||
const out = { uuid: row.uuid, hlc: row.hlc, deleted_at: row.deleted_at || null };
|
||
for (const k of Object.keys(row)) {
|
||
if (SKIP_COLS.includes(k)) continue;
|
||
if (spec.fks[k] !== undefined) { out[k.replace('_id', '_uuid')] = uuidOf(spec.fks[k], row[k]); continue; }
|
||
out[k] = row[k];
|
||
}
|
||
return out;
|
||
};
|
||
}
|
||
|
||
function softDeleteById(table, id) {
|
||
db.prepare('UPDATE ' + table + " SET deleted_at=datetime('now','localtime') WHERE id=?").run(id);
|
||
}
|
||
|
||
function applyWire(change) {
|
||
const spec = WIRE_TABLES[change.table];
|
||
if (!spec) return { accepted: 0, stale: 0, invalid: 1 };
|
||
const rowUuid = String(change.row_uuid || '');
|
||
const incoming = parseHlc(change.hlc);
|
||
if (!incoming || (change.op !== 'upsert' && change.op !== 'delete')) return { accepted: 0, stale: 0, invalid: 1 };
|
||
const existing = db.prepare('SELECT id, hlc FROM ' + change.table + ' WHERE uuid=?').get(rowUuid);
|
||
if (existing) {
|
||
const e = parseHlc(existing.hlc);
|
||
if (e && !hlcNewer(incoming, e)) return { accepted: 0, stale: 1, invalid: 0 };
|
||
}
|
||
let res;
|
||
try {
|
||
res = withTx(db, () => {
|
||
if (change.op === 'delete') {
|
||
if (existing) softDeleteById(change.table, existing.id);
|
||
touchOplog(db, change.table, rowUuid, 'delete', change.hlc);
|
||
return 1;
|
||
}
|
||
const data = change.data || {};
|
||
const fv = {};
|
||
for (const fk of Object.keys(spec.fks)) {
|
||
const uuidVal = data[fk.replace('_id', '_uuid')];
|
||
fv[fk] = idOfUuid(spec.fks[fk], uuidVal, true);
|
||
}
|
||
const cols = [];
|
||
const vals = [];
|
||
for (const k of Object.keys(data)) {
|
||
if (['uuid', 'hlc', 'deleted_at'].includes(k)) continue;
|
||
if (k.endsWith('_uuid') && spec.fks[k.replace('_uuid', '_id')] !== undefined) continue;
|
||
cols.push(k);
|
||
vals.push(data[k]);
|
||
}
|
||
for (const fk of Object.keys(fv)) { cols.push(fk); vals.push(fv[fk]); }
|
||
cols.push('uuid', 'hlc', 'origin');
|
||
vals.push(rowUuid, change.hlc, 'sync');
|
||
if (change.table === 'footprint') {
|
||
cols.push('search_text');
|
||
vals.push(buildSearchText(String(data.title || ''), String(data.description || ''), String(data.result || ''), data.tags || [], data.extras || {}));
|
||
}
|
||
if (existing) {
|
||
const setSql = cols.map(c => c + '=?').join(', ');
|
||
db.prepare('UPDATE ' + change.table + ' SET ' + setSql + ' WHERE id=?').run(...vals, existing.id);
|
||
} else {
|
||
db.prepare('INSERT INTO ' + change.table + ' (' + cols.join(',') + ') VALUES (' + cols.map(() => '?').join(',') + ')').run(...vals);
|
||
}
|
||
touchOplog(db, change.table, rowUuid, 'upsert', change.hlc);
|
||
return 1;
|
||
});
|
||
} catch (ex) {
|
||
throw ex;
|
||
}
|
||
return res === 1 ? { accepted: 1, stale: 0, invalid: 0 } : { accepted: 0, stale: 0, invalid: 0 };
|
||
}
|
||
|
||
function applyWireBatch(changes) {
|
||
let accepted = 0; let stale = 0; let invalid = 0;
|
||
for (const ch of (changes || [])) {
|
||
const r = applyWire(ch);
|
||
accepted += r.accepted; stale += r.stale; invalid += r.invalid;
|
||
}
|
||
return { accepted, stale, invalid };
|
||
}
|
||
|
||
function pullWire(cursorRaw, limitRaw) {
|
||
const cursor = Math.max(0, Number(cursorRaw) || 0);
|
||
const limit = Math.min(500, Math.max(1, Number(limitRaw) || 200));
|
||
const opRows = db.prepare('SELECT id, table_name, row_uuid, op, hlc FROM sync_oplog WHERE id>? ORDER BY id LIMIT ?').all(cursor, limit);
|
||
const changes = [];
|
||
for (const op of opRows) {
|
||
if (!WIRE_TABLES[op.table_name]) continue;
|
||
if (op.op === 'delete') {
|
||
changes.push({ seq: op.id, table: op.table_name, row_uuid: op.row_uuid, op: 'delete', hlc: op.hlc, data: null });
|
||
continue;
|
||
}
|
||
const row = db.prepare('SELECT * FROM ' + op.table_name + ' WHERE uuid=?').get(op.row_uuid);
|
||
if (!row) continue;
|
||
changes.push({ seq: op.id, table: op.table_name, row_uuid: op.row_uuid, op: 'upsert', hlc: op.hlc, data: serializeRowOf(op.table_name)(row) });
|
||
}
|
||
const maxSeq = db.prepare('SELECT COALESCE(MAX(id),0) AS m FROM sync_oplog').get().m;
|
||
const hasMore = opRows.length === limit;
|
||
return {
|
||
changes,
|
||
cursor: hasMore && opRows.length ? opRows[opRows.length - 1].id : Math.max(cursor, maxSeq),
|
||
has_more: hasMore
|
||
};
|
||
}
|
||
|
||
return { handleCommand, getDb, pullWire, applyWireBatch };
|
||
}
|
||
|
||
// ---------- 默认单例(开发桥接 / seed 兼容) ----------
|
||
let defaultCommands = null;
|
||
function getDefault() {
|
||
if (!defaultCommands) defaultCommands = createAppCommands(DB_PATH);
|
||
return defaultCommands;
|
||
}
|
||
|
||
export function getDb() {
|
||
return getDefault().getDb();
|
||
}
|
||
|
||
export function handleCommand(cmd, payload) {
|
||
return getDefault().handleCommand(cmd, payload);
|
||
} |