From dc520bfac0e5e94db7db737d62aeb1a6b2500a2f Mon Sep 17 00:00:00 2001 From: root Date: Fri, 16 Oct 2026 15:24:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E5=91=98=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E4=BD=93=E7=B3=BB=20+=20=E5=8A=A8=E6=80=81=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E7=A0=81=E7=AE=A1=E7=90=86;=20=E5=90=84=E7=AB=AF?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E6=95=B0=E6=8D=AE=E5=9B=BA=E5=AE=9A=20uuid?= =?UTF-8?q?=20=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.builtin-test/footprints.db | Bin 0 -> 217088 bytes app/build-log.txt | Bin 0 -> 3110 bytes app/server/db.mjs | 171 ++++++++++++++++++++++---------- app/src-tauri/src/lib.rs | 156 ++++++++++++++++++----------- sync-server/src/auth.mjs | 34 ++++++- sync-server/src/db.mjs | 12 +++ sync-server/src/index.mjs | 66 ++++++++---- 7 files changed, 305 insertions(+), 134 deletions(-) create mode 100644 app/.builtin-test/footprints.db create mode 100644 app/build-log.txt diff --git a/app/.builtin-test/footprints.db b/app/.builtin-test/footprints.db new file mode 100644 index 0000000000000000000000000000000000000000..6ccc55044139d4e6b2763483574fa1dd3fdc4f6e GIT binary patch literal 217088 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 literal 0 HcmV?d00001 diff --git a/app/build-log.txt b/app/build-log.txt new file mode 100644 index 0000000000000000000000000000000000000000..3ffe817809d3ee8a31baf5a63183d6f687708727 GIT binary patch literal 3110 zcmdUxT~8BH5QgX4#Q$*83j=B!z>h>O3{oK>5mCf&!5S#-f)yy6wkT+#xBhDNdFOE0 zEiA?>3E6CScg~#oc;}rt{r&5%-LdBuY|oCl##Y$S`ZnNgWKHYvUD$%nSj%R)XV4rY zJ#npIZJW2u-lLl%8S(zYdUoU%`h1U(cKAK!nb}L8UG^TK9X(`d=Ujzb$n5~1AK5Wv zJx3;s%QcZ@=aHr8t1&ff7ax5)#CF4^p?iQ8`3giFu3Rgu3xQy-+}nX?u+R4iu?7E9 zHXW=UVx@a$>_OY|Mv_qeqxYhAL*eI+<5BYuTPl*`Iz)`eUj{Q=LGUy68PcWQHW zZSaGbe$LwybT_x|eN*(ap3wu|%B-|6bFarlDBKEG3;eb4_6f;k#3e?-?mGiYNfQgl zWYWUkDYv4Lx9|VulDAYLd2KslIq`@^XI9oKX_Zf*^BTRey(4!a->0s9-g;j5sSC?# zPiak+kLasr*Vkqq+v2slWZ$N-xMd&gAGaxM*6FSVnOs;89aCLYvP!8V+e!MjAe#b+^G-Jz~b zQ4Enb$U&Z3wez8cP0fb}nwkJ(w<8}r$jbf@1I6w=?1+ha*Pc3`JIEDB&l&p#oiXvs zV)CZ9n1v>ctc;wYu^SY_5DPKxIBArb>bdK3O)o{OozhOR3I8q<*)09ayL4lwQ76_x zSzJaj_kL0QsiT8utM1o?Sc5yQ^JrC>wxe6%S(e48`bn%gQJuk4H$HhYG8~dovr^~j zN@jt%t&CKi)K5EJ8S$-yG^eHk_K4@|(a6IszGBbl6#0r)kM4@IYY0AK*%Z6gcd{RB z&K`s5BYqPP66f_?q}sBrN?%t))l(gI)2CS-rkd&M+?aWG3Bk+}uRFV2Aa%-JHw#w4 zEuMC7upLjM#J<~z!DRl*IR3jx7*zXNr(8Mp#!O$G6Ol`@GxSbPGh3{Py zDpk_1Q+o;{C(&Pb-|C6j>6!@#cuyYEzlPWoJ+yU#+%8@! { + 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') {