diff --git a/app/.builtin-test/footprints.db b/app/.builtin-test/footprints.db new file mode 100644 index 0000000..6ccc550 Binary files /dev/null and b/app/.builtin-test/footprints.db differ diff --git a/app/build-log.txt b/app/build-log.txt new file mode 100644 index 0000000..3ffe817 Binary files /dev/null and b/app/build-log.txt differ diff --git a/app/server/db.mjs b/app/server/db.mjs index 4c88d2e..5807e07 100644 --- a/app/server/db.mjs +++ b/app/server/db.mjs @@ -127,6 +127,57 @@ function softDelete(d, table, 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' } @@ -179,69 +230,85 @@ const BUILTIN_DIMENSIONS = [ ]; function ensureBuiltin(db) { - const cnt = db.prepare('SELECT COUNT(*) AS c FROM footprint_type WHERE deleted_at IS NULL').get().c; - if (cnt === 0) { - const ins = db.prepare( - 'INSERT INTO footprint_type (name, icon, color, sort, form_template, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,?,1,?,?,?)'); - for (const [name, icon, color, sort, tpl] of BUILTIN_TYPES) { - const h = hlcNext(db); - ins.run(name, icon, color, sort, tpl, uuidv7(), h, getDeviceId(db)); + const 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 tcnt = db.prepare('SELECT COUNT(*) AS c FROM taxonomy WHERE deleted_at IS NULL').get().c; - if (tcnt === 0) { - const ins = db.prepare('INSERT INTO taxonomy (kind, value, color, sort, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,1,?,?,?)'); - for (const [v, c, s] of BUILTIN_LEVELS) { - const h = hlcNext(db); - ins.run('level', v, c, s, uuidv7(), h, getDeviceId(db)); - } - for (const [v, c, s] of BUILTIN_ROLES) { - const h = hlcNext(db); - ins.run('role', v, c, s, uuidv7(), h, getDeviceId(db)); - } - } - const dcnt = db.prepare('SELECT COUNT(*) AS c FROM dimension WHERE deleted_at IS NULL').get().c; - if (dcnt === 0) { - const ins = db.prepare('INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)'); - for (const [v, c, s] of BUILTIN_DIMENSIONS) { - const h = hlcNext(db); - ins.run(v, c, s, uuidv7(), h, getDeviceId(db)); - } - } - const mcnt = db.prepare('SELECT COUNT(*) AS c FROM metric_def WHERE deleted_at IS NULL').get().c; - if (mcnt === 0) { - const h = hlcNext(db); - db.prepare('INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)') - .run('volunteer_hours', '志愿时长', '小时', 'sum', '志愿服务', uuidv7(), h, getDeviceId(db)); - } - const bcnt = db.prepare('SELECT COUNT(*) AS c FROM grade_band WHERE deleted_at IS NULL').get().c; - if (bcnt === 0) { - const bands = [ - ['优秀', '#16803c', 90, 100, 10], - ['良好', '#0891b2', 80, 90, 20], - ['中等', '#d97706', 70, 80, 30], - ['及格', '#ea580c', 60, 70, 40], - ['不及格', '#dc2626', 0, 60, 50] - ]; - const ins = db.prepare('INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)'); - for (const [label, color, min, max, sort] of bands) { - const h = hlcNext(db); - ins.run(label, color, min, max, sort, uuidv7(), h, getDeviceId(db)); - } - } -} + }; -// ---------- 查询构建 ---------- + 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;'); - // 先补列(老库无 uuid/hlc 等列时,schema 中的索引会引用失败),再执行 schema,最后回填 preMigrateColumns(d); d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8')); migrateSync(d); ensureBuiltin(d); + alignBuiltinUuids(d); return d; } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 6cf8aa4..8de5d31 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -231,12 +231,55 @@ fn get_row_uuid(conn: &Connection, table: &str, id: i64) -> Option { conn.query_row(&format!("SELECT uuid FROM {} WHERE id=?1", table), [id], |r| r.get(0)).ok() } +// 固定内置 uuid(与 Node 端 BUILTIN_UUID 完全一致;各端互通的对齐前提) +const FIXED_BUILTIN: [(&str, &str, &str, &str); 28] = [ + ("footprint_type", "name", "学业", "018f0000-0000-7000-8000-000000000001"), + ("footprint_type", "name", "竞赛", "018f0000-0000-7000-8000-000000000002"), + ("footprint_type", "name", "科研", "018f0000-0000-7000-8000-000000000003"), + ("footprint_type", "name", "实习实践", "018f0000-0000-7000-8000-000000000004"), + ("footprint_type", "name", "学生工作", "018f0000-0000-7000-8000-000000000005"), + ("footprint_type", "name", "志愿公益", "018f0000-0000-7000-8000-000000000006"), + ("footprint_type", "name", "文体活动", "018f0000-0000-7000-8000-000000000007"), + ("footprint_type", "name", "证书技能", "018f0000-0000-7000-8000-000000000008"), + ("footprint_type", "name", "荣誉惩处", "018f0000-0000-7000-8000-000000000009"), + ("taxonomy", "value", "国际级", "018f0000-0000-7000-8000-000000000011"), + ("taxonomy", "value", "国家级", "018f0000-0000-7000-8000-000000000012"), + ("taxonomy", "value", "省市级", "018f0000-0000-7000-8000-000000000013"), + ("taxonomy", "value", "区县级", "018f0000-0000-7000-8000-000000000014"), + ("taxonomy", "value", "校级", "018f0000-0000-7000-8000-000000000015"), + ("taxonomy", "value", "院级", "018f0000-0000-7000-8000-000000000016"), + ("taxonomy", "value", "主导", "018f0000-0000-7000-8000-000000000017"), + ("taxonomy", "value", "核心", "018f0000-0000-7000-8000-000000000018"), + ("taxonomy", "value", "参加", "018f0000-0000-7000-8000-000000000019"), + ("dimension", "name", "学业深造", "018f0000-0000-7000-8000-000000000021"), + ("dimension", "name", "职业发展", "018f0000-0000-7000-8000-000000000022"), + ("dimension", "name", "综合素养", "018f0000-0000-7000-8000-000000000023"), + ("dimension", "name", "兴趣生活", "018f0000-0000-7000-8000-000000000024"), + ("grade_band", "label", "优秀", "018f0000-0000-7000-8000-000000000031"), + ("grade_band", "label", "良好", "018f0000-0000-7000-8000-000000000032"), + ("grade_band", "label", "中等", "018f0000-0000-7000-8000-000000000033"), + ("grade_band", "label", "及格", "018f0000-0000-7000-8000-000000000034"), + ("grade_band", "label", "不及格", "018f0000-0000-7000-8000-000000000035"), + ("metric_def", "key", "volunteer_hours", "018f0000-0000-7000-8000-000000000041"), +]; + +fn align_builtin_uuids(conn: &Connection) { + for (table, col, name, uuid) in FIXED_BUILTIN { + let sql = format!("UPDATE {} SET uuid=?1 WHERE {}=?2 AND uuid<>?1", table, col); + let _ = conn.execute(&sql, rusqlite::params![uuid, name]); + } +} + fn ensure_builtin(conn: &Connection) { + align_builtin_uuids(conn); let cnt: i64 = conn .query_row("SELECT COUNT(*) FROM footprint_type WHERE deleted_at IS NULL", [], |r| r.get(0)) .unwrap_or(0); - if cnt == 0 { - let types = [ + if cnt > 0 { + align_builtin_uuids(conn); + return; + } + let types = [ ("学业", "graduation-cap", "#3056d3", 10, r#"[]"#), ("竞赛", "trophy", "#7c3aed", 20, r#"[{"key":"my_work","label":"我的分工","widget":"textarea","required":true},{"key":"deliverable","label":"最终产品","widget":"text"},{"key":"team_members","label":"队伍成员","widget":"text"}]"#), ("科研", "flask-conical", "#0891b2", 30, r#"[{"key":"authors","label":"作者排序","widget":"text"},{"key":"outcome","label":"成果形式","widget":"select","options":["论文","专利","软件著作权","研究报告"]}]"#), @@ -246,81 +289,74 @@ fn ensure_builtin(conn: &Connection) { ("文体活动", "palette", "#db2777", 70, r#"[{"key":"participation","label":"参与形式","widget":"text"}]"#), ("证书技能", "file-badge", "#64748b", 80, r#"[{"key":"cert_no","label":"证书编号","widget":"text"},{"key":"score","label":"成绩","widget":"text"}]"#), ("荣誉惩处", "award", "#b45309", 90, r#"[{"key":"issued_by","label":"授予 / 决定单位","widget":"text"}]"#), - ]; + ]; + if cnt == 0 { + let mut up = String::new(); for (name, icon, color, sort, tpl) in types { - let h = hlc_next(conn); - let _ = conn.execute( - "INSERT INTO footprint_type (name, icon, color, sort, form_template, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,?,1,?,?,?)", - rusqlite::params![name, icon, color, sort, tpl, uuid_v7(), h, get_device_id(conn)], - ); + let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "footprint_type" && *c == "name" && *n == name); + if let Some((_, _, _, uuid)) = fixed { + let _ = conn.execute( + "INSERT INTO footprint_type (name, icon, color, sort, form_template, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,?,1,?,?,?)", + rusqlite::params![name, icon, color, sort, tpl, uuid, "0:0:builtin", "builtin"], + ); + } } - } - let tcnt: i64 = conn - .query_row("SELECT COUNT(*) FROM taxonomy WHERE deleted_at IS NULL", [], |r| r.get(0)) - .unwrap_or(0); - if tcnt == 0 { let levels = [ - ("国际级", "#7c3aed", 10), ("国家级", "#dc2626", 20), ("省市级", "#ea580c", 30), - ("区县级", "#2563eb", 40), ("校级", "#16a34a", 50), ("院级", "#0891b2", 60), + ("国际级", "#7c3aed", 10), ("国家级", "#dc2626", 20), ("省市级", "#ea580c", 30), + ("区县级", "#2563eb", 40), ("校级", "#16a34a", 50), ("院级", "#0891b2", 60), ]; for (v, c, s) in levels { - let h = hlc_next(conn); - let _ = conn.execute( - "INSERT INTO taxonomy (kind, value, color, sort, is_builtin, uuid, hlc, origin) VALUES ('level',?,?,?,1,?,?,?)", - rusqlite::params![v, c, s, uuid_v7(), h, get_device_id(conn)], - ); + let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "taxonomy" && *n == v); + if let Some((_, _, _, uuid)) = fixed { + let _ = conn.execute( + "INSERT INTO taxonomy (kind, value, color, sort, is_builtin, uuid, hlc, origin) VALUES ('level',?,?,?,1,?,?,?)", + rusqlite::params![v, c, s, uuid, "0:0:builtin", "builtin"], + ); + } } let roles = [("主导", "#3056d3", 10), ("核心", "#0891b2", 20), ("参加", "#64748b", 30)]; for (v, c, s) in roles { - let h = hlc_next(conn); - let _ = conn.execute( - "INSERT INTO taxonomy (kind, value, color, sort, is_builtin, uuid, hlc, origin) VALUES ('role',?,?,?,1,?,?,?)", - rusqlite::params![v, c, s, uuid_v7(), h, get_device_id(conn)], - ); + let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "taxonomy" && *n == v); + if let Some((_, _, _, uuid)) = fixed { + let _ = conn.execute( + "INSERT INTO taxonomy (kind, value, color, sort, is_builtin, uuid, hlc, origin) VALUES ('role',?,?,?,1,?,?,?)", + rusqlite::params![v, c, s, uuid, "0:0:builtin", "builtin"], + ); + } } - } - let dcnt: i64 = conn - .query_row("SELECT COUNT(*) FROM dimension WHERE deleted_at IS NULL", [], |r| r.get(0)) - .unwrap_or(0); - if dcnt == 0 { let dims = [ - ("学业深造", "#3056d3", 10), ("职业发展", "#0891b2", 20), - ("综合素养", "#16a34a", 30), ("兴趣生活", "#db2777", 40), + ("学业深造", "#3056d3", 10), ("职业发展", "#0891b2", 20), + ("综合素养", "#16a34a", 30), ("兴趣生活", "#db2777", 40), ]; for (v, c, s) in dims { - let h = hlc_next(conn); - let _ = conn.execute( - "INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)", - rusqlite::params![v, c, s, uuid_v7(), h, get_device_id(conn)], - ); + let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "dimension" && *n == v); + if let Some((_, _, _, uuid)) = fixed { + let _ = conn.execute( + "INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)", + rusqlite::params![v, c, s, uuid, "0:0:builtin", "builtin"], + ); + } } - } - let mcnt: i64 = conn - .query_row("SELECT COUNT(*) FROM metric_def WHERE deleted_at IS NULL", [], |r| r.get(0)) - .unwrap_or(0); - if mcnt == 0 { - let h = hlc_next(conn); - let _ = conn.execute( - "INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)", - rusqlite::params!["volunteer_hours", "志愿时长", "小时", "sum", "志愿服务", uuid_v7(), h, get_device_id(conn)], - ); - } - let bcnt: i64 = conn - .query_row("SELECT COUNT(*) FROM grade_band WHERE deleted_at IS NULL", [], |r| r.get(0)) - .unwrap_or(0); - if bcnt == 0 { let bands = [ - ("优秀", "#16803c", 90.0, 100.0, 10), - ("良好", "#0891b2", 80.0, 90.0, 20), - ("中等", "#d97706", 70.0, 80.0, 30), - ("及格", "#ea580c", 60.0, 70.0, 40), - ("不及格", "#dc2626", 0.0, 60.0, 50), + ("优秀", "#16803c", 90.0, 100.0, 10), ("良好", "#0891b2", 80.0, 90.0, 20), + ("中等", "#d97706", 70.0, 80.0, 30), ("及格", "#ea580c", 60.0, 70.0, 40), + ("不及格", "#dc2626", 0.0, 60.0, 50), ]; for (label, color, min, max, sort) in bands { - let h = hlc_next(conn); + let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "grade_band" && *n == label); + if let Some((_, _, _, uuid)) = fixed { + let _ = conn.execute( + "INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)", + rusqlite::params![label, color, min, max, sort, uuid, "0:0:builtin", "builtin"], + ); + } + } + let h = "0:0:builtin"; + let fixed = FIXED_BUILTIN.iter().find(|(t, c, n, _)| *t == "metric_def" && *n == "volunteer_hours"); + if let Some((_, _, _, uuid)) = fixed { let _ = conn.execute( - "INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)", - rusqlite::params![label, color, min, max, sort, uuid_v7(), h, get_device_id(conn)], + "INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)", + rusqlite::params!["volunteer_hours", "志愿时长", "小时", "sum", "志愿服务", uuid, h, "builtin"], ); } } diff --git a/sync-server/src/auth.mjs b/sync-server/src/auth.mjs index 21702dd..2886bd5 100644 --- a/sync-server/src/auth.mjs +++ b/sync-server/src/auth.mjs @@ -4,14 +4,28 @@ import { getDb } from './db.mjs'; export function register({ username, password, device_name, invite_code }) { const d = getDb(); - // 多用户管控:邀请码(设置后必填)+ 用户数上限(0=不限) - const invite = process.env.INVITE_CODE; - if (invite && invite_code !== invite) throw new Error('邀请码无效'); + // 多用户管控:管理员颁发邀请码;env INVITE_CODE 作为静态兜底 const maxUsers = Number(process.env.MAX_USERS || 0); if (maxUsers > 0) { const cnt = d.prepare('SELECT COUNT(*) AS c FROM users').get().c; if (cnt >= maxUsers) throw new Error('注册名额已满,请联系管理员'); } + const userCount = d.prepare('SELECT COUNT(*) AS c FROM users').get().c; + let isAdmin = 0; + if (userCount === 0) { + isAdmin = 1; // 首位注册者自动成为管理员 + } else { + const code = String(invite_code || '').trim().toUpperCase(); + if (code) { + const row = d.prepare('SELECT id, valid FROM invite_codes WHERE code=?').get(code); + if (!row || !row.valid) throw new Error('邀请码无效或已使用'); + var __usedInviteId = row.id; // eslint-disable-line no-var + } else if (process.env.INVITE_CODE && invite_code === process.env.INVITE_CODE) { + // 静态兜底码 + } else { + throw new Error('需要邀请码,请联系管理员'); + } + } if (!/^[a-zA-Z0-9_-]{3,32}$/.test(username || '')) throw new Error('用户名需为 3-32 位字母数字-_'); if (!password || password.length < 8) throw new Error('密码至少 8 位'); const exists = d.prepare('SELECT 1 FROM users WHERE username=?').get(username); @@ -20,8 +34,12 @@ export function register({ username, password, device_name, invite_code }) { const salt = crypto.randomBytes(16).toString('hex'); const hash = crypto.scryptSync(password, salt, 64).toString('hex'); const user_id = crypto.randomUUID(); - d.prepare('INSERT INTO users (id, username, password_hash, salt) VALUES (?,?,?,?)') - .run(user_id, username, hash, salt); + d.prepare('INSERT INTO users (id, username, password_hash, salt, is_admin) VALUES (?,?,?,?,?)') + .run(user_id, username, hash, salt, isAdmin); + if (typeof __usedInviteId === 'number') { + d.prepare("UPDATE invite_codes SET used_by=?, used_at=datetime('now','localtime'), valid=0 WHERE id=?") + .run(user_id, __usedInviteId); + } return issueToken(user_id, device_name); } @@ -56,6 +74,12 @@ function issueToken(user_id, device_name) { return { token, user_id, device_id, device_name: name, expires_at: expires }; } +export function isUserAdmin(user_id) { + const d = getDb(); + const row = d.prepare('SELECT is_admin FROM users WHERE id=?').get(user_id); + return !!row && !!row.is_admin; +} + export function authUser(req) { const d = getDb(); const header = req.headers['authorization'] || ''; diff --git a/sync-server/src/db.mjs b/sync-server/src/db.mjs index 7dc2b8b..89dbb9d 100644 --- a/sync-server/src/db.mjs +++ b/sync-server/src/db.mjs @@ -81,6 +81,18 @@ CREATE TABLE IF NOT EXISTS attachments ( PRIMARY KEY (user_id, sha256) ); `); + // 管理员与邀请码(动态颁发制;首个注册用户自动成为管理员) + const hasAdmin = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('users') WHERE name='is_admin'").get().c; + if (Number(hasAdmin) === 0) db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;"); + db.exec(`CREATE TABLE IF NOT EXISTS invite_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + created_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + used_by TEXT, + used_at TEXT, + valid INTEGER NOT NULL DEFAULT 1 + );`); return db; } diff --git a/sync-server/src/index.mjs b/sync-server/src/index.mjs index dac7954..9a074dc 100644 --- a/sync-server/src/index.mjs +++ b/sync-server/src/index.mjs @@ -5,7 +5,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { getDb } from './db.mjs'; import { createAppCommands } from './app-commands.mjs'; -import { register, login, authUser } from './auth.mjs'; +import { register, login, authUser, isUserAdmin } from './auth.mjs'; +import crypto from 'node:crypto'; import { push, pull } from './sync.mjs'; import { upload, download } from './attachments.mjs'; @@ -64,21 +65,52 @@ const server = http.createServer(async (req, res) => { return send(200, true, login(body)); } - // ---- 管理(X-Admin-Token)---- - if (req.method === 'GET' && route === '/admin/users') { - const expect = process.env.ADMIN_TOKEN; - if (!expect || req.headers['x-admin-token'] !== expect) { - return send(403, false, '管理接口未启用或令牌错误'); - } + // ---- 管理(仅 is_admin 登录会话) ---- + if (route === '/admin/users' || route === '/admin/invites' || route.startsWith('/admin/invites/generate') || route.startsWith('/admin/invites/revoke')) { + let auth; + try { auth = authUser(req); } + catch (e) { return send(e.code || 401, false, e.message); } + if (!isUserAdmin(auth.user_id)) return send(403, false, '需要管理员账号'); + const db = getDb(); - const users = db.prepare(` - SELECT u.id, u.username, u.created_at, - (SELECT COUNT(*) FROM devices dv WHERE dv.user_id = u.id) AS devices, - (SELECT COUNT(*) FROM current c WHERE c.user_id = u.id AND c.op = 'upsert') AS records, - (SELECT COUNT(*) FROM attachments a WHERE a.user_id = u.id) AS attachments, - (SELECT COALESCE(SUM(size), 0) FROM attachments a WHERE a.user_id = u.id) AS attachment_bytes - FROM users u ORDER BY u.created_at`).all(); - return send(200, true, { total: users.length, users }); + if (req.method === 'GET' && route === '/admin/users') { + const users = db.prepare(` + SELECT u.id, u.username, u.is_admin, u.created_at, + (SELECT COUNT(*) FROM devices dv WHERE dv.user_id = u.id) AS devices, + (SELECT COUNT(*) FROM current c WHERE c.user_id = u.id AND c.op = 'upsert') AS records, + (SELECT COUNT(*) FROM attachments a WHERE a.user_id = u.id) AS attachments, + (SELECT COALESCE(SUM(size), 0) FROM attachments a WHERE a.user_id = u.id) AS attachment_bytes + FROM users u ORDER BY u.created_at`).all() + .map(u => ({ ...u, is_admin: !!u.is_admin })); + return send(200, true, { total: users.length, users }); + } + if (req.method === 'GET' && route === '/admin/invites') { + const invites = db.prepare(` + SELECT i.id, i.code, i.created_at, i.used_by, i.used_at, i.valid, + u.username AS used_by_name + FROM invite_codes i LEFT JOIN users u ON u.id = i.used_by + ORDER BY i.id DESC`).all() + .map(i => ({ ...i, valid: !!i.valid })); + return send(200, true, { total: invites.length, invites }); + } + if (req.method === 'POST' && route === '/admin/invites/generate') { + const body = await readJson(req); + const count = Math.min(50, Math.max(1, Number(body.count) || 1)); + const codes = []; + const ins = db.prepare('INSERT INTO invite_codes (code, created_by) VALUES (?,?)'); + for (let i = 0; i < count; i++) { + const code = 'FP-' + crypto.randomBytes(5).toString('hex').toUpperCase(); + ins.run(code, auth.user_id); + codes.push(code); + } + return send(200, true, { codes }); + } + if (req.method === 'POST' && route === '/admin/invites/revoke') { + const body = await readJson(req); + const r = db.prepare('UPDATE invite_codes SET valid=0 WHERE id=?').run(Number(body.id)); + return send(200, true, { revoked: r }); + } + return send(404, false, 'not found'); } // ---- 同步与 Web API(鉴权区) ---- @@ -88,8 +120,8 @@ const server = http.createServer(async (req, res) => { catch (e) { return send(e.code || 401, false, e.message); } if (req.method === 'GET' && route === '/auth/me') { - const row = getDb().prepare('SELECT id, username FROM users WHERE id=?').get(auth.user_id); - return send(200, true, { user_id: auth.user_id, username: row?.username ?? '', device_id: auth.device_id }); + const row = getDb().prepare('SELECT id, username, is_admin FROM users WHERE id=?').get(auth.user_id); + return send(200, true, { user_id: auth.user_id, username: row?.username ?? '', is_admin: !!row?.is_admin, device_id: auth.device_id }); } if (req.method === 'POST' && route === '/sync/push') {