Skip to content

Commit 25386d9

Browse files
committed
fix(photo-session): 审查修复 8 项——store 失败回滚/像素上限/计数口径/互斥窗口/Esc 与 resize/裁剪越界/再来一组
1 parent fea51ae commit 25386d9

6 files changed

Lines changed: 86 additions & 27 deletions

File tree

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,14 @@ fn now_unix_ms() -> u64 {
5454
/// (JS Date 格式化,Rust 侧免引本地时间依赖)。
5555
#[tauri::command]
5656
pub fn start_photo_session(state: State<'_, AppState>, title: Option<String>) -> Result<i64, String> {
57+
let mut guard = state.photo_session.lock().map_err(|_| "图文采集状态锁中毒".to_string())?;
58+
// 互斥检查(审查修复:live_active 读取移入锁内——缩小"检查后占用前被
59+
// live 抢先"的 TOCTOU 窗口;残余窗口:live 侧检查与置位分属不同临界区,
60+
// 双面板极端时序仍可能并存——UX 级防护已足够,live 侧同样在开始时查本槽)
5761
#[cfg(target_os = "windows")]
5862
let live_active = state.live_session.active_session_id().is_some();
5963
#[cfg(not(target_os = "windows"))]
6064
let live_active = false;
61-
let mut guard = state.photo_session.lock().map_err(|_| "图文采集状态锁中毒".to_string())?;
6265
check_photo_mutex(live_active, *guard)?;
6366
let title = title
6467
.map(|t| t.trim().to_string())
@@ -73,10 +76,14 @@ pub fn start_photo_session(state: State<'_, AppState>, title: Option<String>) ->
7376
kind: Some("photo".to_string()),
7477
})
7578
.map_err(|e| e.to_string())?;
76-
*guard = Some(session.id);
77-
// 长驻图片库 store(跨截图保持去重 FIFO 与预算计数——实时链路同模式)
79+
// 长驻图片库 store(跨截图保持去重 FIFO 与预算计数——实时链路同模式);
80+
// 审查修复:先建 store 再占用互斥槽——创建失败回滚会话,不留占用与空壳
7881
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())?;
82+
let store = SessionImageStore::new(dir).map_err(|e| {
83+
let _ = state.db.delete_session(session.id);
84+
format!("初始化图片库失败: {}", e)
85+
})?;
86+
*guard = Some(session.id);
8087
*state.photo_store.lock().map_err(|_| "图文图片库锁中毒".to_string())? = Some(store);
8188
Ok(session.id)
8289
}
@@ -148,9 +155,9 @@ pub fn finish_photo_session(state: State<'_, AppState>, session_id: i64) -> Resu
148155
return Err("该会话不是进行中的图文采集".to_string());
149156
}
150157
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);
158+
// 审查修复:store 创建失败视为异常传播(不得当作"无图"而误删会话)
159+
let store = SessionImageStore::new(dir.clone()).map_err(|e| format!("读取图片库失败: {}", e))?;
160+
let has_images = !store.list_images().is_empty();
154161
if has_images {
155162
state.db.finish_session(session_id).map_err(|e| e.to_string())?;
156163
} else {

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

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ const OCR_MAX_WIDTH: u32 = 960;
2222
const MIN_SCORE: f32 = 0.5;
2323
/// 识别预览条数上限(前端即时反馈)。
2424
const PREVIEW_MAX: usize = 3;
25-
/// 单图 base64 解码后字节上限(16MB——解码前拒绝,省 CPU)。
26-
const MAX_PHOTO_BYTES: usize = 16 * 1024 * 1024;
25+
/// base64 串长度上限(16MB 字节 → base64 膨胀 4/3——解码前拒绝,省 CPU)。
26+
const MAX_PHOTO_B64_LEN: usize = 16 * 1024 * 1024 * 4 / 3 + 4;
27+
/// 解码后像素数上限(40MP——压缩炸弹防御:小体积 PNG 可解出超大位图,
28+
/// to_rgb8 前拒绝,避免数百 MB 内存分配)。
29+
const MAX_PIXELS: u64 = 40_000_000;
2730

2831
/// 截图保存结果(前端反馈契约;camelCase 与前端 PhotoResult 对齐)。
2932
#[derive(Debug, Clone, PartialEq, Serialize)]
@@ -75,10 +78,10 @@ pub fn save_photo_capture(
7578
if b.score < MIN_SCORE || b.text.trim().is_empty() {
7679
continue;
7780
}
78-
count += 1;
7981
if preview.len() < PREVIEW_MAX {
8082
preview.push(b.text.clone());
8183
}
84+
// 审查修复:成功落库才计数(与 import_frame 链路口径一致——失败不虚高)
8285
if let Err(e) = db.add_ocr_block(&NewSessionOcrBlock {
8386
session_id,
8487
timestamp_ms,
@@ -95,28 +98,37 @@ pub fn save_photo_capture(
9598
screen_id: None,
9699
}) {
97100
eprintln!("[Photo] OCR 块落库失败: {}", e);
101+
} else {
102+
count += 1;
98103
}
99104
}
100105
Ok(PhotoCaptureResult { duplicated: false, block_count: count, preview, image_ref: rel })
101106
}
102107

103108
/// base64 解码 + 解码器验证(白名单由 image 解码器把关——capture_fragment 同模式)。
104109
fn decode_image(image_b64: &str) -> Result<image::RgbImage, String> {
105-
let bytes = base64::engine::general_purpose::STANDARD
106-
.decode(image_b64.trim())
107-
.map_err(|e| format!("图片 base64 解码失败: {}", e))?;
108-
if bytes.is_empty() {
110+
let trimmed = image_b64.trim();
111+
if trimmed.is_empty() {
109112
return Err("图片数据为空".to_string());
110113
}
111-
if bytes.len() > MAX_PHOTO_BYTES {
112-
return Err(format!("图片超限(>{}MB)", MAX_PHOTO_BYTES / 1024 / 1024));
114+
// 审查修复:解码前按 base64 长度预估拒绝(16MB 字节上限——省解码 CPU)
115+
if trimmed.len() > MAX_PHOTO_B64_LEN {
116+
return Err(format!("图片超限(>{}MB)", MAX_PHOTO_B64_LEN / 4 * 3 / 1024 / 1024));
113117
}
118+
let bytes = base64::engine::general_purpose::STANDARD
119+
.decode(trimmed)
120+
.map_err(|e| format!("图片 base64 解码失败: {}", e))?;
114121
let img = image::load_from_memory(&bytes)
115122
.map_err(|e| format!("图片解码失败(仅支持 PNG/JPEG 等常见格式): {}", e))?;
116123
let (w, h) = img.dimensions();
117124
if w == 0 || h == 0 {
118125
return Err("图片尺寸非法".to_string());
119126
}
127+
// 审查修复:像素数上限(压缩炸弹防御——小体积 PNG 解出超大位图时,
128+
// to_rgb8 的 w*h*3 分配可达数百 MB,解码后立即拒绝)
129+
if (w as u64) * (h as u64) > MAX_PIXELS {
130+
return Err(format!("图片像素超限(>{}MP)", MAX_PIXELS / 1_000_000));
131+
}
120132
Ok(img.to_rgb8())
121133
}
122134

‎app/src/components/PhotoCapturePanel.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,18 @@ export default function PhotoCapturePanel({ onOpenSessions, onStatus }: Props) {
172172
去会话页查看
173173
</button>
174174
)}
175+
{/* 审查修复:完成后可原地开始下一组采集(无需切页重挂载回 idle) */}
176+
<button
177+
onClick={() => {
178+
setPhase("idle");
179+
setSessionId(null);
180+
setCount(0);
181+
setMsg("");
182+
}}
183+
style={{ ...btn, width: "100%", marginTop: 6, background: "#fff", border: "1px solid #e5e7eb", borderRadius: 6 }}
184+
>
185+
▶ 再来一组
186+
</button>
175187
</>
176188
)}
177189
</div>

‎app/src/components/ScreenSelectOverlay.tsx‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* 先减 letterbox 偏移、除以显示缩放,再按图像物理像素换算;
99
* 不经 window.devicePixelRatio(显示缩放已含在 letterbox scale)。
1010
*/
11-
import { useRef, useState } from "react";
11+
import { useCallback, useEffect, useRef, useState } from "react";
1212
import { normToPixels } from "../utils/photoCrop";
1313

1414
interface Props {
@@ -35,15 +35,28 @@ export default function ScreenSelectOverlay({ src, imageWidth, imageHeight, savi
3535
const [fit, setFit] = useState<{ scale: number; left: number; top: number } | null>(null);
3636

3737
/** letterbox 布局:图像完整可见居中(含窗口外屏幕区域),比例 = 显示/物理 */
38-
const measureFit = () => {
38+
const measureFit = useCallback(() => {
3939
const el = wrapRef.current;
4040
if (!el) return;
4141
const rw = el.clientWidth;
4242
const rh = el.clientHeight;
4343
if (rw <= 0 || rh <= 0 || imageWidth <= 0 || imageHeight <= 0) return;
4444
const scale = Math.min(rw / imageWidth, rh / imageHeight);
4545
setFit({ scale, left: (rw - imageWidth * scale) / 2, top: (rh - imageHeight * scale) / 2 });
46-
};
46+
}, [imageWidth, imageHeight]);
47+
48+
// 审查修复:Esc 取消(与提示文案一致)+ 窗口 resize 重算 letterbox(防框选错位)
49+
useEffect(() => {
50+
const onKey = (e: KeyboardEvent) => {
51+
if (e.key === "Escape") onCancel();
52+
};
53+
window.addEventListener("keydown", onKey);
54+
window.addEventListener("resize", measureFit);
55+
return () => {
56+
window.removeEventListener("keydown", onKey);
57+
window.removeEventListener("resize", measureFit);
58+
};
59+
}, [onCancel, measureFit]);
4760

4861
/** 鼠标坐标 → 图像归一化坐标(减 letterbox 偏移 + 除显示缩放 + clamp) */
4962
const norm = (clientX: number, clientY: number): Box | null => {

‎app/src/utils/photoCrop.test.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,19 @@ describe("normToPixels", () => {
2020
expect(r).toEqual({ x: 480, y: 270, w: 960, h: 540 });
2121
});
2222

23-
it("越界输入 clamp 到图像内", () => {
23+
it("越界输入 clamp 到图像内且输出不越界", () => {
24+
// 输入全越界(起点在右/下边界外)→ 起点收至末行/末列、宽高收至剩余空间
2425
const r = normToPixels(-0.5, 1.5, 2, 2, 100, 100);
25-
expect(r).toEqual({ x: 0, y: 100, w: 100, h: 100 });
26+
expect(r).toEqual({ x: 0, y: 99, w: 100, h: 1 });
27+
});
28+
29+
it("起点在界内但宽高越界 → 收窄到图像边界(审查 P7)", () => {
30+
// 框选右下角 50%,起点 (0.9, 0.9) → 剩余空间仅 10px
31+
const r = normToPixels(0.9, 0.9, 0.5, 0.5, 100, 100);
32+
expect(r).toEqual({ x: 90, y: 90, w: 10, h: 10 });
33+
// 输出矩形必须完全在图像内(canvas 源矩形越界 → 透明带)
34+
expect(r.x + r.w).toBeLessThanOrEqual(100);
35+
expect(r.y + r.h).toBeLessThanOrEqual(100);
2636
});
2737

2838
it("非整数坐标取整(含四舍五入)", () => {

‎app/src/utils/photoCrop.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
*
44
* @ai-context: 归一化坐标(0-1,框选层相对图像)→ 物理像素(GDI 捕获尺寸)。
55
* DPI 换算基准:图像物理像素 × 归一化,不经 window.devicePixelRatio
6-
* (显示缩放已由 letterbox 层消化)。clamp 到图像内、宽高取整。
6+
* (显示缩放已由 letterbox 层消化)。输入 clamp 到 [0,1];输出矩形
7+
* 保证完全在图像内(起点 ≤ 末行/末列、宽高不越界——canvas 源矩形
8+
* 越界会产生透明带,审查 P7 防御)。
79
*/
810
export function normToPixels(
911
x: number,
@@ -17,11 +19,14 @@ export function normToPixels(
1719
const cy = Math.min(1, Math.max(0, y));
1820
const cw = Math.min(1, Math.max(0, w));
1921
const ch = Math.min(1, Math.max(0, h));
22+
// 起点 clamp 到图像末行/末列(round 可能越界 1 像素——如 cx=1 → imageW)
23+
const px = Math.min(imageW - 1, Math.round(cx * imageW));
24+
const py = Math.min(imageH - 1, Math.round(cy * imageH));
2025
return {
21-
x: Math.round(cx * imageW),
22-
y: Math.round(cy * imageH),
23-
// 越界/零宽防御:至少 1 物理像素(canvas 拒绝 0 尺寸)
24-
w: Math.max(1, Math.round(cw * imageW)),
25-
h: Math.max(1, Math.round(ch * imageH)),
26+
x: px,
27+
y: py,
28+
// 宽高:至少 1 物理像素且不越出图像右/下边界(canvas 拒绝 0 尺寸)
29+
w: Math.max(1, Math.min(imageW - px, Math.round(cw * imageW))),
30+
h: Math.max(1, Math.min(imageH - py, Math.round(ch * imageH))),
2631
};
2732
}

0 commit comments

Comments
 (0)