|
| 1 | +//! 笔记/会话对外不可变 uid(REQ-277,v0.19.4)。 |
| 2 | +//! |
| 3 | +//! @ai-context Why:用户痛点 = AI 任务域裸数字 id 上屏 + 未来导出/深链/跨设备 |
| 4 | +//! 合并需要稳定可读身份。库内 INTEGER 主键**不动**(35 表重建 + |
| 5 | +//! 无迁移框架的成本核算否决——见 docs/versions/v0.19.4.md §4), |
| 6 | +//! notes/sessions 各增 `uid TEXT UNIQUE` 作为对外身份。 |
| 7 | +//! @ai-context 形态:`<kind>-YYYYMMDD-<6位 base36>`(n=笔记/s=会话;示例 |
| 8 | +//! `n-20260904-x7f2k9`)。日期取 UTC(与内部 unix 时间同基准、 |
| 9 | +//! 跨时区稳定——uid 是可读标识而非用户可见日期);后缀 = fnv1a64 |
| 10 | +//! (kind:id:created_at) 的确定性哈希(同库确定性、无需随机源、 |
| 11 | +//! 不暴露自增序),唯一索引 + 冲突重试(salt 递增)兜底。 |
| 12 | +//! @ai-context 性质:创建即生成、永不修改;内部数字 FK/localStorage 键/命令 |
| 13 | +//! 入参保持不动;消费端(导出/深链/日志 uid 化)后续铺开。 |
| 14 | +//! @ai-context 兼容:表缺 uid 列(测试手工旧 schema)时静默跳过——uid 为 |
| 15 | +//! 身份增强非关键路径,绝不阻断插入/启动。 |
| 16 | +//! |
| 17 | +//! 注意:表名仅取本模块常量(TAB_*)——禁止外部传表名拼 SQL。 |
| 18 | +
|
| 19 | +use rusqlite::{params, Connection}; |
| 20 | + |
| 21 | +/// notes 表常量(仅供本模块回填/确保用——内部固定字符串拼接,无注入面) |
| 22 | +pub const TAB_NOTES: &str = "notes"; |
| 23 | +/// sessions 表常量 |
| 24 | +pub const TAB_SESSIONS: &str = "sessions"; |
| 25 | +/// 笔记 uid 前缀 |
| 26 | +pub const KIND_NOTE: char = 'n'; |
| 27 | +/// 会话 uid 前缀 |
| 28 | +pub const KIND_SESSION: char = 's'; |
| 29 | + |
| 30 | +/// 冲突重试上限(salt 0..=8——同秒同 id 哈希冲突概率极低,9 次后放弃留 NULL, |
| 31 | +/// 下次启动回填再试:永不阻塞主路径) |
| 32 | +const MAX_SALT_RETRY: u32 = 8; |
| 33 | +/// 36^6(uid 后缀空间——u64 运算防溢出) |
| 34 | +const SUFFIX_SPACE: u64 = 36u64.pow(6); |
| 35 | + |
| 36 | +/// fnv1a64(offset basis / prime——纯 std,确定性) |
| 37 | +fn fnv1a64(bytes: &[u8]) -> u64 { |
| 38 | + let mut h: u64 = 0xcbf29ce484222325; |
| 39 | + for b in bytes { |
| 40 | + h ^= u64::from(*b); |
| 41 | + h = h.wrapping_mul(0x100000001b3); |
| 42 | + } |
| 43 | + h |
| 44 | +} |
| 45 | + |
| 46 | +/// unix 秒 → UTC 日期 YYYYMMDD(Hinnant civil_from_days——无 chrono 依赖; |
| 47 | +/// created_at ≥0,负秒仅理论防御仍正确) |
| 48 | +pub(crate) fn date_from_epoch(secs: i64) -> String { |
| 49 | + let days = secs.div_euclid(86_400); |
| 50 | + let z = days + 719_468; |
| 51 | + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; |
| 52 | + let doe = (z - era * 146_097) as u64; // [0, 146096] |
| 53 | + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] |
| 54 | + let y = yoe as i64 + era * 400; |
| 55 | + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] |
| 56 | + let mp = (5 * doy + 2) / 153; // [0, 11] |
| 57 | + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] |
| 58 | + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] |
| 59 | + let yy = if m <= 2 { y + 1 } else { y }; |
| 60 | + format!("{yy:04}{m:02}{d:02}") |
| 61 | +} |
| 62 | + |
| 63 | +/// 定长 base36(宽度不足补 '0'——uid 后缀定宽可排序可辨识) |
| 64 | +fn to_base36(mut v: u64, width: usize) -> String { |
| 65 | + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; |
| 66 | + let mut s = vec![b'0'; width]; |
| 67 | + for i in (0..width).rev() { |
| 68 | + s[i] = DIGITS[(v % 36) as usize]; |
| 69 | + v /= 36; |
| 70 | + } |
| 71 | + String::from_utf8(s).expect("base36 恒为 ASCII") |
| 72 | +} |
| 73 | + |
| 74 | +/// 生成候选 uid(salt=冲突重试序号;确定性——同参恒同值) |
| 75 | +pub fn make_uid(kind: char, id: i64, created_at: i64, salt: u32) -> String { |
| 76 | + let seed = format!("{kind}:{id}:{created_at}"); |
| 77 | + let h = fnv1a64(seed.as_bytes()) |
| 78 | + .wrapping_add(u64::from(salt).wrapping_mul(0x9e37_79b9_7f4a_7c15)); |
| 79 | + format!("{kind}-{}-{}", date_from_epoch(created_at), to_base36(h % SUFFIX_SPACE, 6)) |
| 80 | +} |
| 81 | + |
| 82 | +/// 表是否含 uid 列(旧库迁移前/测试手工 schema 兼容判断) |
| 83 | +fn has_uid_column(conn: &Connection, table: &str) -> rusqlite::Result<bool> { |
| 84 | + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; |
| 85 | + let cols = stmt |
| 86 | + .query_map([], |r| r.get::<_, String>(1))? |
| 87 | + .collect::<rusqlite::Result<Vec<_>>>()?; |
| 88 | + Ok(cols.iter().any(|c| c == "uid")) |
| 89 | +} |
| 90 | + |
| 91 | +fn uid_exists(conn: &Connection, table: &str, uid: &str) -> rusqlite::Result<bool> { |
| 92 | + let mut stmt = conn.prepare(&format!("SELECT 1 FROM {table} WHERE uid = ?1"))?; |
| 93 | + let mut rows = stmt.query_map(params![uid], |_| Ok(()))?; |
| 94 | + Ok(rows.next().transpose()?.is_some()) |
| 95 | +} |
| 96 | + |
| 97 | +/// 存量回填:为 `uid IS NULL` 的行生成 uid(幂等——只补 NULL;重复调用零更新)。 |
| 98 | +/// 返回本次补写行数。表缺列 → Ok(0)(兼容旧 schema 测试)。 |
| 99 | +pub fn backfill_table(conn: &Connection, table: &str, kind: char) -> rusqlite::Result<usize> { |
| 100 | + if !has_uid_column(conn, table)? { |
| 101 | + return Ok(0); |
| 102 | + } |
| 103 | + let mut stmt = conn.prepare(&format!( |
| 104 | + "SELECT id, created_at FROM {table} WHERE uid IS NULL ORDER BY id" |
| 105 | + ))?; |
| 106 | + let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; |
| 107 | + let mut updated = 0usize; |
| 108 | + for row in rows { |
| 109 | + let (id, created_at) = row?; |
| 110 | + if ensure_row_uid(conn, table, kind, id, created_at)? { |
| 111 | + updated += 1; |
| 112 | + } |
| 113 | + } |
| 114 | + Ok(updated) |
| 115 | +} |
| 116 | + |
| 117 | +/// 单行确保:插入后为无 uid 的新行生成 uid(幂等——已有 uid 则跳过)。 |
| 118 | +pub fn ensure_uid( |
| 119 | + conn: &Connection, |
| 120 | + table: &str, |
| 121 | + kind: char, |
| 122 | + id: i64, |
| 123 | + created_at: i64, |
| 124 | +) -> rusqlite::Result<()> { |
| 125 | + if !has_uid_column(conn, table)? { |
| 126 | + return Ok(()); // 旧 schema(测试)——uid 为身份增强,缺列不阻断 |
| 127 | + } |
| 128 | + let _ = ensure_row_uid(conn, table, kind, id, created_at)?; |
| 129 | + Ok(()) |
| 130 | +} |
| 131 | + |
| 132 | +/// 冲突重试写入(salt 0..=MAX;唯一索引冲突即换盐;全失败留 NULL 下次再补) |
| 133 | +fn ensure_row_uid( |
| 134 | + conn: &Connection, |
| 135 | + table: &str, |
| 136 | + kind: char, |
| 137 | + id: i64, |
| 138 | + created_at: i64, |
| 139 | +) -> rusqlite::Result<bool> { |
| 140 | + // 闭包内以 Option<String> 读取——NULL 安全(query_row 外层 optional() 不 |
| 141 | + // 豁免列级 NULL,须在 row 读取层处理) |
| 142 | + let existing: Option<String> = conn.query_row( |
| 143 | + &format!("SELECT uid FROM {table} WHERE id = ?1"), |
| 144 | + params![id], |
| 145 | + |r| r.get::<_, Option<String>>(0), |
| 146 | + )?; |
| 147 | + if existing.as_deref().is_some_and(|u| !u.is_empty()) { |
| 148 | + return Ok(false); // 已有 uid(幂等) |
| 149 | + } |
| 150 | + for salt in 0..=MAX_SALT_RETRY { |
| 151 | + let uid = make_uid(kind, id, created_at, salt); |
| 152 | + if !uid_exists(conn, table, &uid)? { |
| 153 | + conn.execute( |
| 154 | + &format!("UPDATE {table} SET uid = ?1 WHERE id = ?2"), |
| 155 | + params![uid, id], |
| 156 | + )?; |
| 157 | + return Ok(true); |
| 158 | + } |
| 159 | + } |
| 160 | + eprintln!("[db_uid] {table} id={id} uid 冲突重试耗尽(留 NULL 下轮回填)"); |
| 161 | + Ok(false) |
| 162 | +} |
| 163 | + |
| 164 | +#[cfg(test)] |
| 165 | +mod tests { |
| 166 | + use super::*; |
| 167 | + use rusqlite::Connection; |
| 168 | + |
| 169 | + #[test] |
| 170 | + fn date_from_epoch_known_epochs() { |
| 171 | + assert_eq!(date_from_epoch(0), "19700101"); |
| 172 | + // 2024-02-29T00:00:00Z(闰年边界) |
| 173 | + assert_eq!(date_from_epoch(1_709_164_800), "20240229"); |
| 174 | + // 2026-09-04T00:00:00Z |
| 175 | + assert_eq!(date_from_epoch(1_788_480_000), "20260904"); |
| 176 | + } |
| 177 | + |
| 178 | + #[test] |
| 179 | + fn make_uid_shape_stable_and_deterministic() { |
| 180 | + let a = make_uid(KIND_NOTE, 42, 1_788_480_000, 0); |
| 181 | + let b = make_uid(KIND_NOTE, 42, 1_788_480_000, 0); |
| 182 | + assert_eq!(a, b, "同参必须确定性"); |
| 183 | + assert!(a.starts_with("n-20260904-"), "形态 n-YYYYMMDD-xxxxxx: {a}"); |
| 184 | + assert_eq!(a.len(), 17, "n-(1)+9+1+6 = 17 字符: {a}"); |
| 185 | + assert_ne!(make_uid(KIND_NOTE, 43, 1_788_480_000, 0), a, "id 参与哈希"); |
| 186 | + assert_ne!(make_uid(KIND_SESSION, 42, 1_788_480_000, 0), a, "kind 参与哈希"); |
| 187 | + assert_ne!(make_uid(KIND_NOTE, 42, 1_788_480_000, 1), a, "salt 换值"); |
| 188 | + } |
| 189 | + |
| 190 | + #[test] |
| 191 | + fn backfill_idempotent_and_only_null_rows() { |
| 192 | + let conn = Connection::open_in_memory().unwrap(); |
| 193 | + conn.execute_batch( |
| 194 | + "CREATE TABLE notes ( |
| 195 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 196 | + created_at INTEGER NOT NULL, |
| 197 | + uid TEXT |
| 198 | + ); |
| 199 | + INSERT INTO notes (created_at, uid) VALUES (1767398400, NULL), (1767398400, 'keep-me'), (1767484800, NULL);", |
| 200 | + ) |
| 201 | + .unwrap(); |
| 202 | + let first = backfill_table(&conn, TAB_NOTES, KIND_NOTE).unwrap(); |
| 203 | + assert_eq!(first, 2, "两行 NULL 被补写"); |
| 204 | + let n: i64 = conn |
| 205 | + .query_row("SELECT COUNT(*) FROM notes WHERE uid IS NULL", [], |r| r.get(0)) |
| 206 | + .unwrap(); |
| 207 | + assert_eq!(n, 0); |
| 208 | + let has_keep: i64 = conn |
| 209 | + .query_row("SELECT COUNT(*) FROM notes WHERE uid = 'keep-me'", [], |r| r.get(0)) |
| 210 | + .unwrap(); |
| 211 | + assert_eq!(has_keep, 1, "既有 uid 不被覆盖"); |
| 212 | + let second = backfill_table(&conn, TAB_NOTES, KIND_NOTE).unwrap(); |
| 213 | + assert_eq!(second, 0, "幂等:二次回填零更新"); |
| 214 | + // 唯一性(同一 schema 下全量 uid 互异) |
| 215 | + let distinct: i64 = conn |
| 216 | + .query_row("SELECT COUNT(DISTINCT uid) FROM notes", [], |r| r.get(0)) |
| 217 | + .unwrap(); |
| 218 | + let total: i64 = conn |
| 219 | + .query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0)) |
| 220 | + .unwrap(); |
| 221 | + assert_eq!(distinct, total); |
| 222 | + } |
| 223 | + |
| 224 | + #[test] |
| 225 | + fn ensure_uid_fills_new_row_and_skips_existing() { |
| 226 | + let conn = Connection::open_in_memory().unwrap(); |
| 227 | + conn.execute_batch( |
| 228 | + "CREATE TABLE sessions ( |
| 229 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 230 | + started_at INTEGER NOT NULL, |
| 231 | + uid TEXT |
| 232 | + ); |
| 233 | + INSERT INTO sessions (id, started_at, uid) VALUES (1, 1767398400, NULL), (2, 1767398400, 's-20260904-zzzzzz');", |
| 234 | + ) |
| 235 | + .unwrap(); |
| 236 | + ensure_uid(&conn, TAB_SESSIONS, KIND_SESSION, 1, 1_788_480_000).unwrap(); |
| 237 | + ensure_uid(&conn, TAB_SESSIONS, KIND_SESSION, 2, 1_788_480_000).unwrap(); |
| 238 | + let u1: String = conn |
| 239 | + .query_row("SELECT uid FROM sessions WHERE id = 1", [], |r| r.get(0)) |
| 240 | + .unwrap(); |
| 241 | + assert!(u1.starts_with("s-20260904-")); |
| 242 | + let u2: String = conn |
| 243 | + .query_row("SELECT uid FROM sessions WHERE id = 2", [], |r| r.get(0)) |
| 244 | + .unwrap(); |
| 245 | + assert_eq!(u2, "s-20260904-zzzzzz", "已有 uid 不被覆盖"); |
| 246 | + } |
| 247 | + |
| 248 | + #[test] |
| 249 | + fn missing_uid_column_is_noop() { |
| 250 | + let conn = Connection::open_in_memory().unwrap(); |
| 251 | + conn.execute_batch("CREATE TABLE notes (id INTEGER PRIMARY KEY, created_at INTEGER NOT NULL);") |
| 252 | + .unwrap(); |
| 253 | + assert_eq!(backfill_table(&conn, TAB_NOTES, KIND_NOTE).unwrap(), 0); |
| 254 | + ensure_uid(&conn, TAB_NOTES, KIND_NOTE, 1, 0).unwrap(); // 不 panic |
| 255 | + } |
| 256 | +} |
0 commit comments