Files
FootPrint/app/src-tauri/src/lib.rs
T

1662 lines
83 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Connection>,
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<rusqlite::types::ToSqlOutput<'_>> {
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<Vec<Value>, String> {
let mut stmt = conn.prepare(sql).map_err(e)?;
let names: Vec<String> = (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<i64, String> {
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<String> {
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<String> = 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<i64> = 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<String> = 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<String> {
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<Value> = s
.split(',')
.filter_map(|x| x.trim().parse::<i64>().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<P>) {
let mut conds: Vec<String> = Vec::new();
let mut params: Vec<P> = 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<String> = 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::<Vec<_>>().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::<Vec<_>>().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<Value, String> {
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, &params)?;
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::<i64>().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<Value, String> {
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<String>)> = 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<Value> = 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<String> = 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<String> = 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::<Vec<_>>().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<Value> = 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::<String>())
.or_else(|| row.get("created_at").and_then(|v| v.as_str()).map(|s| s.chars().take(10).collect::<String>()))
.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<String, i64> = 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<f64>>(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<i64> = 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<String>)> = 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<i64> = 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<i64> = semesters.iter().filter_map(|s| s.get("id").and_then(|v| v.as_i64())).collect();
let courses: Vec<Value> = 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<f64>| -> Option<Value> {
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<Value> = 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<Value> = 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<Value> = 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<Value> = 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<i64> = {
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<Value> = 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<AppData>, payload: Value) -> Result<Value, String> {
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<i64> = 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<i64> = 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<i64> = 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<String>,) = 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<String> = 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));
}
}