use rusqlite::Connection; use serde_json::{json, Value}; use std::sync::Mutex; use tauri::Manager; const SCHEMA_SQL: &str = include_str!("../../schema.sql"); pub struct AppData { conn: Mutex, data_dir: std::path::PathBuf, } #[derive(Clone)] enum P { S(String), I(i64), R(f64), N, } impl rusqlite::ToSql for P { fn to_sql(&self) -> rusqlite::Result> { use rusqlite::types::{ToSqlOutput, Value as RV}; let v = match self { P::S(s) => RV::Text(s.clone()), P::I(i) => RV::Integer(*i), P::R(f) => RV::Real(*f), P::N => RV::Null, }; Ok(ToSqlOutput::Owned(v)) } } fn pval(v: &Value) -> P { match v { Value::Number(n) => { if let Some(i) = n.as_i64() { P::I(i) } else { P::R(n.as_f64().unwrap_or(0.0)) } } Value::String(s) => P::S(s.clone()), Value::Bool(b) => P::I(if *b { 1 } else { 0 }), _ => P::N, } } fn pstr(v: &Value) -> P { match v { Value::String(s) => P::S(s.clone()), Value::Number(n) => P::S(n.to_string()), _ => P::N, } } fn rows_to_json( conn: &Connection, sql: &str, params: &[P], ) -> Result, String> { let mut stmt = conn.prepare(sql).map_err(e)?; let names: Vec = (0..stmt.column_count()) .map(|i| stmt.column_name(i).unwrap_or("").to_string()) .collect(); let mut out = Vec::new(); let mut rows = stmt .query(rusqlite::params_from_iter(params.iter())) .map_err(e)?; while let Some(row) = rows.next().map_err(e)? { let mut obj = serde_json::Map::new(); for (i, name) in names.iter().enumerate() { let v = match row.get_ref(i).map_err(e)? { rusqlite::types::ValueRef::Null => Value::Null, rusqlite::types::ValueRef::Integer(n) => json!(n), rusqlite::types::ValueRef::Real(f) => json!(f), rusqlite::types::ValueRef::Text(t) => { json!(String::from_utf8_lossy(t).to_string()) } rusqlite::types::ValueRef::Blob(b) => { use base64::Engine; json!(base64::engine::general_purpose::STANDARD.encode(b)) } }; obj.insert(name.clone(), v); } out.push(Value::Object(obj)); } Ok(out) } // 返回最近一次 INSERT 的 rowid(UPDATE/DELETE 的调用方不使用该值) fn exec(conn: &Connection, sql: &str, params: &[P]) -> Result { let mut stmt = conn.prepare(sql).map_err(e)?; stmt.execute(rusqlite::params_from_iter(params.iter())).map_err(e)?; Ok(conn.last_insert_rowid()) } fn e(err: rusqlite::Error) -> String { err.to_string() } // ---------- 同步基础设施 ---------- fn get_meta(conn: &Connection, key: &str) -> Option { conn.query_row("SELECT value FROM sync_state WHERE key=?1", [key], |r| r.get(0)).ok() } fn set_meta(conn: &Connection, key: &str, value: &str) { let _ = conn.execute( "INSERT INTO sync_state (key, value) VALUES (?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value", rusqlite::params![key, value], ); } fn get_device_id(conn: &Connection) -> String { if let Some(id) = get_meta(conn, "device_id") { return id; } let s = uuid::Uuid::now_v7().simple().to_string(); let id: String = s.chars().take(12).collect(); set_meta(conn, "device_id", &id); id } fn uuid_v7() -> String { uuid::Uuid::now_v7().to_string() } // 混合逻辑时钟:毫秒:计数:设备 —— 时钟回拨免疫,同设备单调,跨设备可比较 fn hlc_next(conn: &Connection) -> String { let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64; let last_ms: i64 = get_meta(conn, "hlc_ms").and_then(|v| v.parse().ok()).unwrap_or(0); let last_cnt: i64 = get_meta(conn, "hlc_counter").and_then(|v| v.parse().ok()).unwrap_or(0); let (new_ms, new_cnt) = if now_ms > last_ms { (now_ms, 0) } else { (last_ms, last_cnt + 1) }; set_meta(conn, "hlc_ms", &new_ms.to_string()); set_meta(conn, "hlc_counter", &new_cnt.to_string()); format!("{}:{}:{}", new_ms, new_cnt, get_device_id(conn)) } fn touch_oplog(conn: &Connection, table: &str, row_uuid: &str, op: &str, hlc: &str) { let _ = conn.execute( "INSERT INTO sync_oplog (table_name, row_uuid, op, hlc) VALUES (?1,?2,?3,?4)", rusqlite::params![table, row_uuid, op, hlc], ); } const SYNC_TABLES: [&str; 10] = [ "footprint", "footprint_type", "taxonomy", "dimension", "attachment", "semester", "course_grade", "metric_def", "saved_view", "grade_band", ]; fn add_sync_cols(conn: &Connection, t: &str, extra_sha: bool) { let exists: i64 = conn .query_row("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", [t], |r| r.get(0)) .unwrap_or(0); if exists == 0 { return; // 全新库:表由 schema 创建,自带同步列 } let mut have: Vec = Vec::new(); if let Ok(mut stmt) = conn.prepare(&format!("PRAGMA table_info({})", t)) { if let Ok(mut rows) = stmt.query([]) { while let Ok(Some(row)) = rows.next() { if let Ok(name) = row.get::<_, String>(1) { have.push(name); } } } } let mut cols: Vec<(&str, &str)> = vec![ ("uuid", "TEXT"), ("deleted_at", "TEXT"), ("origin", "TEXT"), ("hlc", "TEXT"), ]; if extra_sha { cols.push(("sha256", "TEXT")); } for (name, ddl) in cols { if !have.iter().any(|x| x == name) { let _ = conn.execute_batch(&format!("ALTER TABLE {} ADD COLUMN {} {}", t, name, ddl)); } } } fn pre_migrate_columns(conn: &Connection) { for t in SYNC_TABLES { add_sync_cols(conn, t, false); } add_sync_cols(conn, "attachment", true); } fn migrate_sync(conn: &Connection) { for t in SYNC_TABLES { let mut ids: Vec = Vec::new(); if let Ok(mut stmt) = conn.prepare(&format!("SELECT id FROM {} WHERE uuid IS NULL", t)) { if let Ok(mut rows) = stmt.query([]) { while let Ok(Some(row)) = rows.next() { if let Ok(id) = row.get::<_, i64>(0) { ids.push(id); } } } } for id in ids { let uuid = uuid_v7(); let h = hlc_next(conn); let _ = conn.execute( &format!("UPDATE {} SET uuid=?1, hlc=?2, origin='migrate' WHERE id=?3", t), rusqlite::params![uuid, h, id], ); } } } fn soft_delete(conn: &Connection, table: &str, id: i64) { let uuid: Option = conn .query_row(&format!("SELECT uuid FROM {} WHERE id=?1", table), [id], |r| r.get(0)) .ok(); if let Some(uuid) = uuid { let h = hlc_next(conn); let _ = conn.execute( &format!("UPDATE {} SET deleted_at=datetime('now','localtime'), hlc=?1 WHERE id=?2", table), rusqlite::params![h, id], ); touch_oplog(conn, table, &uuid, "delete", &h); } } 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 { 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":["论文","专利","软件著作权","研究报告"]}]"#), ("实习实践", "briefcase", "#0f766e", 40, r#"[{"key":"position","label":"岗位","widget":"text"},{"key":"mentor","label":"带教人","widget":"text"}]"#), ("学生工作", "network", "#d97706", 50, r#"[{"key":"duties","label":"职责","widget":"textarea"},{"key":"term","label":"任期","widget":"text"}]"#), ("志愿公益", "heart-handshake", "#16a34a", 60, r#"[{"key":"hours","label":"志愿时长","widget":"number","unit":"小时","metric":"volunteer_hours"},{"key":"contact","label":"组织负责人","widget":"text"}]"#), ("文体活动", "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 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 levels = [ ("国际级", "#7c3aed", 10), ("国家级", "#dc2626", 20), ("省市级", "#ea580c", 30), ("区县级", "#2563eb", 40), ("校级", "#16a34a", 50), ("院级", "#0891b2", 60), ]; for (v, c, s) in levels { 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 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 dims = [ ("学业深造", "#3056d3", 10), ("职业发展", "#0891b2", 20), ("综合素养", "#16a34a", 30), ("兴趣生活", "#db2777", 40), ]; for (v, c, s) in dims { 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 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), ]; for (label, color, min, max, sort) in bands { 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 metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)", rusqlite::params!["volunteer_hours", "志愿时长", "小时", "sum", "志愿服务", uuid, h, "builtin"], ); } } } fn map_row(v: &mut Value) { if let Some(obj) = v.as_object_mut() { for key in ["metrics", "extras"] { if let Some(Value::String(s)) = obj.get(key) { let parsed: Value = serde_json::from_str(s).unwrap_or(json!({})); obj.insert(key.to_string(), parsed); } } if let Some(Value::String(s)) = obj.get("tags") { let parsed: Value = serde_json::from_str(s).unwrap_or(json!([])); obj.insert("tags".into(), parsed); } if let Some(Value::String(s)) = obj.get("dim_ids") { let dims: Vec = s .split(',') .filter_map(|x| x.trim().parse::().ok()) .map(|x| json!(x)) .collect(); obj.insert("dimensions".into(), json!(dims)); } else { obj.insert("dimensions".into(), json!([])); } obj.remove("dim_ids"); } } // 中文检索:长度>=3 的词走 FTS5 trigram(子串匹配),更短的词回退 LIKE const BASE_SELECT: &str = "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"; fn build_where(query: &Value) -> (String, Vec

) { let mut conds: Vec = Vec::new(); let mut params: Vec

= Vec::new(); let mut joins = String::new(); if let Some(q) = query.get("q").and_then(|v| v.as_str()) { let mut fts_terms: Vec = Vec::new(); for raw in q.split_whitespace() { let clean: String = raw.chars().filter(|c| *c != '"' && *c != '\\').collect(); if clean.trim().is_empty() { continue; } if clean.chars().count() >= 3 { fts_terms.push(format!("\"{}\"", clean)); } else { conds.push("fp.search_text LIKE ?".into()); params.push(P::S(format!("%{}%", clean))); } } if !fts_terms.is_empty() { joins.push_str(" JOIN footprint_fts ON footprint_fts.rowid = fp.id"); conds.push("footprint_fts MATCH ?".into()); params.push(P::S(fts_terms.join(" AND "))); } } for (key, col) in [("types", "fp.type_id"), ("levels", "fp.level"), ("roles", "fp.role"), ("statuses", "fp.status")] { if let Some(arr) = query.get(key).and_then(|v| v.as_array()) { if !arr.is_empty() { let ph = arr.iter().map(|_| "?").collect::>().join(","); conds.push(format!("{} IN ({})", col, ph)); for v in arr { params.push(if col.ends_with("type_id") { pval(v) } else { pstr(v) }); } } } } if let Some(arr) = query.get("dims").and_then(|v| v.as_array()) { if !arr.is_empty() { let ph = arr.iter().map(|_| "?").collect::>().join(","); conds.push(format!( "fp.id IN (SELECT footprint_id FROM footprint_dimension WHERE dimension_id IN ({}))", ph )); for v in arr { params.push(pval(v)); } } } if let Some(y) = query.get("year").and_then(|v| v.as_i64()) { conds.push("strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = ?".into()); params.push(P::S(y.to_string())); } if let Some(p) = query.get("polarity").and_then(|v| v.as_str()) { conds.push("fp.polarity = ?".into()); params.push(P::S(p.to_string())); } if query.get("has_attachment").and_then(|v| v.as_bool()).unwrap_or(false) { conds.push("EXISTS (SELECT 1 FROM attachment a WHERE a.owner_type='footprint' AND a.owner_id=fp.id)".into()); } if let Some(t) = query.get("tag").and_then(|v| v.as_str()) { conds.push("fp.tags LIKE ?".into()); params.push(P::S(format!("%\"{}\"", t))); } let where_sql = if conds.is_empty() { " WHERE fp.deleted_at IS NULL".to_string() } else { format!(" WHERE fp.deleted_at IS NULL AND {}", conds.join(" AND ")) }; (format!("{}{}", joins, where_sql), params) } const SORTS: [(&str, &str); 4] = [ ("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"), ]; fn list_footprints(conn: &Connection, query: &Value) -> Result { let (where_join, params) = build_where(query); let page = query.get("page").and_then(|v| v.as_i64()).unwrap_or(1).max(1); let page_size = query.get("page_size").and_then(|v| v.as_i64()).unwrap_or(20).clamp(1, 2000); let sort = query.get("sort").and_then(|v| v.as_str()).unwrap_or("date_desc"); let order = SORTS.iter().find(|(k, _)| *k == sort).map(|(_, v)| *v).unwrap_or(SORTS[0].1); let total: i64 = conn .query_row( &format!("SELECT COUNT(*) FROM footprint fp{}", where_join), rusqlite::params_from_iter(params.iter()), |r| r.get(0), ) .map_err(e)?; let sql = format!( "{} {} ORDER BY {} LIMIT {} OFFSET {}", BASE_SELECT, where_join, order, page_size, (page - 1) * page_size ); let mut items = rows_to_json(conn, &sql, ¶ms)?; for it in items.iter_mut() { map_row(it); } Ok(json!({ "items": items, "total": total, "page": page, "page_size": page_size })) } fn num_or_null(v: &Value) -> P { match v { Value::Number(_) => pval(v), Value::String(s) if !s.is_empty() => s.parse::().map(P::I).unwrap_or(P::N), _ => P::N, } } fn s_or_null(v: &Value) -> P { match v { Value::String(s) if !s.is_empty() => P::S(s.clone()), Value::Number(n) => P::S(n.to_string()), _ => P::N, } } fn dispatch(state: &AppData, cmd: &str, payload: &Value) -> Result { let conn = state.conn.lock().map_err(|_| "数据库忙".to_string())?; match cmd { "app_info" => { let pending: i64 = conn.query_row("SELECT COUNT(*) FROM sync_oplog WHERE synced_at IS NULL", [], |r| r.get(0)).unwrap_or(0); Ok(json!({ "app_name": "Footprints 足迹", "version": "0.2.0", "db_path": state.data_dir.join("footprints.db").to_string_lossy(), "attachment_dir": state.data_dir.join("attachments").to_string_lossy(), "schema_ok": true, "device_id": get_device_id(&conn), "pending_ops": pending, "last_sync_at": get_meta(&conn, "last_sync_at") })) } "sync_status" => { let pending: i64 = conn.query_row("SELECT COUNT(*) FROM sync_oplog WHERE synced_at IS NULL", [], |r| r.get(0)).unwrap_or(0); Ok(json!({ "mode": "local", "device_id": get_device_id(&conn), "pending_ops": pending, "last_sync_at": get_meta(&conn, "last_sync_at") })) } "type_list" => { let mut items = rows_to_json( &conn, "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", &[], )?; for it in items.iter_mut() { if let Some(obj) = it.as_object_mut() { if let Some(Value::String(s)) = obj.get("form_template") { let parsed: Value = serde_json::from_str(s).unwrap_or(json!([])); obj.insert("form_template".into(), parsed); } for k in ["is_builtin", "closed"] { let b = obj.get(k).map(|v| v.as_i64().unwrap_or(0) != 0).unwrap_or(false); obj.insert(k.into(), json!(b)); } } } Ok(json!({ "items": items })) } "type_upsert" => { let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or(""); if name.trim().is_empty() { return Err("类型名称不能为空".into()); } let tpl = serde_json::to_string(payload.get("form_template").unwrap_or(&json!([]))).unwrap(); let icon = payload.get("icon").and_then(|v| v.as_str()).unwrap_or("tag"); let color = payload.get("color").and_then(|v| v.as_str()).unwrap_or("#64748b"); let sort = payload.get("sort").and_then(|v| v.as_i64()).unwrap_or(100); let closed = payload.get("closed").and_then(|v| v.as_bool()).unwrap_or(false); if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) { let h = hlc_next(&conn); exec(&conn, "UPDATE footprint_type SET name=?, icon=?, color=?, sort=?, form_template=?, closed=?, hlc=? WHERE id=?", &[P::S(name.trim().into()), P::S(icon.into()), P::S(color.into()), P::I(sort), P::S(tpl), P::I(closed as i64), P::S(h.clone()), P::I(id)])?; if let Some(u) = get_row_uuid(&conn, "footprint_type", id) { touch_oplog(&conn, "footprint_type", &u, "upsert", &h); } Ok(json!({ "item": { "id": id } })) } else { let h = hlc_next(&conn); let new_uuid = uuid_v7(); let r = exec(&conn, "INSERT INTO footprint_type (name, icon, color, sort, form_template, closed, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?)", &[P::S(name.trim().into()), P::S(icon.into()), P::S(color.into()), P::I(sort), P::S(tpl), P::I(closed as i64), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?; touch_oplog(&conn, "footprint_type", &new_uuid, "upsert", &h); Ok(json!({ "item": { "id": r as i64 } })) } } "type_delete" => { let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0); let used: i64 = conn .query_row("SELECT COUNT(*) FROM footprint WHERE type_id=?1 AND deleted_at IS NULL", [id], |r| r.get(0)) .map_err(e)?; if used > 0 { return Err(format!("该类型下仍有 {} 条记录,请先迁移后再删除", used)); } let row: Option<(i64, Option)> = conn .query_row("SELECT is_builtin, uuid FROM footprint_type WHERE id=?1", [id], |r| { Ok((r.get(0)?, r.get(1)?)) }) .ok(); if let Some((builtin, _)) = row { if builtin != 0 { return Err("内置类型不可删除".into()); } soft_delete(&conn, "footprint_type", id); } Ok(json!({ "ok": true })) } "taxonomy_list" => { let items = match payload.get("kind").and_then(|v| v.as_str()) { Some(kind) => rows_to_json(&conn, "SELECT * FROM taxonomy WHERE kind=?1 AND deleted_at IS NULL ORDER BY sort, id", &[P::S(kind.into())])?, None => rows_to_json(&conn, "SELECT * FROM taxonomy WHERE deleted_at IS NULL ORDER BY kind, sort, id", &[])?, }; let items: Vec = items .into_iter() .map(|mut it| { if let Some(obj) = it.as_object_mut() { let b = obj.get("is_builtin").map(|v| v.as_i64().unwrap_or(0) != 0).unwrap_or(false); obj.insert("is_builtin".into(), json!(b)); } it }) .collect(); Ok(json!({ "items": items })) } "taxonomy_upsert" => { let kind = payload.get("kind").and_then(|v| v.as_str()).unwrap_or("level"); let value = payload.get("value").and_then(|v| v.as_str()).unwrap_or(""); let color = payload.get("color").and_then(|v| v.as_str()); let sort = payload.get("sort").and_then(|v| v.as_i64()).unwrap_or(100); if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) { let h = hlc_next(&conn); exec(&conn, "UPDATE taxonomy SET kind=?, value=?, color=?, sort=?, hlc=? WHERE id=?", &[P::S(kind.into()), P::S(value.into()), s_or_null(&json!(color)), P::I(sort), P::S(h.clone()), P::I(id)])?; if let Some(u) = get_row_uuid(&conn, "taxonomy", id) { touch_oplog(&conn, "taxonomy", &u, "upsert", &h); } Ok(json!({ "item": { "id": id } })) } else { let h = hlc_next(&conn); let new_uuid = uuid_v7(); let r = exec(&conn, "INSERT INTO taxonomy (kind, value, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)", &[P::S(kind.into()), P::S(value.into()), s_or_null(&json!(color)), P::I(sort), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?; touch_oplog(&conn, "taxonomy", &new_uuid, "upsert", &h); Ok(json!({ "item": { "id": r as i64 } })) } } "taxonomy_delete" => { let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0); let builtin: i64 = conn .query_row("SELECT is_builtin FROM taxonomy WHERE id=?1", [id], |r| r.get(0)) .map_err(e)?; if builtin != 0 { return Err("内置条目不可删除".into()); } soft_delete(&conn, "taxonomy", id); Ok(json!({ "ok": true })) } "dimension_list" => Ok(json!({ "items": rows_to_json(&conn, "SELECT * FROM dimension WHERE deleted_at IS NULL ORDER BY sort, id", &[])? })), "dimension_upsert" => { let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or(""); let color = payload.get("color").and_then(|v| v.as_str()); let sort = payload.get("sort").and_then(|v| v.as_i64()).unwrap_or(100); if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) { let h = hlc_next(&conn); exec(&conn, "UPDATE dimension SET name=?, color=?, sort=?, hlc=? WHERE id=?", &[P::S(name.into()), s_or_null(&json!(color)), P::I(sort), P::S(h.clone()), P::I(id)])?; if let Some(u) = get_row_uuid(&conn, "dimension", id) { touch_oplog(&conn, "dimension", &u, "upsert", &h); } Ok(json!({ "item": { "id": id } })) } else { let h = hlc_next(&conn); let new_uuid = uuid_v7(); let r = exec(&conn, "INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)", &[P::S(name.into()), s_or_null(&json!(color)), P::I(sort), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?; touch_oplog(&conn, "dimension", &new_uuid, "upsert", &h); Ok(json!({ "item": { "id": r as i64 } })) } } "dimension_delete" => { soft_delete(&conn, "dimension", payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0)); Ok(json!({ "ok": true })) } "footprint_list" => list_footprints(&conn, payload.get("query").unwrap_or(&json!({}))), "footprint_get" => { let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0); let mut row = rows_to_json(&conn, &format!("{} AND fp.id=?1", BASE_SELECT), &[P::I(id)])? .into_iter().next() .ok_or_else(|| "记录不存在".to_string())?; map_row(&mut row); let attachments = rows_to_json(&conn, "SELECT * FROM attachment WHERE owner_type='footprint' AND owner_id=?1 AND deleted_at IS NULL ORDER BY id", &[P::I(id)])?; if let Some(obj) = row.as_object_mut() { obj.insert("attachments".into(), json!(attachments)); } Ok(json!({ "item": row })) } "footprint_upsert" => { let default_item = json!({}); let it = payload.get("item").unwrap_or(&default_item); let type_id = it.get("type_id").and_then(|v| v.as_i64()) .ok_or_else(|| "缺少记录类型".to_string())?; let title = it.get("title").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); if title.is_empty() { return Err("缺少标题".into()); } let sget = |k: &str| it.get(k).and_then(|v| v.as_str()).map(|s| s.to_string()); let description = sget("description").unwrap_or_default(); let result = sget("result").unwrap_or_default(); let tags: Vec = it.get("tags").and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) .unwrap_or_default(); let extras = it.get("extras").cloned().unwrap_or(json!({})); let metrics = it.get("metrics").cloned().unwrap_or(json!({})); let mut search_parts: Vec = vec![title.clone(), description.clone(), result.clone(), tags.join(" ")]; if let Some(obj) = extras.as_object() { for (_, v) in obj { match v { Value::String(s) => search_parts.push(s.clone()), Value::Array(a) => search_parts.push(a.iter().map(|x| x.to_string()).collect::>().join(" ")), Value::Number(n) => search_parts.push(n.to_string()), _ => {} } } } let search_text = search_parts.join("\n"); let tags_json = serde_json::to_string(&tags).unwrap(); let id = it.get("id").and_then(|v| v.as_i64()); let final_id = match id { Some(fid) => { let h = hlc_next(&conn); exec(&conn, &format!( "UPDATE footprint SET type_id=?1, title=?2, role=?3, level=?4, polarity=?5, award_grade=?6, rank=?7, organizer=?8, start_date=?9, end_date=?10, status=?11, description=?12, result=?13, metrics=?14, extras=?15, group_id=?16, parent_id=?17, tags=?18, weight=?19, search_text=?20, hlc=?21, updated_at=datetime('now','localtime') WHERE id=?22"), &[P::I(type_id), P::S(title.clone()), s_or_null(it.get("role").unwrap_or(&Value::Null)), s_or_null(it.get("level").unwrap_or(&Value::Null)), P::S(sget("polarity").unwrap_or_else(|| "positive".into())), s_or_null(it.get("award_grade").unwrap_or(&Value::Null)), num_or_null(it.get("rank").unwrap_or(&Value::Null)), s_or_null(it.get("organizer").unwrap_or(&Value::Null)), s_or_null(it.get("start_date").unwrap_or(&Value::Null)), s_or_null(it.get("end_date").unwrap_or(&Value::Null)), P::S(sget("status").unwrap_or_else(|| "draft".into())), P::S(description.clone()), P::S(result.clone()), P::S(serde_json::to_string(&metrics).unwrap()), P::S(serde_json::to_string(&extras).unwrap()), s_or_null(it.get("group_id").unwrap_or(&Value::Null)), num_or_null(it.get("parent_id").unwrap_or(&Value::Null)), P::S(tags_json), P::I(it.get("weight").and_then(|v| v.as_i64()).unwrap_or(3)), P::S(search_text), P::S(h.clone()), P::I(fid)])?; if let Some(u) = get_row_uuid(&conn, "footprint", fid) { touch_oplog(&conn, "footprint", &u, "upsert", &h); } fid } None => { let h = hlc_next(&conn); let new_uuid = uuid_v7(); let r = exec(&conn, "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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", &[P::I(type_id), P::S(title.clone()), s_or_null(it.get("role").unwrap_or(&Value::Null)), s_or_null(it.get("level").unwrap_or(&Value::Null)), P::S(sget("polarity").unwrap_or_else(|| "positive".into())), s_or_null(it.get("award_grade").unwrap_or(&Value::Null)), num_or_null(it.get("rank").unwrap_or(&Value::Null)), s_or_null(it.get("organizer").unwrap_or(&Value::Null)), s_or_null(it.get("start_date").unwrap_or(&Value::Null)), s_or_null(it.get("end_date").unwrap_or(&Value::Null)), P::S(sget("status").unwrap_or_else(|| "draft".into())), P::S(description.clone()), P::S(result.clone()), P::S(serde_json::to_string(&metrics).unwrap()), P::S(serde_json::to_string(&extras).unwrap()), s_or_null(it.get("group_id").unwrap_or(&Value::Null)), num_or_null(it.get("parent_id").unwrap_or(&Value::Null)), P::S(tags_json), P::I(it.get("weight").and_then(|v| v.as_i64()).unwrap_or(3)), P::S(search_text), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?; touch_oplog(&conn, "footprint", &new_uuid, "upsert", &h); r as i64 } }; if let Some(dims) = it.get("dimension_ids").and_then(|v| v.as_array()) { exec(&conn, "DELETE FROM footprint_dimension WHERE footprint_id=?1", &[P::I(final_id)])?; for d in dims { if let Some(did) = d.as_i64() { exec(&conn, "INSERT OR IGNORE INTO footprint_dimension (footprint_id, dimension_id) VALUES (?1,?2)", &[P::I(final_id), P::I(did)])?; } } } Ok(json!({ "id": final_id })) } "footprint_delete" => { let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0); let exists: i64 = conn .query_row("SELECT COUNT(*) FROM footprint WHERE id=?1", [id], |r| r.get(0)) .unwrap_or(0); if exists > 0 { // FTS 内容表同步移除,墓碑保留在主表 let _ = conn.execute( "INSERT INTO footprint_fts(footprint_fts, rowid, search_text) SELECT 'delete', id, search_text FROM footprint WHERE id=?1", [id], ); soft_delete(&conn, "footprint", id); } Ok(json!({ "ok": true })) } "timeline" => { let mut q = payload.get("query").cloned().unwrap_or(json!({})); if let Some(obj) = q.as_object_mut() { obj.insert("page_size".into(), json!(2000)); obj.insert("sort".into(), json!("date_desc")); } let res = list_footprints(&conn, &q)?; let items = res.get("items").and_then(|v| v.as_array()).cloned().unwrap_or_default(); let mut groups: Vec = Vec::new(); for row in items { let date = row.get("start_date").and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(|s| s.chars().take(10).collect::()) .or_else(|| row.get("created_at").and_then(|v| v.as_str()).map(|s| s.chars().take(10).collect::())) .unwrap_or_default(); if date.len() < 10 { continue; } let year: i64 = date[..4].parse().unwrap_or(0); let month: i64 = date[5..7].parse().unwrap_or(0); let g = groups.iter_mut().find(|g| g.get("year").and_then(|v| v.as_i64()) == Some(year)); match g { Some(g) => { if let Some(obj) = g.as_object_mut() { *obj.get_mut("count").unwrap() = json!(obj.get("count").and_then(|v| v.as_i64()).unwrap_or(0) + 1); let months = obj.get_mut("months").and_then(|v| v.as_array_mut()).unwrap(); match months.iter_mut().find(|m| m.get("month").and_then(|v| v.as_i64()) == Some(month)) { Some(m) => m.as_object_mut().unwrap().get_mut("items").unwrap().as_array_mut().unwrap().push(row), None => months.push(json!({ "month": month, "items": [row] })), } } } None => groups.push(json!({ "year": year, "count": 1, "months": [{ "month": month, "items": [row] }] })), } } groups.sort_by(|a, b| b.get("year").and_then(|v| v.as_i64()).cmp(&a.get("year").and_then(|v| v.as_i64()))); let total = res.get("total").cloned().unwrap_or(json!(0)); Ok(json!({ "groups": groups, "total": total })) } "stats_overview" => { let year = payload.get("year").and_then(|v| v.as_i64()); let ycond = year .map(|y| format!(" AND strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = '{}'", y)) .unwrap_or_default(); let ycond_raw = year .map(|y| format!(" AND strftime('%Y', COALESCE(NULLIF(start_date,''), substr(created_at,1,10))) = '{}'", y)) .unwrap_or_default(); let total: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM footprint fp WHERE fp.deleted_at IS NULL{}", ycond), [], |r| r.get(0)).map_err(e)?; let cur_year = year.unwrap_or_else(|| chrono_year()); let total_this_year: i64 = conn.query_row( "SELECT COUNT(*) FROM footprint fp WHERE strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = ?1", [cur_year], |r| r.get(0)).map_err(e)?; let attachment_total: i64 = conn.query_row(&format!( "SELECT COUNT(*) FROM attachment a JOIN footprint fp ON fp.id=a.owner_id AND a.owner_type='footprint' WHERE a.deleted_at IS NULL AND fp.deleted_at IS NULL{}", ycond), [], |r| r.get(0)).map_err(e)?; let by_type = rows_to_json(&conn, &format!( "SELECT ft.name AS label, ft.color AS color, COUNT(*) AS count FROM footprint fp JOIN footprint_type ft ON ft.id=fp.type_id WHERE fp.deleted_at IS NULL{} GROUP BY fp.type_id ORDER BY count DESC", ycond), &[])?; let by_level = rows_to_json(&conn, &format!( "SELECT COALESCE(fp.level,'未设置') AS label, COUNT(*) AS count FROM footprint fp WHERE fp.deleted_at IS NULL{} GROUP BY fp.level ORDER BY count DESC", ycond), &[])?; let by_role = rows_to_json(&conn, &format!( "SELECT COALESCE(fp.role,'未设置') AS label, COUNT(*) AS count FROM footprint fp WHERE fp.deleted_at IS NULL{} GROUP BY fp.role ORDER BY count DESC", ycond), &[])?; let by_status = rows_to_json(&conn, &format!( "SELECT fp.status AS label, COUNT(*) AS count FROM footprint fp WHERE fp.deleted_at IS NULL{} GROUP BY fp.status", ycond), &[])?; let monthly_rows = rows_to_json(&conn, &format!( "SELECT substr(COALESCE(NULLIF(start_date,''), substr(created_at,1,10)),1,7) AS ym, COUNT(*) AS count FROM footprint WHERE deleted_at IS NULL{} GROUP BY ym ORDER BY ym", ycond_raw), &[])?; let monthly = { let map: std::collections::HashMap = monthly_rows.iter().filter_map(|r| { Some((r.get("ym")?.as_str()?.to_string(), r.get("count")?.as_i64()?)) }).collect(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); // 以本地时间近似(UTC+8) let dt = now + 8 * 3600; let days = dt / 86400; let mut base_year = 1970i64; let mut rem = days as i64; loop { let leap = (base_year % 4 == 0 && base_year % 100 != 0) || base_year % 400 == 0; let len = if leap { 366 } else { 365 }; if rem < len { break; } rem -= len; base_year += 1; } let leap = (base_year % 4 == 0 && base_year % 100 != 0) || base_year % 400 == 0; let mlens = [31, if leap {29} else {28}, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; let mut month_idx = 0; for len in mlens { if rem < len { break; } rem -= len; month_idx += 1; } let mut out = Vec::new(); for i in (0..12).rev() { let mut m = month_idx - i; let mut y = base_year; while m <= 0 { m += 12; y -= 1; } let ym = format!("{:04}-{:02}", y, m); out.push(json!({ "ym": ym, "count": map.get(&ym).copied().unwrap_or(0) })); } out }; let mut metrics = Vec::new(); let defs = rows_to_json(&conn, "SELECT * FROM metric_def WHERE deleted_at IS NULL ORDER BY id", &[])?; for m in defs { let key = m.get("key").and_then(|v| v.as_str()).unwrap_or("").to_string(); let path = format!("$\"{}\"", key); let agg = m.get("agg").and_then(|v| v.as_str()).unwrap_or("sum").to_string(); let value: f64 = match agg.as_str() { "sum" => conn.query_row(&format!( "SELECT SUM(CAST(json_extract(fp.metrics, ?1) AS REAL)) FROM footprint fp WHERE json_extract(fp.metrics, ?1) IS NOT NULL AND fp.deleted_at IS NULL{}", ycond), [&path], |r| r.get::<_, Option>(0)).map(|v| v.unwrap_or(0.0)).unwrap_or(0.0), "count" => conn.query_row(&format!( "SELECT COUNT(*) FROM footprint fp WHERE json_extract(fp.metrics, ?1) IS NOT NULL AND fp.deleted_at IS NULL{}", ycond), [&path], |r| r.get::<_, i64>(0)).map(|v| v as f64).unwrap_or(0.0), _ => conn.query_row(&format!( "SELECT COUNT(DISTINCT json_extract(fp.metrics, ?1)) FROM footprint fp WHERE json_extract(fp.metrics, ?1) IS NOT NULL AND fp.deleted_at IS NULL{}", ycond), [&path], |r| r.get::<_, i64>(0)).map(|v| v as f64).unwrap_or(0.0), }; metrics.push(json!({ "key": key, "label": m.get("label").cloned().unwrap_or(json!(key.clone())), "unit": m.get("unit").cloned().unwrap_or(Value::Null), "agg": agg, "value": value })); } let years: Vec = rows_to_json(&conn, "SELECT DISTINCT CAST(strftime('%Y', COALESCE(NULLIF(start_date,''), substr(created_at,1,10))) AS INTEGER) AS y FROM footprint WHERE deleted_at IS NULL ORDER BY y DESC", &[])? .iter().filter_map(|r| r.get("y").and_then(|v| v.as_i64())).collect(); Ok(json!({ "total": total, "total_this_year": total_this_year, "attachment_total": attachment_total, "by_type": by_type, "by_level": by_level, "by_role": by_role, "by_status": by_status, "monthly": monthly, "metrics": metrics, "years": years })) } "metric_list" => Ok(json!({ "items": rows_to_json(&conn, "SELECT * FROM metric_def WHERE deleted_at IS NULL ORDER BY id", &[])? })), "metric_upsert" => { let key = payload.get("key").and_then(|v| v.as_str()).unwrap_or(""); let label = payload.get("label").and_then(|v| v.as_str()).unwrap_or(key); let unit = payload.get("unit").and_then(|v| v.as_str()); let agg = payload.get("agg").and_then(|v| v.as_str()).unwrap_or("sum"); let section = payload.get("section").and_then(|v| v.as_str()); let existing: Option<(i64, Option)> = conn .query_row("SELECT id, uuid FROM metric_def WHERE key=?1 AND deleted_at IS NULL", [key], |r| { Ok((r.get(0)?, r.get(1)?)) }) .ok(); let h = hlc_next(&conn); match existing { Some((id, uuid)) => { exec(&conn, "UPDATE metric_def SET label=?, unit=?, agg=?, section=?, hlc=? WHERE id=?", &[P::S(label.into()), s_or_null(&json!(unit)), P::S(agg.to_string()), s_or_null(&json!(section)), P::S(h.clone()), P::I(id)])?; if let Some(u) = uuid { touch_oplog(&conn, "metric_def", &u, "upsert", &h); } } None => { let new_uuid = uuid_v7(); exec(&conn, "INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)", &[P::S(key.into()), P::S(label.into()), s_or_null(&json!(unit)), P::S(agg.to_string()), s_or_null(&json!(section)), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?; touch_oplog(&conn, "metric_def", &new_uuid, "upsert", &h); } } Ok(json!({ "item": payload })) } "metric_delete" => { let key = payload.get("key").and_then(|v| v.as_str()).unwrap_or(""); let row: Option = conn.query_row("SELECT id FROM metric_def WHERE key=?1", [key], |r| r.get(0)).ok(); if let Some(id) = row { soft_delete(&conn, "metric_def", id); } Ok(json!({ "ok": true })) } // ---------- 学年学科成绩 ---------- "grades_overview" => { let bands = rows_to_json(&conn, "SELECT * FROM grade_band WHERE deleted_at IS NULL ORDER BY sort, id", &[])?; let semesters = rows_to_json(&conn, "SELECT * FROM semester WHERE deleted_at IS NULL ORDER BY term DESC", &[])?; let sem_ids: Vec = semesters.iter().filter_map(|s| s.get("id").and_then(|v| v.as_i64())).collect(); let courses: Vec = rows_to_json(&conn, "SELECT * FROM course_grade WHERE deleted_at IS NULL ORDER BY id", &[])? .into_iter() .filter(|c| sem_ids.contains(&c.get("semester_id").and_then(|v| v.as_i64()).unwrap_or(-1))) .collect(); let band_of = |score: Option| -> Option { let s = score?; for b in bands.iter() { let min = b.get("min").and_then(|v| v.as_f64()).unwrap_or(0.0); let max = b.get("max").and_then(|v| v.as_f64()).unwrap_or(100.0); if s >= min && s < max { return Some(json!({ "id": b.get("id").cloned().unwrap_or(Value::Null), "label": b.get("label").cloned().unwrap_or(json!("")), "color": b.get("color").cloned().unwrap_or(json!("")) })); } } for b in bands.iter() { let min = b.get("min").and_then(|v| v.as_f64()).unwrap_or(0.0); let max = b.get("max").and_then(|v| v.as_f64()).unwrap_or(100.0); if s >= min && s <= max { return Some(json!({ "id": b.get("id").cloned().unwrap_or(Value::Null), "label": b.get("label").cloned().unwrap_or(json!("")), "color": b.get("color").cloned().unwrap_or(json!("")) })); } } None }; let mut all_scored: Vec<(f64, f64)> = Vec::new(); // (score, credit) let mut sems_out: Vec = Vec::new(); for s in semesters { let sid = s.get("id").and_then(|v| v.as_i64()).unwrap_or(0); let my: Vec<&Value> = courses.iter().filter(|c| c.get("semester_id").and_then(|v| v.as_i64()) == Some(sid)).collect(); let mut credits_all = 0.0; let mut credits_scored = 0.0; let mut weighted = 0.0; let mut courses_out: Vec = Vec::new(); for c in my { let credit = c.get("credit").and_then(|v| v.as_f64()).unwrap_or(0.0); let score = c.get("score").and_then(|v| v.as_f64()); credits_all += credit; if let Some(sc) = score { all_scored.push((sc, credit)); credits_scored += credit; weighted += sc * credit; } let mut co = c.clone(); if let Some(obj) = co.as_object_mut() { obj.insert("band".into(), band_of(score).unwrap_or(Value::Null)); } courses_out.push(co); } let mut so = s.clone(); if let Some(obj) = so.as_object_mut() { obj.insert("courses".into(), json!(courses_out)); obj.insert("stats".into(), json!({ "credits": (credits_all * 10.0).round() / 10.0, "courses": courses_out.len(), "avg": if credits_scored > 0.0 { Some((weighted / credits_scored * 100.0).round() / 100.0) } else { None } })); } sems_out.push(so); } let credits_all: f64 = all_scored.iter().map(|(_, c)| c).sum(); let weighted: f64 = all_scored.iter().map(|(s, c)| s * c).sum(); let overall_avg = if credits_all > 0.0 { Some((weighted / credits_all * 100.0).round() / 100.0) } else { None }; let mut tier_dist: Vec = Vec::new(); for b in bands.iter() { let bid = b.get("id").and_then(|v| v.as_i64()); let count = all_scored.iter().filter(|(s, _)| { band_of(Some(*s)).and_then(|x| x.get("id").and_then(|v| v.as_i64())) == bid }).count(); tier_dist.push(json!({ "label": b.get("label").cloned().unwrap_or(json!("")), "color": b.get("color").cloned().unwrap_or(json!("")), "count": count })); } let mut trend_sems: Vec<&Value> = sems_out.iter().filter(|s| s.get("gpa").and_then(|v| v.as_f64()).is_some()).collect(); trend_sems.sort_by(|a, b| { a.get("term").and_then(|v| v.as_str()).unwrap_or("").cmp(b.get("term").and_then(|v| v.as_str()).unwrap_or("")) }); let gpa_trend: Vec = trend_sems.iter().map(|s| json!({ "term": s.get("term").cloned().unwrap_or(json!("")), "gpa": s.get("gpa").cloned().unwrap_or(Value::Null), "avg": s.get("stats").and_then(|st| st.get("avg").cloned()).unwrap_or(Value::Null) })).collect(); let total_credits: f64 = courses.iter().map(|c| c.get("credit").and_then(|v| v.as_f64()).unwrap_or(0.0)).sum(); Ok(json!({ "semesters": sems_out, "bands": bands, "overall": { "avg": overall_avg, "credits": (total_credits * 10.0).round() / 10.0, "courses": courses.len(), "gpa_trend": gpa_trend, "tier_dist": tier_dist } })) } "semester_upsert" => { let term = payload.get("term").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); if term.is_empty() { return Err("缺少学期名称".into()); } let gpa = payload.get("gpa").and_then(|v| v.as_f64()); let rank = payload.get("rank").and_then(|v| v.as_i64()); let total_students = payload.get("total_students").and_then(|v| v.as_i64()); if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) { let dup: i64 = conn .query_row("SELECT COUNT(*) FROM semester WHERE term=?1 AND deleted_at IS NULL AND id != ?2", rusqlite::params![term.as_str(), id], |r| r.get(0)) .map_err(e)?; if dup > 0 { return Err(format!("学期已存在:{}", term)); } let h = hlc_next(&conn); exec(&conn, "UPDATE semester SET term=?1, gpa=?2, rank=?3, total_students=?4, hlc=?5 WHERE id=?6", &[P::S(term), P::R(gpa.unwrap_or(0.0)), num_or_null(&json!(rank)), num_or_null(&json!(total_students)), P::S(h.clone()), P::I(id)])?; if let Some(u) = get_row_uuid(&conn, "semester", id) { touch_oplog(&conn, "semester", &u, "upsert", &h); } Ok(json!({ "item": { "id": id } })) } else { let dup: i64 = conn.query_row("SELECT COUNT(*) FROM semester WHERE term=?1 AND deleted_at IS NULL", [term.as_str()], |r| r.get(0)).map_err(e)?; if dup > 0 { return Err(format!("学期已存在:{}", term)); } let h = hlc_next(&conn); let new_uuid = uuid_v7(); let r = exec(&conn, "INSERT INTO semester (term, gpa, rank, total_students, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)", &[P::S(term), P::R(gpa.unwrap_or(0.0)), num_or_null(&json!(rank)), num_or_null(&json!(total_students)), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?; touch_oplog(&conn, "semester", &new_uuid, "upsert", &h); Ok(json!({ "item": { "id": r as i64 } })) } } "semester_delete" => { let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0); soft_delete(&conn, "semester", id); // 级联墓碑该学期下全部课程,避免孤儿课程污染总览 let course_ids: Vec = { let mut out = Vec::new(); if let Ok(mut stmt) = conn.prepare("SELECT id FROM course_grade WHERE semester_id=?1 AND deleted_at IS NULL") { if let Ok(mut rows) = stmt.query([id]) { while let Ok(Some(row)) = rows.next() { if let Ok(cid) = row.get::<_, i64>(0) { out.push(cid); } } } } out }; for cid in course_ids { soft_delete(&conn, "course_grade", cid); } Ok(json!({ "ok": true })) } "course_upsert" => { let semester_id = payload.get("semester_id").and_then(|v| v.as_i64()).ok_or_else(|| "缺少学期".to_string())?; let name = payload.get("course_name").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); if name.is_empty() { return Err("缺少课程名".into()); } let score = payload.get("score").and_then(|v| v.as_f64()); if let Some(sc) = score { if !(0.0..=100.0).contains(&sc) { return Err("分数需在 0-100 之间".into()); } } let gp = payload.get("grade_point").and_then(|v| v.as_f64()); if let Some(g) = gp { if g < 0.0 { return Err("绩点不能为负".into()); } } let credit = payload.get("credit").and_then(|v| v.as_f64()).unwrap_or(0.0); if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) { let h = hlc_next(&conn); exec(&conn, "UPDATE course_grade SET semester_id=?1, course_name=?2, credit=?3, score=?4, grade_point=?5, hlc=?6 WHERE id=?7", &[P::I(semester_id), P::S(name), P::R(credit), P::R(score.unwrap_or(0.0)), P::R(gp.unwrap_or(0.0)), P::S(h.clone()), P::I(id)])?; if let Some(u) = get_row_uuid(&conn, "course_grade", id) { touch_oplog(&conn, "course_grade", &u, "upsert", &h); } Ok(json!({ "item": { "id": id } })) } else { let h = hlc_next(&conn); let new_uuid = uuid_v7(); let r = exec(&conn, "INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)", &[P::I(semester_id), P::S(name), P::R(credit), P::R(score.unwrap_or(0.0)), P::R(gp.unwrap_or(0.0)), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?; touch_oplog(&conn, "course_grade", &new_uuid, "upsert", &h); Ok(json!({ "item": { "id": r as i64 } })) } } "course_delete" => { soft_delete(&conn, "course_grade", payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0)); Ok(json!({ "ok": true })) } "band_upsert" => { let label = payload.get("label").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); if label.is_empty() { return Err("缺少等级名称".into()); } let min = payload.get("min").and_then(|v| v.as_f64()).ok_or_else(|| "分数区间不合法(需 min < max)".to_string())?; let max = payload.get("max").and_then(|v| v.as_f64()).ok_or_else(|| "分数区间不合法(需 min < max)".to_string())?; if min >= max { return Err("分数区间不合法(需 min < max)".into()); } let color = payload.get("color").and_then(|v| v.as_str()).unwrap_or("#64748b"); let sort = payload.get("sort").and_then(|v| v.as_i64()).unwrap_or(100); if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) { let h = hlc_next(&conn); exec(&conn, "UPDATE grade_band SET label=?1, color=?2, min=?3, max=?4, sort=?5, hlc=?6 WHERE id=?7", &[P::S(label.clone()), P::S(color.into()), P::R(min), P::R(max), P::I(sort), P::S(h.clone()), P::I(id)])?; if let Some(u) = get_row_uuid(&conn, "grade_band", id) { touch_oplog(&conn, "grade_band", &u, "upsert", &h); } Ok(json!({ "item": { "id": id } })) } else { let h = hlc_next(&conn); let new_uuid = uuid_v7(); let r = exec(&conn, "INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)", &[P::S(label), P::S(color.into()), P::R(min), P::R(max), P::I(sort), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?; touch_oplog(&conn, "grade_band", &new_uuid, "upsert", &h); Ok(json!({ "item": { "id": r as i64 } })) } } "band_delete" => { soft_delete(&conn, "grade_band", payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0)); Ok(json!({ "ok": true })) } "attachment_add" => { use base64::Engine; use std::io::Write; let owner = payload.get("owner_id").and_then(|v| v.as_i64()).unwrap_or(0); let file_name = payload.get("file_name").and_then(|v| v.as_str()).unwrap_or("file").to_string(); let mime = payload.get("mime").and_then(|v| v.as_str()); let data = payload.get("data_b64").and_then(|v| v.as_str()).unwrap_or(""); let bytes = base64::engine::general_purpose::STANDARD.decode(data).map_err(|x| x.to_string())?; let safe: String = file_name.chars().map(|c| if "\\/:*?\"<>|".contains(c) { '_' } else { c }).collect(); let rel = format!("attachments/{}/{}_{}", owner, std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(), safe); let abs = state.data_dir.join(&rel); std::fs::create_dir_all(abs.parent().unwrap()).map_err(|x| x.to_string())?; let mut f = std::fs::File::create(&abs).map_err(|x| x.to_string())?; f.write_all(&bytes).map_err(|x| x.to_string())?; let rel_norm = rel.replace('\\', "/"); use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(&bytes); let sha_hex = format!("{:x}", hasher.finalize()); let h = hlc_next(&conn); let new_uuid = uuid_v7(); let r = exec(&conn, "INSERT INTO attachment (owner_type, owner_id, kind, file_name, mime, size, rel_path, sha256, uuid, hlc, origin) VALUES ('footprint',?1,NULL,?2,?3,?4,?5,?6,?7,?8,?9)", &[P::I(owner), P::S(file_name), s_or_null(&json!(mime)), P::I(bytes.len() as i64), P::S(rel_norm), P::S(sha_hex), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?; let item = rows_to_json(&conn, "SELECT * FROM attachment WHERE id=?1", &[P::I(r as i64)])?; touch_oplog(&conn, "attachment", &new_uuid, "upsert", &h); Ok(json!({ "item": item.into_iter().next().unwrap_or(Value::Null) })) } "attachment_list" => Ok(json!({ "items": rows_to_json(&conn, "SELECT * FROM attachment WHERE owner_type='footprint' AND owner_id=?1 AND deleted_at IS NULL ORDER BY id", &[P::I(payload.get("owner_id").and_then(|v| v.as_i64()).unwrap_or(0))])? })), "attachment_delete" => { let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0); let rows = rows_to_json(&conn, "SELECT * FROM attachment WHERE id=?1", &[P::I(id)])?; if let Some(row) = rows.into_iter().next() { soft_delete(&conn, "attachment", id); if let Some(rel) = row.get("rel_path").and_then(|v| v.as_str()) { let _ = std::fs::remove_file(state.data_dir.join(rel)); } } Ok(json!({ "ok": true })) } "attachment_read" => { use base64::Engine; let id = payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0); let rows = rows_to_json(&conn, "SELECT * FROM attachment WHERE id=?1", &[P::I(id)])?; let row = rows.into_iter().next().ok_or_else(|| "附件不存在".to_string())?; let rel = row.get("rel_path").and_then(|v| v.as_str()).unwrap_or(""); let data = std::fs::read(state.data_dir.join(rel)).map_err(|x| x.to_string())?; Ok(json!({ "data_b64": base64::engine::general_purpose::STANDARD.encode(data), "mime": row.get("mime").cloned().unwrap_or(Value::Null), "file_name": row.get("file_name").cloned().unwrap_or(json!("")) })) } "view_list" => { let items = rows_to_json(&conn, "SELECT * FROM saved_view WHERE deleted_at IS NULL ORDER BY id", &[])?; let items: Vec = items.into_iter().map(|mut it| { if let Some(obj) = it.as_object_mut() { for k in ["filter", "sort"] { if let Some(Value::String(s)) = obj.get(k) { let parsed: Value = serde_json::from_str(s).unwrap_or(json!({})); obj.insert(k.to_string(), parsed); } } let b = obj.get("is_default").map(|v| v.as_i64().unwrap_or(0) != 0).unwrap_or(false); obj.insert("is_default".into(), json!(b)); } it }).collect(); Ok(json!({ "items": items })) } "view_upsert" => { let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or(""); let filter = serde_json::to_string(payload.get("filter").unwrap_or(&json!({}))).unwrap(); let sort = serde_json::to_string(payload.get("sort").unwrap_or(&json!({}))).unwrap(); let is_default = payload.get("is_default").and_then(|v| v.as_bool()).unwrap_or(false); if let Some(id) = payload.get("id").and_then(|v| v.as_i64()) { let h = hlc_next(&conn); exec(&conn, "UPDATE saved_view SET name=?1, filter=?2, sort=?3, is_default=?4, hlc=?5, updated_at=datetime('now','localtime') WHERE id=?6", &[P::S(name.into()), P::S(filter), P::S(sort), P::I(is_default as i64), P::S(h.clone()), P::I(id)])?; if let Some(u) = get_row_uuid(&conn, "saved_view", id) { touch_oplog(&conn, "saved_view", &u, "upsert", &h); } Ok(json!({ "item": { "id": id } })) } else { let h = hlc_next(&conn); let new_uuid = uuid_v7(); let r = exec(&conn, "INSERT INTO saved_view (name, filter, sort, is_default, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)", &[P::S(name.into()), P::S(filter), P::S(sort), P::I(is_default as i64), P::S(new_uuid.clone()), P::S(h.clone()), P::S(get_device_id(&conn))])?; touch_oplog(&conn, "saved_view", &new_uuid, "upsert", &h); Ok(json!({ "item": { "id": r as i64 } })) } } "view_delete" => { soft_delete(&conn, "saved_view", payload.get("id").and_then(|v| v.as_i64()).unwrap_or(0)); Ok(json!({ "ok": true })) } _ => Err(format!("未知命令:{}", cmd)), } } fn chrono_year() -> i64 { let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() + 8 * 3600; let mut year = 1970i64; let mut rem = (secs / 86400) as i64; loop { let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; let len = if leap { 366 } else { 365 }; if rem < len { break; } rem -= len; year += 1; } year } macro_rules! cmd { ($name:ident) => { #[tauri::command] fn $name(state: tauri::State, payload: Value) -> Result { dispatch(&state, stringify!($name), &payload) } }; } cmd!(app_info); cmd!(type_list); cmd!(type_upsert); cmd!(type_delete); cmd!(taxonomy_list); cmd!(taxonomy_upsert); cmd!(taxonomy_delete); cmd!(dimension_list); cmd!(dimension_upsert); cmd!(dimension_delete); cmd!(footprint_list); cmd!(footprint_get); cmd!(footprint_upsert); cmd!(footprint_delete); cmd!(timeline); cmd!(stats_overview); cmd!(metric_list); cmd!(metric_upsert); cmd!(metric_delete); cmd!(grades_overview); cmd!(semester_upsert); cmd!(semester_delete); cmd!(course_upsert); cmd!(course_delete); cmd!(band_upsert); cmd!(band_delete); cmd!(attachment_add); cmd!(attachment_list); cmd!(attachment_delete); cmd!(attachment_read); cmd!(view_list); cmd!(view_upsert); cmd!(view_delete); pub fn run() { tauri::Builder::default() .setup(|app| { let dir = app.path().app_data_dir().expect("无法获取应用数据目录"); std::fs::create_dir_all(dir.join("attachments")).map_err(|x| x.to_string())?; let conn = Connection::open(dir.join("footprints.db")).map_err(|x| x.to_string())?; conn.pragma_update(None, "foreign_keys", true).map_err(|x| x.to_string())?; pre_migrate_columns(&conn); conn.execute_batch(SCHEMA_SQL).map_err(|x| x.to_string())?; migrate_sync(&conn); ensure_builtin(&conn); app.manage(AppData { conn: Mutex::new(conn), data_dir: dir }); Ok(()) }) .invoke_handler(tauri::generate_handler![ app_info, type_list, type_upsert, type_delete, taxonomy_list, taxonomy_upsert, taxonomy_delete, dimension_list, dimension_upsert, dimension_delete, footprint_list, footprint_get, footprint_upsert, footprint_delete, timeline, stats_overview, metric_list, metric_upsert, metric_delete, grades_overview, semester_upsert, semester_delete, course_upsert, course_delete, band_upsert, band_delete, attachment_add, attachment_list, attachment_delete, attachment_read, view_list, view_upsert, view_delete ]) .run(tauri::generate_context!()) .expect("error while running footprints"); } #[cfg(test)] mod tests { use super::*; fn fresh_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); pre_migrate_columns(&conn); conn.execute_batch(SCHEMA_SQL).unwrap(); migrate_sync(&conn); ensure_builtin(&conn); conn } fn test_app() -> AppData { let dir = std::env::temp_dir().join(format!("fp-test-{}-{}", std::process::id(), uuid_v7().replace('-', ""))); std::fs::create_dir_all(dir.join("attachments")).unwrap(); let conn = Connection::open_in_memory().unwrap(); conn.pragma_update(None, "foreign_keys", true).unwrap(); pre_migrate_columns(&conn); conn.execute_batch(SCHEMA_SQL).unwrap(); migrate_sync(&conn); ensure_builtin(&conn); AppData { conn: Mutex::new(conn), data_dir: dir } } // ---- 同步地基 ---- #[test] fn uuid_v7_format_and_uniqueness() { let mut seen = std::collections::HashSet::new(); for _ in 0..2000 { let u = uuid_v7(); assert_eq!(u.len(), 36, "格式应为标准 UUID: {}", u); assert_eq!(u.as_bytes()[14], b'7', "version 位应为 7: {}", u); assert!(seen.insert(u), "UUID 不应重复"); } } #[test] fn hlc_next_is_monotonic() { let conn = fresh_db(); let mut prev: (i64, i64) = (0, 0); for _ in 0..500 { let h = hlc_next(&conn); let parts: Vec = h.split(':').take(2).map(|x| x.parse().unwrap()).collect(); let (ms, cnt) = (parts[0], parts[1]); assert!(ms > prev.0 || (ms == prev.0 && cnt > prev.1), "HLC 必须单调递增: {} after {:?}", h, prev); prev = (ms, cnt); } } #[test] fn hlc_next_survives_clock_regression() { let conn = fresh_db(); let h1 = hlc_next(&conn); let p: Vec = h1.split(':').take(2).map(|x| x.parse().unwrap()).collect(); // 真实回拨场景:设备时钟曾超前(hlc_ms 在未来),系统校时后 Date.now() 回到过去 set_meta(&conn, "hlc_ms", &((p[0] + 60_000).to_string())); set_meta(&conn, "hlc_counter", "0"); let h2 = hlc_next(&conn); let q: Vec = h2.split(':').take(2).map(|x| x.parse().unwrap()).collect(); assert!(q[0] >= p[0] && (q[0] > p[0] || q[1] > p[1]), "时钟回正后 HLC 仍需不回退: {} -> {}", h1, h2); } #[test] fn migrate_legacy_backfills_sync_columns() { // 模拟 v1 老库:无 uuid/deleted_at/origin/hlc 列 let conn = Connection::open_in_memory().unwrap(); conn.execute_batch( "CREATE TABLE footprint_type (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, icon TEXT, color TEXT, sort INTEGER, form_template TEXT, is_builtin INTEGER, closed INTEGER, created_at TEXT); CREATE TABLE semester (id INTEGER PRIMARY KEY AUTOINCREMENT, term TEXT UNIQUE, gpa REAL, rank INTEGER, total_students INTEGER); INSERT INTO semester (term, gpa, rank, total_students) VALUES ('2020-2021-1', 3.5, 10, 100);", ).unwrap(); pre_migrate_columns(&conn); conn.execute_batch(SCHEMA_SQL).unwrap(); migrate_sync(&conn); let (cnt, uuids): (i64, String) = conn .query_row("SELECT COUNT(*), COALESCE(GROUP_CONCAT(uuid),'') FROM semester WHERE uuid IS NOT NULL", [], |r| { Ok((r.get(0)?, r.get(1)?)) }) .unwrap(); assert_eq!(cnt, 1, "存量行应回填 uuid"); assert!(uuids.contains('-'), "uuid 应为完整格式"); let origin: String = conn.query_row("SELECT origin FROM semester", [], |r| r.get(0)).unwrap(); assert_eq!(origin, "migrate"); } // ---- 命令层(dispatch 运行时)---- #[test] fn footprint_roundtrip_with_extras_and_metrics() { let app = test_app(); let types = dispatch(&app, "type_list", &json!({})).unwrap(); let type_id = types.get("items").and_then(|v| v.as_array()).unwrap()[0] .get("id").and_then(|v| v.as_i64()).unwrap(); let id = dispatch(&app, "footprint_upsert", &json!({ "item": { "type_id": type_id, "title": "运行时往返", "status": "finished", "tags": ["测试", "多端"], "extras": { "my_work": "验证同步地基" }, "metrics": { "volunteer_hours": 8 }, "dimension_ids": [] }})).unwrap(); let fid = id.get("id").and_then(|v| v.as_i64()).unwrap(); let got = dispatch(&app, "footprint_get", &json!({ "id": fid })).unwrap(); let item = got.get("item").unwrap(); assert_eq!(item.get("title").unwrap().as_str().unwrap(), "运行时往返"); assert_eq!(item.get("extras").unwrap().get("my_work").unwrap().as_str().unwrap(), "验证同步地基"); assert_eq!(item.get("metrics").unwrap().get("volunteer_hours").unwrap().as_f64().unwrap(), 8.0); assert!(item.get("uuid").and_then(|v| v.as_str()).is_some(), "足迹应携带 uuid"); assert!(item.get("hlc").and_then(|v| v.as_str()).is_some(), "足迹应携带 hlc"); // oplog 应有 1 条待同步 let op: i64 = app.conn.lock().unwrap() .query_row("SELECT COUNT(*) FROM sync_oplog WHERE table_name='footprint' AND synced_at IS NULL", [], |r| r.get(0)).unwrap(); assert_eq!(op, 1, "创建应写入 oplog"); } #[test] fn footprint_delete_is_tombstone_and_excluded_from_list() { let app = test_app(); let types = dispatch(&app, "type_list", &json!({})).unwrap(); let type_id = types.get("items").and_then(|v| v.as_array()).unwrap()[0] .get("id").and_then(|v| v.as_i64()).unwrap(); let id = dispatch(&app, "footprint_upsert", &json!({ "item": { "type_id": type_id, "title": "将被删除 keyword-unique", "status": "draft", "tags": [], "extras": {}, "metrics": {}, "dimension_ids": [] }})).unwrap(); let fid = id.get("id").and_then(|v| v.as_i64()).unwrap(); dispatch(&app, "footprint_delete", &json!({ "id": fid })).unwrap(); // 行保留但带墓碑 let (deleted_at, ): (Option,) = app.conn.lock().unwrap() .query_row("SELECT deleted_at FROM footprint WHERE id=?1", [fid], |r| Ok((r.get(0)?,))).unwrap(); assert!(deleted_at.is_some(), "删除应为软删除墓碑"); // 列表与检索均排除 let list = dispatch(&app, "footprint_list", &json!({ "query": { "q": "keyword-unique" } })).unwrap(); assert_eq!(list.get("total").and_then(|v| v.as_i64()), Some(0), "墓碑不应出现在检索中"); // oplog 中产生 delete 操作 let op: String = app.conn.lock().unwrap() .query_row("SELECT op FROM sync_oplog WHERE row_uuid=(SELECT uuid FROM footprint WHERE id=?1) ORDER BY id DESC LIMIT 1", [fid], |r| r.get(0)).unwrap(); assert_eq!(op, "delete"); } #[test] fn semester_delete_cascades_courses() { let app = test_app(); let sid = dispatch(&app, "semester_upsert", &json!({ "term": "2030-2031-1", "gpa": 3.0, "rank": 5, "total_students": 100 })) .unwrap().get("item").unwrap().get("id").and_then(|v| v.as_i64()).unwrap(); for name in ["课程A", "课程B", "课程C"] { dispatch(&app, "course_upsert", &json!({ "semester_id": sid, "course_name": name, "credit": 2, "score": 88, "grade_point": 3.8 })).unwrap(); } dispatch(&app, "semester_delete", &json!({ "id": sid })).unwrap(); let orphans: i64 = app.conn.lock().unwrap() .query_row("SELECT COUNT(*) FROM course_grade WHERE semester_id=?1 AND deleted_at IS NULL", [sid], |r| r.get(0)).unwrap(); assert_eq!(orphans, 0, "学期删除后不应残留孤儿课程"); let overview = dispatch(&app, "grades_overview", &json!({})).unwrap(); assert_eq!(overview.get("overall").unwrap().get("courses").and_then(|v| v.as_i64()), Some(0), "总览不应统计孤儿课程"); } #[test] fn semester_rename_duplicate_is_rejected() { let app = test_app(); dispatch(&app, "semester_upsert", &json!({ "term": "2030-2031-1" })).unwrap(); let s2 = dispatch(&app, "semester_upsert", &json!({ "term": "2030-2031-2" })).unwrap(); let id2 = s2.get("item").unwrap().get("id").and_then(|v| v.as_i64()).unwrap(); // 实证:改名前表内状态 { let conn = app.conn.lock().unwrap(); let mut stmt = conn.prepare("SELECT id, term, deleted_at FROM semester ORDER BY id").unwrap(); let mut rows = stmt.query([]).unwrap(); while let Ok(Some(row)) = rows.next() { let id: i64 = row.get(0).unwrap(); let t: String = row.get(1).unwrap(); let da: Option = row.get(2).unwrap(); println!("[rename-debug] semester id={} term={} deleted_at={:?}", id, t, da); } } let r = dispatch(&app, "semester_upsert", &json!({ "id": id2, "term": "2030-2031-1" })); println!("[rename-debug] dispatch result: {:?}", r.as_ref().map(|v| v.to_string()).unwrap_or_else(|e| e.clone())); assert!(r.is_err(), "改名撞已有学期名应被拒绝"); assert!(r.unwrap_err().contains("学期已存在"), "错误信息应友好"); } #[test] fn grades_overview_math() { let app = test_app(); let sid = dispatch(&app, "semester_upsert", &json!({ "term": "2030-2031-1" })).unwrap() .get("item").unwrap().get("id").and_then(|v| v.as_i64()).unwrap(); dispatch(&app, "course_upsert", &json!({ "semester_id": sid, "course_name": "A", "credit": 4, "score": 90, "grade_point": 4.0 })).unwrap(); dispatch(&app, "course_upsert", &json!({ "semester_id": sid, "course_name": "B", "credit": 2, "score": 80, "grade_point": 3.0 })).unwrap(); // 加权平均 = (90*4 + 80*2) / 6 = 86.67 let ov = dispatch(&app, "grades_overview", &json!({})).unwrap(); let avg = ov.get("overall").unwrap().get("avg").and_then(|v| v.as_f64()).unwrap(); assert!((avg - 86.67).abs() < 0.01, "加权平均应为 86.67,实际 {}", avg); assert_eq!(ov.get("overall").unwrap().get("credits").and_then(|v| v.as_f64()), Some(6.0)); } }