Skip to content

Commit 5bb9c3a

Browse files
committed
feat(ocr): 结构图存储与记录——struct/命名空间+独立预算+新表(REQ-183)
1 parent 1629ea0 commit 5bb9c3a

7 files changed

Lines changed: 588 additions & 3 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ impl Db {
173173
)?;
174174
// 关联查询索引(列表 has_note 子查询 + 笔记页来源跳转共用)
175175
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_notes_session ON notes(session_id)")?;
176+
// v0.7.7(REQ-183):结构图记录表(建表幂等——新库建表/旧库补表)
177+
crate::db_structures::init(&conn)?;
176178
Ok(Self { conn: Arc::new(Mutex::new(conn)) })
177179
}
178180

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

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
//! 结构图记录存储(REQ-183 / v0.7.7):session_structure_images 表 CRUD。
2+
//!
3+
//! @ai-context: 非线性结构图的持久化元数据(文件在会话目录 struct/,本表记录
4+
//! 归属/类型/坐标/时间/来源)——图库 UI 与将来笔记消费(按屏
5+
//! screen_id 锚点)的数据源;文件删除由记录驱动。
6+
//! @ai-context: kind/source 为字符串枚举(不动 RegionKind——版面分析模块零
7+
//! 改动);bbox 为 JSON 帧坐标;旧会话(无 screen_id)NULL 降级。
8+
9+
use rusqlite::{params, Connection, OptionalExtension};
10+
11+
use crate::db::Db;
12+
use crate::error::Result;
13+
14+
/// 结构图记录(前端契约,camelCase)。
15+
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
16+
#[serde(rename_all = "camelCase")]
17+
pub struct StructureImageRecord {
18+
pub id: i64,
19+
pub session_id: i64,
20+
/// 所属屏(NULL=旧数据无屏/手动无屏上下文)
21+
#[serde(default)]
22+
pub screen_id: Option<i64>,
23+
/// table/formula/code/image/manual
24+
pub kind: String,
25+
/// 帧坐标 JSON {"x","y","w","h"}
26+
pub bbox: String,
27+
/// 裁剪源帧时间戳(屏内选优帧/手动屏 first_seen)
28+
pub source_ts_ms: u64,
29+
/// struct/xxx.webp 相对路径
30+
pub crop_path: String,
31+
/// auto/manual
32+
pub source: String,
33+
pub created_at: u64,
34+
}
35+
36+
/// 建表(幂等;db::open 末尾调用——新库建表 + 旧库补表)。
37+
pub fn init(conn: &Connection) -> Result<()> {
38+
conn.execute_batch(
39+
"CREATE TABLE IF NOT EXISTS session_structure_images (
40+
id INTEGER PRIMARY KEY AUTOINCREMENT,
41+
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
42+
screen_id INTEGER,
43+
kind TEXT NOT NULL,
44+
bbox TEXT NOT NULL DEFAULT '{}',
45+
source_ts_ms INTEGER NOT NULL,
46+
crop_path TEXT NOT NULL,
47+
source TEXT NOT NULL DEFAULT 'auto',
48+
created_at INTEGER NOT NULL
49+
);
50+
CREATE INDEX IF NOT EXISTS idx_struct_images_session
51+
ON session_structure_images(session_id, created_at);",
52+
)?;
53+
Ok(())
54+
}
55+
56+
/// 插入记录(返回新 id)。
57+
pub fn insert_structure_image(db: &Db, rec: &StructureImageRecord) -> Result<i64> {
58+
let conn = db.conn.lock().unwrap();
59+
conn.execute(
60+
"INSERT INTO session_structure_images
61+
(session_id, screen_id, kind, bbox, source_ts_ms, crop_path, source, created_at)
62+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
63+
params![
64+
rec.session_id,
65+
rec.screen_id,
66+
rec.kind,
67+
rec.bbox,
68+
rec.source_ts_ms as i64,
69+
rec.crop_path,
70+
rec.source,
71+
rec.created_at as i64,
72+
],
73+
)?;
74+
Ok(conn.last_insert_rowid())
75+
}
76+
77+
/// 会话结构图列表(按入库时间升序——图库展示顺序)。
78+
pub fn list_structure_images(db: &Db, session_id: i64) -> Result<Vec<StructureImageRecord>> {
79+
let conn = db.conn.lock().unwrap();
80+
let mut stmt = conn.prepare(
81+
"SELECT id, session_id, screen_id, kind, bbox, source_ts_ms, crop_path, source, created_at
82+
FROM session_structure_images WHERE session_id = ?1 ORDER BY created_at ASC",
83+
)?;
84+
let rows = stmt
85+
.query_map(params![session_id], row_to_record)?
86+
.collect::<std::result::Result<Vec<_>, _>>()?;
87+
Ok(rows)
88+
}
89+
90+
/// 删除记录(返回被删记录——命令层据此删文件;不存在 → Ok(None))。
91+
pub fn delete_structure_image(db: &Db, id: i64) -> Result<Option<StructureImageRecord>> {
92+
let conn = db.conn.lock().unwrap();
93+
let existing = conn
94+
.query_row(
95+
"SELECT id, session_id, screen_id, kind, bbox, source_ts_ms, crop_path, source, created_at
96+
FROM session_structure_images WHERE id = ?1",
97+
params![id],
98+
row_to_record,
99+
)
100+
.optional()?;
101+
if let Some(rec) = &existing {
102+
conn.execute("DELETE FROM session_structure_images WHERE id = ?1", params![id])?;
103+
}
104+
Ok(existing)
105+
}
106+
107+
/// 行 → 记录映射。
108+
fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<StructureImageRecord> {
109+
Ok(StructureImageRecord {
110+
id: row.get(0)?,
111+
session_id: row.get(1)?,
112+
screen_id: row.get(2)?,
113+
kind: row.get(3)?,
114+
bbox: row.get(4)?,
115+
source_ts_ms: row.get::<_, i64>(5)? as u64,
116+
crop_path: row.get(6)?,
117+
source: row.get(7)?,
118+
created_at: row.get::<_, i64>(8)? as u64,
119+
})
120+
}
121+
122+
#[cfg(test)]
123+
#[path = "db_structures_tests.rs"]
124+
mod tests;
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//! 结构图记录存储单测(REQ-183 / v0.7.7,AAA 模式)。
2+
//!
3+
//! @ai-context: 内存库隔离(不触碰真实数据);外键 ON——记录必须先建真实会话;
4+
//! 覆盖建表幂等/CRUD/删除返回/排序/外键级联(删会话清记录)。
5+
6+
use super::*;
7+
use crate::db::Db;
8+
9+
/// 建会话(外键约束前置)→ 返回 session id。
10+
fn session(db: &Db) -> i64 {
11+
db.create_session(&crate::types::NewSession {
12+
title: "测试会话".to_string(),
13+
source_window: None,
14+
profile: None,
15+
})
16+
.unwrap()
17+
.id
18+
}
19+
20+
/// 构造记录(id 由 DB 分配)。
21+
fn rec(session_id: i64, kind: &str, ts: u64) -> StructureImageRecord {
22+
StructureImageRecord {
23+
id: 0,
24+
session_id,
25+
screen_id: Some(1),
26+
kind: kind.to_string(),
27+
bbox: r#"{"x":10,"y":20,"w":100,"h":50}"#.to_string(),
28+
source_ts_ms: ts,
29+
crop_path: format!("struct/{}.webp", ts),
30+
source: "auto".to_string(),
31+
created_at: ts,
32+
}
33+
}
34+
35+
#[test]
36+
fn insert_and_list_roundtrip() {
37+
// Arrange:内存库 + 会话 + 两条记录
38+
let db = Db::open(":memory:").unwrap();
39+
let sid = session(&db);
40+
let a = rec(sid, "table", 1_000);
41+
let b = rec(sid, "image", 2_000);
42+
43+
// Act
44+
let id_a = insert_structure_image(&db, &a).unwrap();
45+
let id_b = insert_structure_image(&db, &b).unwrap();
46+
let list = list_structure_images(&db, sid).unwrap();
47+
48+
// Assert:id 分配 + 按入库时间升序 + 字段完整
49+
assert!(id_a < id_b);
50+
assert_eq!(list.len(), 2);
51+
assert_eq!(list[0].crop_path, "struct/1000.webp");
52+
assert_eq!(list[1].kind, "image");
53+
assert_eq!(list[1].source_ts_ms, 2_000);
54+
assert_eq!(list[0].screen_id, Some(1));
55+
}
56+
57+
#[test]
58+
fn list_scoped_to_session() {
59+
// Arrange:两个会话各一条
60+
let db = Db::open(":memory:").unwrap();
61+
let s1 = session(&db);
62+
let s2 = session(&db);
63+
insert_structure_image(&db, &rec(s1, "code", 1_000)).unwrap();
64+
insert_structure_image(&db, &rec(s2, "manual", 5_000)).unwrap();
65+
66+
// Act
67+
let list = list_structure_images(&db, s1).unwrap();
68+
69+
// Assert:只返回会话 1
70+
assert_eq!(list.len(), 1);
71+
assert_eq!(list[0].session_id, s1);
72+
}
73+
74+
#[test]
75+
fn delete_returns_record_then_missing() {
76+
// Arrange
77+
let db = Db::open(":memory:").unwrap();
78+
let sid = session(&db);
79+
let id = insert_structure_image(&db, &rec(sid, "formula", 3_000)).unwrap();
80+
81+
// Act:删除(返回记录)+ 再删(None)
82+
let removed = delete_structure_image(&db, id).unwrap();
83+
let again = delete_structure_image(&db, id).unwrap();
84+
85+
// Assert:首删返回记录(文件删除由命令层驱动),再删 None
86+
assert_eq!(removed.as_ref().unwrap().kind, "formula");
87+
assert_eq!(removed.unwrap().crop_path, "struct/3000.webp");
88+
assert!(again.is_none());
89+
assert!(list_structure_images(&db, sid).unwrap().is_empty());
90+
}
91+
92+
#[test]
93+
fn init_is_idempotent_and_supports_null_screen() {
94+
// Arrange:重复 init 不报错
95+
let db = Db::open(":memory:").unwrap();
96+
let sid = session(&db);
97+
98+
// Act:再次建表(幂等)+ 无屏记录(旧数据降级形态)
99+
{
100+
let conn = db.conn.lock().unwrap();
101+
init(&conn).unwrap();
102+
}
103+
let mut old = rec(sid, "image", 1_000);
104+
old.screen_id = None;
105+
insert_structure_image(&db, &old).unwrap();
106+
let list = list_structure_images(&db, sid).unwrap();
107+
108+
// Assert:幂等成功 + NULL screen_id 往返
109+
assert_eq!(list[0].screen_id, None);
110+
}
111+
112+
#[test]
113+
fn session_cascade_deletes_records() {
114+
// Arrange:建会话 + 结构图记录
115+
let db = Db::open(":memory:").unwrap();
116+
let sid = session(&db);
117+
insert_structure_image(&db, &rec(sid, "table", 1_000)).unwrap();
118+
assert_eq!(list_structure_images(&db, sid).unwrap().len(), 1);
119+
120+
// Act:删除会话(外键级联)
121+
db.delete_session(sid).unwrap();
122+
123+
// Assert:记录随会话清除
124+
assert!(list_structure_images(&db, sid).unwrap().is_empty());
125+
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,8 @@ pub fn encode_webp_public(bgraw: &[u8], width: u32, height: u32, path: &Path) ->
267267
}
268268

269269
/// BGRA8 → RGB(纯函数;尺寸/长度不匹配返回 None)。
270-
fn bgra_to_rgb(bgraw: &[u8], width: u32, height: u32) -> Option<image::RgbImage> {
270+
/// @ai-context: pub(crate)(v0.7.7 REQ-183):structure_store 结构图存储复用。
271+
pub(crate) fn bgra_to_rgb(bgraw: &[u8], width: u32, height: u32) -> Option<image::RgbImage> {
271272
let pixel_len = width as usize * height as usize * 4;
272273
if width == 0 || height == 0 || bgraw.len() != pixel_len {
273274
return None;
@@ -280,7 +281,8 @@ fn bgra_to_rgb(bgraw: &[u8], width: u32, height: u32) -> Option<image::RgbImage>
280281
}
281282

282283
/// 编码 WebP(lossless)到文件(纯 IO)。
283-
fn encode_webp(rgb: &image::RgbImage, path: &Path) -> Result<()> {
284+
/// @ai-context: pub(crate)(v0.7.7 REQ-183):structure_store 结构图存储复用。
285+
pub(crate) fn encode_webp(rgb: &image::RgbImage, path: &Path) -> Result<()> {
284286
let mut out = std::io::Cursor::new(Vec::new());
285287
let encoder = image::codecs::webp::WebPEncoder::new_lossless(&mut out);
286288
rgb.write_with_encoder(encoder)
@@ -290,7 +292,8 @@ fn encode_webp(rgb: &image::RgbImage, path: &Path) -> Result<()> {
290292
}
291293

292294
/// 缩略图(最近邻缩小 BGRA 到 THUMB 尺寸,保持宽高比;纯函数)。
293-
fn resize_bgra(bgraw: &[u8], width: u32, height: u32) -> Option<image::RgbImage> {
295+
/// @ai-context: pub(crate)(v0.7.7 REQ-183):structure_store 结构图缩略图复用。
296+
pub(crate) fn resize_bgra(bgraw: &[u8], width: u32, height: u32) -> Option<image::RgbImage> {
294297
let rgb = bgra_to_rgb(bgraw, width, height)?;
295298
if width <= THUMB_MAX_WIDTH && height <= THUMB_MAX_HEIGHT {
296299
return Some(rgb);

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ mod commands_video;
6161
mod concat;
6262
mod db;
6363
mod db_artifacts;
64+
// v0.7.7(REQ-183):结构图记录存储——session_structure_images 表 CRUD
65+
mod db_structures;
6466
// v0.7.0 M1.5(REQ-108):会话信号事件数据层(统一信号事件表读写)
6567
mod db_session_events;
6668
mod db_sessions;
@@ -154,6 +156,8 @@ mod structure_models;
154156
mod structure_note;
155157
// v0.7.7(REQ-182):结构图检测纯函数——diagram_likeness/pick_sharpest/区域过滤
156158
mod structure_detect;
159+
// v0.7.7(REQ-183):结构图存储——struct/ 命名空间 + 独立预算 + 去重
160+
mod structure_store;
157161
mod structure_tier;
158162
mod stutter_fold;
159163
mod subtitle;

0 commit comments

Comments
 (0)