Skip to content

Commit 028ed55

Browse files
committed
feat(db): notes/sessions uid 基建与全站裸号显示治理(REQ-277)
1 parent 7126452 commit 028ed55

21 files changed

Lines changed: 401 additions & 35 deletions

‎app/src-tauri/src/db.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ impl Db {
3737
db.init_ai_tasks()?;
3838
// v0.16.0(REQ-224):AI 对话持久化(会话/消息双表)
3939
db.init_ai_chat()?;
40+
// REQ-277(v0.19.4):存量 notes/sessions 幂等回填 uid(只补 NULL 行;
41+
// 身份增强非关键路径——失败警告不阻断启动,下轮打开再补)
42+
if let Err(e) = db.with_conn(|conn| {
43+
crate::db_uid::backfill_table(conn, crate::db_uid::TAB_NOTES, crate::db_uid::KIND_NOTE)?;
44+
crate::db_uid::backfill_table(conn, crate::db_uid::TAB_SESSIONS, crate::db_uid::KIND_SESSION)?;
45+
Ok(())
46+
}) {
47+
eprintln!("[Db] uid 存量回填失败(可忽略,下次启动重试): {e}");
48+
}
4049
Ok(db)
4150
}
4251

‎app/src-tauri/src/db_fragments.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ impl Db {
218218
params![title, fragment.text, group_id, now],
219219
)?;
220220
let note_id = tx.last_insert_rowid();
221+
// REQ-277:碎片升笔记生成对外 uid(同事务;旧 schema 测试缺列静默跳过)
222+
crate::db_uid::ensure_uid(&tx, crate::db_uid::TAB_NOTES, crate::db_uid::KIND_NOTE, note_id, now)?;
221223
// ③ 图片搬运 fragments/ → notes-images/{note_id}/(失败降级纯文本)
222224
let mut content = fragment.text.clone();
223225
if let Some(rel) = fragment.image_path.as_deref() {

‎app/src-tauri/src/db_migrations.rs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ pub(crate) fn init_schema(conn: &Connection) -> Result<()> {
2121
content TEXT NOT NULL,
2222
source TEXT NOT NULL DEFAULT 'manual',
2323
created_at INTEGER NOT NULL,
24-
updated_at INTEGER NOT NULL
24+
updated_at INTEGER NOT NULL,
25+
-- REQ-277(v0.19.4):对外不可变 uid(日期前缀+短哈希;旧库 ensure_column 补)
26+
uid TEXT
2527
);
2628
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at DESC);
2729
-- 会话主表(ADR-004:每次学习 = 一个会话)
@@ -35,7 +37,9 @@ pub(crate) fn init_schema(conn: &Connection) -> Result<()> {
3537
-- v0.5.0 M1(REQ-043):视频类型档案标识(kebab-case;NULL=默认档案)
3638
profile TEXT,
3739
-- v0.11.7(图文会话,ADR-020):会话类型(NULL=实时/视频导入等视频类;'photo'=图文截屏会话)
38-
kind TEXT
40+
kind TEXT,
41+
-- REQ-277(v0.19.4):对外不可变 uid(旧库 ensure_column 补)
42+
uid TEXT
3943
);
4044
-- 会话转写段(ASR final / 字幕 / 融合统一落库)
4145
CREATE TABLE IF NOT EXISTS session_segments (
@@ -468,6 +472,14 @@ CREATE TABLE IF NOT EXISTS contracts (
468472
crate::db_ai_usage::init(conn)?;
469473
// v0.18.0(REQ-248):学习目标三表(goals/goal_milestones/goal_groups——意图层)
470474
crate::db_goals::init(conn)?;
475+
// REQ-277(v0.19.4):旧库迁移——notes/sessions 补对外 uid 列 + 唯一索引
476+
// (建表批只对新库生效;索引必须在此刻建——新库列已随 CREATE 存在、旧库刚补列)
477+
ensure_column(conn, "notes", "uid", "ALTER TABLE notes ADD COLUMN uid TEXT")?;
478+
ensure_column(conn, "sessions", "uid", "ALTER TABLE sessions ADD COLUMN uid TEXT")?;
479+
conn.execute_batch(
480+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_uid ON notes(uid);
481+
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_uid ON sessions(uid);",
482+
)?;
471483
Ok(())
472484
}
473485

‎app/src-tauri/src/db_notes.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ impl Db {
3030
],
3131
)?;
3232
let id = conn.last_insert_rowid();
33+
// REQ-277:新笔记生成对外 uid(同事务;列缺失=旧 schema 测试,静默跳过)
34+
crate::db_uid::ensure_uid(conn, crate::db_uid::TAB_NOTES, crate::db_uid::KIND_NOTE, id, now)?;
3335
// v0.19.0(REQ-258)索引钩子:新建笔记即入派生索引(同事务软失败
3436
// 记录不阻断——失败可见于 kb_index_stats 角标)
3537
soft_rebuild_note(conn, id, &new.content);

‎app/src-tauri/src/db_sessions.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ impl Db {
3131
"INSERT INTO sessions (title, source_window, started_at, status, profile, kind) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
3232
params![new.title, new.source_window, now, SESSION_STATUS_RECORDING, new.profile, new.kind],
3333
)?;
34+
let id = conn.last_insert_rowid();
35+
// REQ-277:新会话生成对外 uid(同事务;旧 schema 测试缺列静默跳过)
36+
crate::db_uid::ensure_uid(conn, crate::db_uid::TAB_SESSIONS, crate::db_uid::KIND_SESSION, id, now)?;
3437
Ok(Session {
35-
id: conn.last_insert_rowid(),
38+
id,
3639
title: new.title.clone(),
3740
source_window: new.source_window.clone(),
3841
started_at: now,

‎app/src-tauri/src/db_uid.rs‎

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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+
}

‎app/src-tauri/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ mod db;
171171
// H3 拆分(db.rs 原 678 行硬拆):schema 建表/列迁移 + notes 读写独立成块
172172
mod db_migrations;
173173
mod db_notes;
174+
// REQ-277(v0.19.4):笔记/会话对外不可变 uid(生成/回填/幂等确保)
175+
mod db_uid;
174176
// v0.11.0(REQ-195):笔记组数据层(统一产物层唯一容器,v4 §7.4)
175177
mod db_note_groups;
176178
// v0.11.1:碎片原料层数据读写(fragments 表;碎片不是笔记,独立身份)

‎app/src/components/AiTaskPanel.tsx‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,18 +103,20 @@ export default function AiTaskPanel() {
103103
}
104104
setMsg("");
105105
try {
106-
const note = selected.opType === "refine"
107-
? await invoke<{ id: number }>("ai_refine_apply", {
106+
// 采纳落库(返回 {id} 仅历史协议——REQ-277 后不再向用户展示裸编号)
107+
await (selected.opType === "refine"
108+
? invoke<{ id: number }>("ai_refine_apply", {
108109
sessionId: selected.refId,
109110
result: await invoke<AiRefineResult>("ai_refine_result", { taskId: selected.taskId }),
110111
taskId: selected.taskId,
111112
})
112-
: await invoke<{ id: number }>("ai_enrich_apply", {
113+
: invoke<{ id: number }>("ai_enrich_apply", {
113114
noteId: selected.refId,
114115
result: await invoke<AiEnrichResult>("ai_enrich_result", { taskId: selected.taskId }),
115116
taskId: selected.taskId,
116-
});
117-
setMsg(`已落库为笔记 #${note.id}(可到笔记页查看版本时间线)`);
117+
}));
118+
// REQ-277:落库提示不带裸 # 数字(笔记身份=语义位置,不是编号)
119+
setMsg("已落库为笔记(可到笔记页查看版本时间线)");
118120
setSelected(null);
119121
setSummary(null);
120122
void load();
@@ -147,7 +149,7 @@ export default function AiTaskPanel() {
147149
{badge.text}
148150
</span>
149151
<span style={{ color: "#374151", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
150-
{t.opType === "refine" ? "会话" : `笔记 #${t.refId}`}
152+
{t.opType === "refine" ? "会话" : "笔记"}
151153
{t.model && <span style={{ color: "#9ca3af" }}> · {t.model}</span>}
152154
</span>
153155
<span style={{ color: "#9ca3af", width: 52, textAlign: "right" }}>{fmtMs(t.elapsedMs)}</span>
@@ -174,7 +176,12 @@ export default function AiTaskPanel() {
174176
{/* 结果摘要 + 采纳(防重复采纳:采纳后任务标记 adopted,历史中不再可恢复) */}
175177
{selected && summary && (
176178
<div style={{ border: "1px solid #a7f3d0", background: "#ecfdf5", borderRadius: 6, padding: 8, marginTop: 6, fontSize: 11 }}>
177-
<div style={{ fontWeight: 600, marginBottom: 4 }}>任务 #{selected.taskId} 结果</div>
179+
<div style={{ fontWeight: 600, marginBottom: 4 }}>
180+
任务结果 · {opLabel(selected.opType)}
181+
<span style={{ fontWeight: 400, color: "#6b7280" }}>
182+
({new Date(selected.createdAt * 1000).toLocaleTimeString()})
183+
</span>
184+
</div>
178185
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginBottom: 6 }}>
179186
{summary.map((s) => (
180187
<span key={s.label} style={{ color: "#374151" }}>

0 commit comments

Comments
 (0)