|
| 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