Skip to content

Commit 2c20d43

Browse files
committed
feat(notes): REQ-315 组置顶与手动排序后端(pin 列+note_group_orders 分区快照)
1 parent 0e4835a commit 2c20d43

7 files changed

Lines changed: 469 additions & 14 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//! 组排序/置顶命令(REQ-315,v0.20.11 批 6)。
2+
//!
3+
//! @ai-context: 命令薄壳——kind 白名单(防任意分区字符串)/量级守卫/归属校验
4+
//! (防陈旧序污染分区,note_orders 同款三防);保存=分区快照覆写
5+
//! (空 ids=分区回自动);变更广播 NoteGroups 域(组列表即时可见)。
6+
//! DB 读写见 db_note_group_orders.rs;渲染排序=前端纯函数(组侧栏/
7+
//! 树组头/过滤平铺共用,utils/groupOrder.ts),本层不做排序。
8+
9+
use tauri::State;
10+
11+
use crate::commands::AppState;
12+
use crate::db_note_group_orders::is_valid_group_order_kind;
13+
14+
/// 单分区手动序量级上限(防御 IPC/DB 滥用;组数远小于笔记数,上限对齐 note_orders)。
15+
const GROUP_IDS_MAX: usize = 2000;
16+
17+
fn validate_kind(kind: &str) -> Result<(), String> {
18+
if is_valid_group_order_kind(kind) {
19+
Ok(())
20+
} else {
21+
Err(format!("非法的手动排序分区: {}(支持: course/topic/standalone/feed)", kind))
22+
}
23+
}
24+
25+
/// 组置顶/取消置顶(pin=1/0——数据字段沿 notes.pin,措辞统一为「置顶」)。
26+
#[tauri::command]
27+
pub fn update_note_group_pin(
28+
state: State<'_, AppState>,
29+
id: i64,
30+
pin: i64,
31+
) -> Result<bool, String> {
32+
if id <= 0 {
33+
return Err("无效的组 id".to_string());
34+
}
35+
if pin != 0 && pin != 1 {
36+
return Err("置顶参数只能为 0(取消)或 1(置顶)".to_string());
37+
}
38+
let ok = state
39+
.db
40+
.update_note_group_pin(id, pin)
41+
.map_err(|e| e.to_string())?;
42+
if ok {
43+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::NoteGroups);
44+
}
45+
Ok(ok)
46+
}
47+
48+
/// 全量组手动序((group_id, seq);量小整拉——前端按分区过滤消费)。
49+
#[tauri::command]
50+
pub fn note_group_order_list(state: State<'_, AppState>) -> Result<Vec<(i64, i64)>, String> {
51+
state.db.load_group_orders().map_err(|e| e.to_string())
52+
}
53+
54+
/// 保存某 kind 分区完整手动序(分区快照先删后插单事务;空 group_ids=分区回自动)。
55+
#[tauri::command]
56+
pub fn note_group_order_save(
57+
state: State<'_, AppState>,
58+
kind: String,
59+
group_ids: Vec<i64>,
60+
) -> Result<(), String> {
61+
validate_kind(&kind)?;
62+
if group_ids.len() > GROUP_IDS_MAX {
63+
return Err(format!("组数超上限({} > {})", group_ids.len(), GROUP_IDS_MAX));
64+
}
65+
let mut seen = std::collections::HashSet::new();
66+
if !group_ids.iter().all(|id| *id > 0 && seen.insert(*id)) {
67+
return Err("组 id 非法或重复".to_string());
68+
}
69+
// 归属校验:全部 id 必须当前属于该 kind 且未置顶(陈旧/置顶序整体拒绝——
70+
// 置顶组由 pin 列置顶区表达,不占手动序位)
71+
let ok = state
72+
.db
73+
.verify_group_order_membership(&kind, &group_ids)
74+
.map_err(|e| e.to_string())?;
75+
if !ok {
76+
return Err("部分组不属于该分区或已置顶——请刷新后再排序".to_string());
77+
}
78+
state
79+
.db
80+
.save_group_order(&kind, &group_ids)
81+
.map_err(|e| e.to_string())?;
82+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::NoteGroups);
83+
Ok(())
84+
}
85+
86+
/// 清除单组手动位(=该组回自动区;幂等——无行零动作不广播)。
87+
#[tauri::command]
88+
pub fn note_group_order_clear(state: State<'_, AppState>, group_id: i64) -> Result<(), String> {
89+
if group_id <= 0 {
90+
return Err("无效的组 id".to_string());
91+
}
92+
let cleared = state
93+
.db
94+
.clear_group_order(group_id)
95+
.map_err(|e| e.to_string())?;
96+
if cleared {
97+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::NoteGroups);
98+
}
99+
Ok(())
100+
}
101+
102+
#[cfg(test)]
103+
mod tests {
104+
use super::validate_kind;
105+
106+
#[test]
107+
fn kind_whitelist_enforced() {
108+
assert!(validate_kind("course").is_ok());
109+
assert!(validate_kind("topic").is_ok());
110+
assert!(validate_kind("standalone").is_ok());
111+
assert!(validate_kind("feed").is_ok());
112+
assert!(validate_kind("").is_err());
113+
assert!(validate_kind("note; DROP TABLE note_groups").is_err());
114+
assert!(validate_kind("Course").is_err());
115+
}
116+
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,17 @@ CREATE TABLE IF NOT EXISTS contracts (
324324
CREATE INDEX IF NOT EXISTS idx_note_orders_scope ON note_orders(scope, ord);
325325
",
326326
)?;
327+
// REQ-315(v0.20.11 批 6):组手动排序表——group_id 主键(组 1:1),seq=kind
328+
// 分区内相对序号(渲染时按 kind 分区消费)。与 note_orders 无 FK 的差异:
329+
// 笔记会跨 scope 迁移故需 purge 防 id 复活旧序位,组即排序 scope 本体、删除即
330+
// 消亡无复用路径——FK ON DELETE CASCADE 即完整清理(读写在 db_note_group_orders.rs)
331+
conn.execute_batch(
332+
"CREATE TABLE IF NOT EXISTS note_group_orders (
333+
group_id INTEGER PRIMARY KEY REFERENCES note_groups(id) ON DELETE CASCADE,
334+
seq INTEGER NOT NULL
335+
);
336+
",
337+
)?;
327338
// v0.5.0 M1(REQ-043):旧库迁移——sessions 表补 profile 列(兼容既有数据库)
328339
ensure_column(conn, "sessions", "profile", "ALTER TABLE sessions ADD COLUMN profile TEXT")?;
329340
// v0.11.7(图文会话,ADR-020):旧库迁移——sessions 表补 kind 列
@@ -464,6 +475,14 @@ CREATE TABLE IF NOT EXISTS contracts (
464475
"color",
465476
"ALTER TABLE note_groups ADD COLUMN color TEXT",
466477
)?;
478+
// REQ-315(v0.20.11 批 6):旧库迁移——note_groups 补 pin 列
479+
// (0=未置顶/1=置顶;NOT NULL DEFAULT 0——置顶区排序第一条件,老数据零迁移)
480+
ensure_column(
481+
conn,
482+
"note_groups",
483+
"pin",
484+
"ALTER TABLE note_groups ADD COLUMN pin INTEGER NOT NULL DEFAULT 0",
485+
)?;
467486
// v0.14.1:旧库迁移——knowledge_canvas_states 补画布偏好三列
468487
// (CREATE TABLE 只对新库生效;旧行升级后落 DEFAULT——行为与默认设置一致,
469488
// 零破坏纪律:不猜用户偏好,首开即默认 smoothstep + radial)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//! 组排序与置顶数据层(REQ-315,v0.20.11 批 6)。
2+
//!
3+
//! @ai-context: 交互矩阵扩展自 REQ-287 笔记手动序(note_orders):组列表按 kind
4+
//! 分区——分区渲染序 = 置顶区(pin=1,区内按 updated_at 降序)→
5+
//! 手动区(本表 seq 升序)→ 自动区(其余按 updated_at 降序)。
6+
//! save=分区快照(先删后插单事务;空 ids=分区整体回自动);clear=
7+
//! 单组回自动(稀疏行合法——渲染规则②③支持缺行成员回落自动区)。
8+
//! 置顶组不参与手动序写入(save 校验拒绝),存量行保留——取消置顶
9+
//! 即回原手动位(notes.pin 同语义,批 6 措辞统一为「置顶」)。
10+
//! @ai-context: FK ON DELETE CASCADE 随组删除清行(与 note_orders 无 FK 的差异:
11+
//! 笔记移组跨 scope 复用 id 需 purge 防"移回复活旧序位",组即排序
12+
//! scope 本体、删除即消亡无复用路径)。kind 变更边界(改判换分区)
13+
//! 在 db_note_groups::override_group_route 同事务清行。
14+
//! @ai-context: pin 列存 note_groups(与 notes.pin 对齐),但更新方法落本模块——
15+
//! 置顶=排序域第一区操作,与手动序同域内聚(db_note_groups.rs 已
16+
//! 接近 300 行上限,AGENTS §3 单文件约束)。
17+
18+
use rusqlite::params;
19+
20+
use crate::db::{unix_seconds, Db};
21+
use crate::error::Result;
22+
23+
/// 手动序 kind 分区白名单(对齐渲染分区 course/topic/standalone/feed——非法值
24+
/// 拒绝防任意入库;feed 分区当前无自然成员但保留合法位防前端枚举扩展炸命令)。
25+
pub fn is_valid_group_order_kind(kind: &str) -> bool {
26+
matches!(kind, "course" | "topic" | "standalone" | "feed")
27+
}
28+
29+
impl Db {
30+
/// 组置顶状态(REQ-315;pin=1 置顶 / 0 取消——排序纯函数置顶区消费)。
31+
///
32+
/// @ai-context: updated_at 同步刷新:置顶动作把它推为置顶区内最新
33+
/// (置顶区内部按更新时间降序,与取消置顶回落自动区的口径一致)。
34+
pub fn update_note_group_pin(&self, id: i64, pin: i64) -> Result<bool> {
35+
self.with_conn(|conn| {
36+
let affected = conn.execute(
37+
"UPDATE note_groups SET pin = ?1, updated_at = ?2 WHERE id = ?3",
38+
params![pin, unix_seconds(), id],
39+
)?;
40+
Ok(affected > 0)
41+
})
42+
}
43+
44+
/// 全量组手动序(group_id, seq)——侧栏/树头消费(量小整拉,渲染排序在前端
45+
/// 纯函数,本层不替前端预排;seq 并列按 id 决胜保证确定序——测试可断言)。
46+
pub fn load_group_orders(&self) -> Result<Vec<(i64, i64)>> {
47+
self.with_conn(|conn| {
48+
let mut stmt = conn
49+
.prepare("SELECT group_id, seq FROM note_group_orders ORDER BY seq, group_id")?;
50+
let rows = stmt.query_map([], |row| {
51+
Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
52+
})?;
53+
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
54+
})
55+
}
56+
57+
/// 保存某 kind 分区完整手动序(快照:先删该分区全部行再按序编号插入)。
58+
///
59+
/// @ai-context: 删除域=kind 全分区而非仅入参 ids——组改判/置顶遗留的陈旧行
60+
/// 一并清(分区渲染只认本表 seq,不留双轨);空 ids=分区整体
61+
/// 回自动排序(前端「手排 ↺」复位语义)。
62+
pub fn save_group_order(&self, kind: &str, group_ids: &[i64]) -> Result<()> {
63+
// 事务需要 &mut Connection(with_conn 只给 &Connection——同 delete_group)
64+
let mut conn = self
65+
.conn
66+
.lock()
67+
.unwrap_or_else(|poisoned| poisoned.into_inner());
68+
let tx = conn.transaction()?;
69+
tx.execute(
70+
"DELETE FROM note_group_orders WHERE group_id IN
71+
(SELECT id FROM note_groups WHERE kind = ?1)",
72+
params![kind],
73+
)?;
74+
{
75+
let mut stmt = tx.prepare("INSERT INTO note_group_orders (group_id, seq) VALUES (?1, ?2)")?;
76+
for (i, id) in group_ids.iter().enumerate() {
77+
stmt.execute(params![*id, i as i64])?;
78+
}
79+
}
80+
tx.commit()?;
81+
Ok(())
82+
}
83+
84+
/// 清除单组手动位(=该组回自动区;幂等——无行零动作返回 false 不广播)。
85+
pub fn clear_group_order(&self, group_id: i64) -> Result<bool> {
86+
self.with_conn(|conn| {
87+
let affected =
88+
conn.execute("DELETE FROM note_group_orders WHERE group_id = ?1", params![group_id])?;
89+
Ok(affected > 0)
90+
})
91+
}
92+
93+
/// 归属校验(save 守卫):每个 id 必须当前属于该 kind 分区且未置顶——陈旧
94+
/// 序/置顶组误写整体拒绝(前端 bug/并发改判后的分区污染不落库)。
95+
pub fn verify_group_order_membership(&self, kind: &str, group_ids: &[i64]) -> Result<bool> {
96+
self.with_conn(|conn| {
97+
if group_ids.is_empty() {
98+
return Ok(true);
99+
}
100+
let mut sql = String::from(
101+
"SELECT id FROM note_groups WHERE kind = ?1 AND pin = 0 AND id IN (",
102+
);
103+
for (i, _) in group_ids.iter().enumerate() {
104+
if i > 0 {
105+
sql.push(',');
106+
}
107+
sql.push_str(&format!("?{}", i + 2));
108+
}
109+
sql.push(')');
110+
let mut stmt = conn.prepare(&sql)?;
111+
let mut ps: Vec<&dyn rusqlite::ToSql> = vec![&kind];
112+
for id in group_ids {
113+
ps.push(id);
114+
}
115+
let rows = stmt.query_map(rusqlite::params_from_iter(ps), |row| {
116+
row.get::<_, i64>(0)
117+
})?;
118+
let found: Vec<i64> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
119+
let want: std::collections::HashSet<i64> = group_ids.iter().copied().collect();
120+
let got: std::collections::HashSet<i64> = found.into_iter().collect();
121+
Ok(got == want)
122+
})
123+
}
124+
}
125+
126+
#[cfg(test)]
127+
#[path = "db_note_group_orders_tests.rs"]
128+
mod tests;

0 commit comments

Comments
 (0)