Skip to content

Commit a96da78

Browse files
committed
feat(photo-session): 图文采集 5 命令 + 与实时捕获互斥 + 残留清扫接线
1 parent 4de6c08 commit a96da78

7 files changed

Lines changed: 234 additions & 1 deletion

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,9 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
241241
// v0.8.0 M2(REQ-145):AI 异步任务注册表 + id 序列
242242
ai_tasks,
243243
ai_task_seq,
244+
// v0.11.7(图文会话,ADR-020):图文采集互斥槽(会话 id + 长驻图片库 store)
245+
photo_session: std::sync::Arc::new(std::sync::Mutex::new(None)),
246+
photo_store: std::sync::Arc::new(std::sync::Mutex::new(None)),
244247
});
245248
Ok(())
246249
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ pub struct AppState {
159159
pub ai_tasks: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<u64, crate::commands_ai_refine::AiTaskEntry>>>,
160160
/// v0.8.0 M2(REQ-145):任务 id 序列(原子递增——并发安全)
161161
pub ai_task_seq: std::sync::Arc<std::sync::atomic::AtomicU64>,
162+
/// v0.11.7(图文会话,ADR-020):进行中的图文采集会话 id(互斥槽;
163+
/// start/finish/discard 独占修改——同一时刻最多一个图文采集)
164+
pub photo_session: std::sync::Arc<std::sync::Mutex<Option<i64>>>,
165+
/// v0.11.7(图文会话,ADR-020):图文图片库 store(跨截图常驻——保持
166+
/// 双指纹去重 FIFO 与预算计数;与 photo_session 同生命周期)
167+
pub photo_store: std::sync::Arc<std::sync::Mutex<Option<crate::image_store::SessionImageStore>>>,
162168
}
163169

164170
/// 枚举可捕获的窗口/进程(课堂助手目标窗口选择,含推荐评分)。

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ pub async fn start_live_session(
4747
window_id: Option<i64>,
4848
profile: Option<String>,
4949
) -> Result<i64, String> {
50+
// v0.11.7(图文会话,ADR-020):互斥——图文采集中不得开始实时捕获
51+
if state.photo_session.lock().map(|g| g.is_some()).unwrap_or(false) {
52+
return Err("图文采集进行中,请先完成或放弃".to_string());
53+
}
5054
// 防御性校验:标题归一化(与 create_session 同口径)
5155
let trimmed = title.trim().to_string();
5256
let title: String = if trimmed.is_empty() {
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
//! 图文会话 Tauri commands(v0.11.7,ADR-020)。
2+
//!
3+
//! @ai-context: 第三采集动线编排:start(创建 kind=photo 会话 + 互斥 + 图片库
4+
//! store 常驻)→ capture_screen_snapshot(全屏快照 base64,前端
5+
//! 框选)→ save_photo_capture(截图落库 + OCR)→ finish/discard。
6+
//! @ai-context: 本层只做参数校验、互斥、调用业务模块、错误映射(AGENTS.md §6)。
7+
//! @ai-context: 互斥槽双字段——photo_session(会话 id)与 photo_store(长驻
8+
//! SessionImageStore,跨截图保持双指纹去重 FIFO 与预算计数)。
9+
10+
use base64::Engine;
11+
use serde::Serialize;
12+
use tauri::State;
13+
14+
use crate::commands::AppState;
15+
use crate::image_store::SessionImageStore;
16+
use crate::types::NewSession;
17+
18+
/// 全屏快照(前端遮罩显示用;JPEG 压缩省传输量——框选裁剪后走 PNG 无损回传)。
19+
#[derive(Debug, Clone, Serialize)]
20+
#[serde(rename_all = "camelCase")]
21+
pub struct ScreenSnapshot {
22+
/// JPEG base64(前端拼 data URL 前缀)
23+
pub base64: String,
24+
/// 图像物理像素宽(DPI 换算基准)
25+
pub width: u32,
26+
/// 图像物理像素高
27+
pub height: u32,
28+
}
29+
30+
/// 互斥检查(纯函数,可单测):实时捕获活跃或图文采集已占用 → Err。
31+
/// @ai-context: 抽纯函数便于单测(命令层仅转发——AGENTS.md §6);
32+
/// 互斥理由:图文与实时共享屏幕内容动线,避免状态混乱。
33+
pub fn check_photo_mutex(live_active: bool, occupied: Option<i64>) -> Result<(), String> {
34+
if live_active {
35+
return Err("实时捕获进行中,请先停止再开始图文采集".to_string());
36+
}
37+
if occupied.is_some() {
38+
return Err("已有进行中的图文采集,请先完成或放弃".to_string());
39+
}
40+
Ok(())
41+
}
42+
43+
/// 当前 Unix 毫秒(截图文件名时间戳;唯一且随真实时间推进)。
44+
fn now_unix_ms() -> u64 {
45+
std::time::SystemTime::now()
46+
.duration_since(std::time::UNIX_EPOCH)
47+
.map(|d| d.as_millis() as u64)
48+
.unwrap_or(0)
49+
}
50+
51+
/// 开始图文采集:创建 kind=photo 会话(recording)并占用互斥槽。
52+
///
53+
/// @ai-context: 默认标题「图文会话」——前端生成带时间的标题传入
54+
/// (JS Date 格式化,Rust 侧免引本地时间依赖)。
55+
#[tauri::command]
56+
pub fn start_photo_session(state: State<'_, AppState>, title: Option<String>) -> Result<i64, String> {
57+
#[cfg(target_os = "windows")]
58+
let live_active = state.live_session.active_session_id().is_some();
59+
#[cfg(not(target_os = "windows"))]
60+
let live_active = false;
61+
let mut guard = state.photo_session.lock().map_err(|_| "图文采集状态锁中毒".to_string())?;
62+
check_photo_mutex(live_active, *guard)?;
63+
let title = title
64+
.map(|t| t.trim().to_string())
65+
.filter(|t| !t.is_empty())
66+
.unwrap_or_else(|| "图文会话".to_string());
67+
let session = state
68+
.db
69+
.create_session(&NewSession {
70+
title: title.chars().take(100).collect(),
71+
source_window: None,
72+
profile: None,
73+
kind: Some("photo".to_string()),
74+
})
75+
.map_err(|e| e.to_string())?;
76+
*guard = Some(session.id);
77+
// 长驻图片库 store(跨截图保持去重 FIFO 与预算计数——实时链路同模式)
78+
let dir = state.data_dir.join("session-images").join(session.id.to_string());
79+
let store = SessionImageStore::new(dir).map_err(|e| e.to_string())?;
80+
*state.photo_store.lock().map_err(|_| "图文图片库锁中毒".to_string())? = Some(store);
81+
Ok(session.id)
82+
}
83+
84+
/// 捕获全屏快照(虚拟屏合并区域,多显示器支持)→ JPEG base64。
85+
/// @ai-context: 无需 AppState(gdi_capture 一次性调用,无持久状态)。
86+
#[tauri::command]
87+
pub fn capture_screen_snapshot() -> Result<ScreenSnapshot, String> {
88+
let frame = crate::capture::gdi_capture::gdi_capture(None, now_unix_ms())
89+
.map_err(|e| format!("截屏失败: {}", e))?;
90+
let rgb = crate::image_store::bgra_to_rgb_public(&frame.bgraw, frame.width, frame.height)
91+
.ok_or_else(|| "截屏数据无效".to_string())?;
92+
// JPEG q0.85(仅前端显示用;OCR 输入走裁剪后 PNG 无损回传)
93+
let mut out = std::io::Cursor::new(Vec::new());
94+
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 85);
95+
rgb.write_with_encoder(encoder).map_err(|e| format!("快照编码失败: {}", e))?;
96+
let b64 = base64::engine::general_purpose::STANDARD.encode(out.into_inner());
97+
Ok(ScreenSnapshot { base64: b64, width: frame.width, height: frame.height })
98+
}
99+
100+
/// 保存框选截图 + OCR(PNG base64 无损回传)。
101+
///
102+
/// @ai-context: 三校验——互斥槽归属(防越权提交其他会话)+ 会话状态
103+
/// (recording 才可继续截图)+ kind=photo 限定。
104+
/// @ai-context: store 锁内调用业务函数(OCR ≤20s 有界等待;图文采集为
105+
/// 用户串行动线,同刻仅一个截图保存,锁时长可接受)。
106+
#[tauri::command]
107+
pub fn save_photo_capture(
108+
state: State<'_, AppState>,
109+
session_id: i64,
110+
image_b64: String,
111+
) -> Result<crate::photo_capture::PhotoCaptureResult, String> {
112+
if session_id <= 0 {
113+
return Err("无效的会话 id".to_string());
114+
}
115+
if image_b64.trim().is_empty() {
116+
return Err("图片数据为空".to_string());
117+
}
118+
{
119+
let guard = state.photo_session.lock().map_err(|_| "图文采集状态锁中毒".to_string())?;
120+
if *guard != Some(session_id) {
121+
return Err("该会话不是进行中的图文采集".to_string());
122+
}
123+
}
124+
let session = state
125+
.db
126+
.get_session(session_id)
127+
.map_err(|e| e.to_string())?
128+
.ok_or_else(|| format!("会话不存在: {}", session_id))?;
129+
if session.kind.as_deref() != Some("photo") {
130+
return Err("该会话不是图文会话".to_string());
131+
}
132+
if session.status != "recording" {
133+
return Err("会话已结束,无法继续截图".to_string());
134+
}
135+
let mut guard = state.photo_store.lock().map_err(|_| "图文图片库锁中毒".to_string())?;
136+
let store = guard.as_mut().ok_or_else(|| "无进行中的图文图片库(请先开始图文采集)".to_string())?;
137+
crate::photo_capture::save_photo_capture(store, &state.db, &state.engines, session_id, &image_b64, now_unix_ms())
138+
}
139+
140+
/// 完成图文采集:零截图 → 删除会话(不留空壳);否则 finish。
141+
#[tauri::command]
142+
pub fn finish_photo_session(state: State<'_, AppState>, session_id: i64) -> Result<String, String> {
143+
if session_id <= 0 {
144+
return Err("无效的会话 id".to_string());
145+
}
146+
let mut guard = state.photo_session.lock().map_err(|_| "图文采集状态锁中毒".to_string())?;
147+
if *guard != Some(session_id) {
148+
return Err("该会话不是进行中的图文采集".to_string());
149+
}
150+
let dir = state.data_dir.join("session-images").join(session_id.to_string());
151+
let has_images = SessionImageStore::new(dir.clone())
152+
.map(|s| !s.list_images().is_empty())
153+
.unwrap_or(false);
154+
if has_images {
155+
state.db.finish_session(session_id).map_err(|e| e.to_string())?;
156+
} else {
157+
state.db.delete_session(session_id).map_err(|e| e.to_string())?;
158+
let _ = std::fs::remove_dir_all(&dir);
159+
}
160+
*guard = None;
161+
*state.photo_store.lock().map_err(|_| "图文图片库锁中毒".to_string())? = None;
162+
Ok(if has_images {
163+
format!("图文会话 #{} 已保存", session_id)
164+
} else {
165+
"无截图,会话已删除(不留空壳)".to_string()
166+
})
167+
}
168+
169+
/// 放弃图文采集:删除会话(外键级联子表 + 清理图片库目录)。
170+
#[tauri::command]
171+
pub fn discard_photo_session(state: State<'_, AppState>, session_id: i64) -> Result<(), String> {
172+
if session_id <= 0 {
173+
return Err("无效的会话 id".to_string());
174+
}
175+
let mut guard = state.photo_session.lock().map_err(|_| "图文采集状态锁中毒".to_string())?;
176+
if *guard != Some(session_id) {
177+
return Err("该会话不是进行中的图文采集".to_string());
178+
}
179+
state.db.delete_session(session_id).map_err(|e| e.to_string())?;
180+
let dir = state.data_dir.join("session-images").join(session_id.to_string());
181+
let _ = std::fs::remove_dir_all(&dir);
182+
*guard = None;
183+
*state.photo_store.lock().map_err(|_| "图文图片库锁中毒".to_string())? = None;
184+
Ok(())
185+
}
186+
187+
#[cfg(test)]
188+
mod tests {
189+
use super::*;
190+
191+
#[test]
192+
fn mutex_rejects_live_active() {
193+
let e = check_photo_mutex(true, None).unwrap_err();
194+
assert!(e.contains("实时捕获"));
195+
}
196+
197+
#[test]
198+
fn mutex_rejects_occupied_photo() {
199+
let e = check_photo_mutex(false, Some(7)).unwrap_err();
200+
assert!(e.contains("已有进行中"));
201+
}
202+
203+
#[test]
204+
fn mutex_allows_when_free() {
205+
assert!(check_photo_mutex(false, None).is_ok());
206+
}
207+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ pub async fn list_sessions(
6969
limit: Option<u64>,
7070
offset: Option<u64>,
7171
) -> Result<Vec<SessionListItem>, String> {
72+
// v0.11.7(图文会话,ADR-020):崩溃残留清扫(kind=photo + recording +
73+
// 超 24h → failed)——清扫失败不阻断列表(防御:列表永远可用)
74+
if let Err(e) = state.db.sweep_stale_photo_sessions(24 * 3600) {
75+
eprintln!("[sessions] 图文残留清扫失败(列表照常返回): {e}");
76+
}
7277
let limit = limit.unwrap_or(50).min(LIST_LIMIT_MAX);
7378
// REQ-176(v0.7.5):残留 recording 会话兜底——线程已死但 DB 停留
7479
// recording(停止链路异常/崩溃,会话31 实证)→ 列表拉取即翻案,

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ mod commands_contracts;
8787
// 实时会话链路依赖 Windows 捕获 API(WASAPI/DXGI/COM),非 Windows 平台不编译(TD-027 修复)
8888
#[cfg(target_os = "windows")]
8989
mod commands_live;
90+
// v0.11.7(图文会话,ADR-020):图文采集命令层(截屏导入第三动线;与实时捕获互斥)
91+
mod commands_photo;
9092
mod commands_ai;
9193
// v0.8.0 M1(REQ-138/139/140):AI 使能层命令——密钥管理/余额/授权/审计
9294
mod commands_ai_settings;
@@ -354,6 +356,12 @@ pub fn run() {
354356
commands::build_draft,
355357
commands::save_draft_as_note,
356358
commands::process_to_note,
359+
// v0.11.7(图文会话,ADR-020):图文采集 5 命令
360+
commands_photo::start_photo_session,
361+
commands_photo::capture_screen_snapshot,
362+
commands_photo::save_photo_capture,
363+
commands_photo::finish_photo_session,
364+
commands_photo::discard_photo_session,
357365
commands::create_note,
358366
commands::list_notes,
359367
commands::get_note,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ fn ocr_blocks(img: &image::RgbImage, engines: &EnginePool) -> (Vec<crate::types:
127127
let (w, h) = img.dimensions();
128128
let scale = if w > OCR_MAX_WIDTH { w as f32 / OCR_MAX_WIDTH as f32 } else { 1.0 };
129129
let input = if w > OCR_MAX_WIDTH {
130-
let new_h = ((h as u64 * OCR_MAX_WIDTH as u64 / w as u64)).max(1) as u32;
130+
let new_h = (h as u64 * OCR_MAX_WIDTH as u64 / w as u64).max(1) as u32;
131131
image::imageops::resize(img, OCR_MAX_WIDTH, new_h, image::imageops::FilterType::Triangle)
132132
} else {
133133
img.clone()

0 commit comments

Comments
 (0)