Skip to content

Commit 9805c02

Browse files
committed
feat(feed): 碎片原料层与快速捕获命令
1 parent 93795b6 commit 9805c02

8 files changed

Lines changed: 547 additions & 1 deletion

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
101101
// v0.5.0 M8(REQ-055):补缝式 AI 护栏骨架(配额/缓存/审计;云端 V1.0 生效)
102102
let ai_guardrails =
103103
std::sync::Arc::new(std::sync::Mutex::new(crate::ai_guardrails::AiGuardrails::default()));
104+
// v0.11.1:功能开关(feed_capture 默认关——v4 §11.3;JSON 持久化,
105+
// 缺失/损坏回退默认不阻断启动;同 ai_settings 模式)
106+
let feature_flags_path = data_dir.join("feature_flags.json");
107+
let feature_flags = std::sync::Arc::new(std::sync::Mutex::new(
108+
crate::feature_flags::FeatureFlags::load(&feature_flags_path),
109+
));
104110
// v0.6.0 M1(REQ-083/060):可校准配置——ui_junk.json 黑名单与
105111
// symbol_map.json 映射表(缺失走内置默认;损坏回退默认,不阻断启动)
106112
let ui_junk = crate::ui_junk::UiJunkList::load(&data_dir.join("ui_junk.json"));
@@ -210,6 +216,8 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
210216
profile_memory,
211217
data_dir,
212218
ai_guardrails,
219+
feature_flags,
220+
feature_flags_path,
213221
structure_downloader,
214222
speaker_downloader,
215223
structure_tier_path,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ pub struct AppState {
113113
/// v0.5.0 M8(REQ-055):补缝式 AI 护栏状态(每日配额 + 同图 hash 缓存;
114114
/// 云端 V1.0 实装后生效,骨架就位)
115115
pub ai_guardrails: std::sync::Arc<std::sync::Mutex<crate::ai_guardrails::AiGuardrails>>,
116+
/// v0.11.1:功能开关内存态单点(锁内 read-modify-write,同词表模式防 TOCTOU)
117+
pub feature_flags: std::sync::Arc<std::sync::Mutex<crate::feature_flags::FeatureFlags>>,
118+
/// v0.11.1:功能开关 JSON 路径(应用数据目录)
119+
pub feature_flags_path: std::path::PathBuf,
116120
/// v0.5.0 模型版(REQ-047/049/050):结构模型下载器(版面/表格/公式按需下载)
117121
pub structure_downloader: crate::structure_models::StructureModelDownloader,
118122
/// TD-2026-08-20-D 清偿(G1):说话人模型下载器(wespeaker 应用内一键下载)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
//! 碎片捕获与功能开关 commands(v0.11.1;feed 进料口系统层)。
2+
//!
3+
//! @ai-context: v4 §11.3 裁决——feed 能力功能开关默认关:capture_fragment
4+
//! 后端二次校验开关(不信前端隐藏);碎片 DomainTag 自动归组
5+
//! 复用 detect_domain 纯函数(领域粒度=契约一,拒绝大类)。
6+
//! @ai-context: 本层只做参数校验、调用数据层、错误映射(AGENTS.md §6);
7+
//! 图片解码验证用 image crate(白名单格式,防任意字节落盘)。
8+
9+
use tauri::State;
10+
11+
use crate::commands::AppState;
12+
use crate::db_fragments::NewFragment;
13+
use crate::types::{Fragment, NewNoteGroup};
14+
use crate::video_profile_domain::{detect_domain, DomainSignals};
15+
16+
/// 碎片文本最大长度(防超大 payload 拖垮 IPC/DB;几句话的碎片远用不到)。
17+
const FRAGMENT_TEXT_MAX: usize = 2000;
18+
/// 碎片图片最大字节数(示范画面截图级;超限拒绝可诊断)。
19+
const FRAGMENT_IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024;
20+
/// 碎片列表单次上限(防无界查询)。
21+
const FRAGMENT_LIST_LIMIT_MAX: usize = 500;
22+
23+
/// 读取功能开关集(前端据此显隐 feed 捕获面)。
24+
#[tauri::command]
25+
pub fn get_feature_flags(state: State<'_, AppState>) -> Result<crate::feature_flags::FeatureFlags, String> {
26+
let guard = state.feature_flags.lock().map_err(|e| format!("开关锁失败: {}", e))?;
27+
Ok(guard.clone())
28+
}
29+
30+
/// 设置功能开关(白名单在 FeatureFlags::set 内;持久化失败回滚内存态)。
31+
#[tauri::command]
32+
pub fn set_feature_flag(
33+
state: State<'_, AppState>,
34+
name: String,
35+
value: bool,
36+
) -> Result<bool, String> {
37+
let mut guard = state.feature_flags.lock().map_err(|e| format!("开关锁失败: {}", e))?;
38+
let mut next = guard.clone();
39+
if !next.set(&name, value) {
40+
return Err(format!("未知功能开关: {}", name));
41+
}
42+
// 先落盘再更新内存态(落盘失败不留半态)
43+
next.save(&state.feature_flags_path).map_err(|e| format!("开关持久化失败: {}", e))?;
44+
*guard = next;
45+
Ok(value)
46+
}
47+
48+
/// 碎片快速捕获(feed 进料口;文本 + 可选图片字节)。
49+
///
50+
/// @ai-context: 流程——开关准入 → 文本校验 → 图片解码验证落盘 →
51+
/// DomainTag 检测 → feed 主题组查/建 → 碎片落库。
52+
/// 图片失败不阻断文本捕获(诚实降级:纯文本碎片)。
53+
#[tauri::command]
54+
pub async fn capture_fragment(
55+
state: State<'_, AppState>,
56+
text: String,
57+
image_bytes: Option<Vec<u8>>,
58+
source: Option<String>,
59+
) -> Result<Fragment, String> {
60+
// 开关准入(后端不信前端隐藏——v4 §11.3 默认关纪律)
61+
{
62+
let guard = state.feature_flags.lock().map_err(|e| format!("开关锁失败: {}", e))?;
63+
if !guard.feed_capture {
64+
return Err("碎片捕获未启用(请在设置中开启 feed 捕获开关)".to_string());
65+
}
66+
}
67+
let text: String = text.trim().chars().take(FRAGMENT_TEXT_MAX).collect();
68+
if text.is_empty() {
69+
return Err("碎片内容不能为空".to_string());
70+
}
71+
let source = match source.as_deref() {
72+
Some("clipboard") => "clipboard".to_string(),
73+
_ => "manual".to_string(),
74+
};
75+
// 图片:解码验证 + 统一转 PNG 落盘(白名单格式由解码器把关)
76+
let image_path = match image_bytes {
77+
Some(bytes) if !bytes.is_empty() => {
78+
match save_fragment_image(&state.data_dir, &bytes) {
79+
Ok(rel) => Some(rel),
80+
Err(e) => {
81+
// 图片失败不阻断文本捕获(降级为纯文本碎片,留日志线索)
82+
eprintln!("[fragments] 图片落盘失败(降级纯文本碎片): {e}");
83+
None
84+
}
85+
}
86+
}
87+
_ => None,
88+
};
89+
// DomainTag 自动归组(detect_domain 标题词路径——碎片文本即判据源)
90+
let detection = detect_domain(&DomainSignals {
91+
title: Some(text.clone()),
92+
..Default::default()
93+
});
94+
let (domain_tag, group_id) = match detection.kind {
95+
Some(kind) => {
96+
let tag = kind.as_str().to_string();
97+
let gid = resolve_feed_topic_group(&state.db, &tag, kind.label())?;
98+
(Some(tag), Some(gid))
99+
}
100+
None => (None, None), // 未命中 → 无组(结算面兜底归组,不硬塞大类)
101+
};
102+
let fragment = state
103+
.db
104+
.create_fragment(&NewFragment {
105+
text,
106+
image_path,
107+
domain_tag,
108+
group_id,
109+
source,
110+
})
111+
.map_err(|e| e.to_string())?;
112+
Ok(fragment)
113+
}
114+
115+
/// 列出碎片(status 过滤 + 数量上限)。
116+
#[tauri::command]
117+
pub fn list_fragments(
118+
state: State<'_, AppState>,
119+
status: Option<String>,
120+
limit: Option<usize>,
121+
) -> Result<Vec<Fragment>, String> {
122+
let status = status.as_deref().filter(|s| !s.trim().is_empty());
123+
if let Some(s) = status {
124+
if s != "active" && s != "archived" {
125+
return Err(format!("不支持的碎片状态: {}(支持: active/archived)", s));
126+
}
127+
}
128+
let limit = limit.unwrap_or(200).min(FRAGMENT_LIST_LIMIT_MAX);
129+
state.db.list_fragments(status, limit).map_err(|e| e.to_string())
130+
}
131+
132+
/// 组内碎片列表(feed 组详情消费)。
133+
#[tauri::command]
134+
pub fn list_group_fragments(state: State<'_, AppState>, group_id: i64) -> Result<Vec<Fragment>, String> {
135+
if group_id <= 0 {
136+
return Err("无效的组 id".to_string());
137+
}
138+
state.db.list_fragments_by_group(group_id).map_err(|e| e.to_string())
139+
}
140+
141+
/// 解码验证并落盘碎片图片(返回 fragments/ 下相对路径)。
142+
///
143+
/// @ai-context: image::load_from_memory 把关格式白名单(png/jpeg/webp/gif/bmp…
144+
/// 解码器支持集);统一转 PNG 存储(前端展示口径单一);
145+
/// 超限字节数前置拒绝(解码前,省 CPU)。
146+
fn save_fragment_image(data_dir: &std::path::Path, bytes: &[u8]) -> Result<String, String> {
147+
if bytes.len() > FRAGMENT_IMAGE_MAX_BYTES {
148+
return Err(format!("图片超限(上限 {} MB)", FRAGMENT_IMAGE_MAX_BYTES / 1024 / 1024));
149+
}
150+
let img = image::load_from_memory(bytes).map_err(|e| format!("图片解码失败: {}", e))?;
151+
let dir = data_dir.join("fragments");
152+
std::fs::create_dir_all(&dir).map_err(|e| format!("创建碎片图目录失败: {}", e))?;
153+
// 文件名 = 毫秒时间戳 + 随机后缀(防同毫秒碰撞)
154+
let name = format!(
155+
"{}-{}.png",
156+
std::time::SystemTime::now()
157+
.duration_since(std::time::UNIX_EPOCH)
158+
.map(|d| d.as_millis())
159+
.unwrap_or(0),
160+
rand_suffix()
161+
);
162+
let path = dir.join(&name);
163+
img.save(&path).map_err(|e| format!("图片保存失败: {}", e))?;
164+
Ok(format!("fragments/{}", name))
165+
}
166+
167+
/// 文件名随机后缀(4 位十六进制——无 rand crate 依赖,系统熵足够防碰撞)。
168+
fn rand_suffix() -> String {
169+
let now = std::time::SystemTime::now()
170+
.duration_since(std::time::UNIX_EPOCH)
171+
.map(|d| d.subsec_nanos())
172+
.unwrap_or(0);
173+
format!("{:04x}", now & 0xFFFF)
174+
}
175+
176+
/// feed 主题组查/建(契约一:同领域同地形唯一)。
177+
fn resolve_feed_topic_group(
178+
db: &crate::db::Db,
179+
domain_tag: &str,
180+
label: &str,
181+
) -> Result<i64, String> {
182+
if let Some(existing) = db.find_topic_group(domain_tag, "feed").map_err(|e| e.to_string())? {
183+
return Ok(existing.id);
184+
}
185+
let group = db
186+
.create_group(&NewNoteGroup {
187+
name: label.to_string(),
188+
terrain: "feed".to_string(),
189+
kind: "topic".to_string(),
190+
domain_tag: Some(domain_tag.to_string()),
191+
source: "route".to_string(),
192+
series_key: None,
193+
route_reason: Some(
194+
serde_json::json!({"action":"topic","reasons":["碎片 DomainTag 自动归组"]}).to_string(),
195+
),
196+
})
197+
.map_err(|e| e.to_string())?;
198+
Ok(group.id)
199+
}

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

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//! 碎片数据层(v0.11.1;fragments 原料层 CRUD)。
2+
//!
3+
//! @ai-context: 碎片与笔记分表是 v4 契约明确要求(不与课程笔记混装);
4+
//! 本层只管读写——DomainTag 归组判定在 commands_fragments.rs
5+
//! (复用 detect_domain 纯函数),组 CRUD 在 db_note_groups.rs。
6+
7+
use rusqlite::params;
8+
9+
use crate::db::{unix_seconds, Db};
10+
use crate::error::Result;
11+
use crate::types::Fragment;
12+
13+
/// fragments 表统一查询列(列顺序与 row_to_fragment 严格对应)。
14+
const FRAGMENT_COLUMNS: &str =
15+
"id, text, image_path, domain_tag, group_id, source, status, created_at";
16+
17+
/// 新建碎片入参(id/created_at 由数据层填充)。
18+
pub struct NewFragment {
19+
pub text: String,
20+
pub image_path: Option<String>,
21+
pub domain_tag: Option<String>,
22+
pub group_id: Option<i64>,
23+
pub source: String,
24+
}
25+
26+
impl Db {
27+
/// 新建碎片,返回完整记录。
28+
pub fn create_fragment(&self, new: &NewFragment) -> Result<Fragment> {
29+
let now = unix_seconds();
30+
self.with_conn(|conn| {
31+
conn.execute(
32+
"INSERT INTO fragments (text, image_path, domain_tag, group_id, source, status, created_at)
33+
VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6)",
34+
params![new.text, new.image_path, new.domain_tag, new.group_id, new.source, now],
35+
)?;
36+
let id = conn.last_insert_rowid();
37+
Ok(Fragment {
38+
id,
39+
text: new.text.clone(),
40+
image_path: new.image_path.clone(),
41+
domain_tag: new.domain_tag.clone(),
42+
group_id: new.group_id,
43+
source: new.source.clone(),
44+
status: "active".to_string(),
45+
created_at: now,
46+
})
47+
})
48+
}
49+
50+
/// 列出碎片(status 过滤:active/archived/None=全部;按创建时间倒序,
51+
/// 同秒按 id 倒序——id 单调递增,后者更新,排序稳定)。
52+
pub fn list_fragments(&self, status: Option<&str>, limit: usize) -> Result<Vec<Fragment>> {
53+
self.with_conn(|conn| {
54+
let sql = match status {
55+
Some(_) => format!(
56+
"SELECT {} FROM fragments WHERE status = ?1 ORDER BY created_at DESC, id DESC LIMIT ?2",
57+
FRAGMENT_COLUMNS
58+
),
59+
None => format!(
60+
"SELECT {} FROM fragments ORDER BY created_at DESC, id DESC LIMIT ?1",
61+
FRAGMENT_COLUMNS
62+
),
63+
};
64+
let mut stmt = conn.prepare(&sql)?;
65+
let rows = match status {
66+
Some(s) => stmt.query_map(params![s, limit as i64], row_to_fragment)?,
67+
None => stmt.query_map(params![limit as i64], row_to_fragment)?,
68+
};
69+
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
70+
})
71+
}
72+
73+
/// 按组列出碎片(组详情/结算消费;仅 active——归档项不进学习循环)。
74+
pub fn list_fragments_by_group(&self, group_id: i64) -> Result<Vec<Fragment>> {
75+
self.with_conn(|conn| {
76+
let mut stmt = conn.prepare(&format!(
77+
"SELECT {} FROM fragments
78+
WHERE group_id = ?1 AND status = 'active'
79+
ORDER BY created_at DESC, id DESC",
80+
FRAGMENT_COLUMNS
81+
))?;
82+
let rows = stmt.query_map(params![group_id], row_to_fragment)?;
83+
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
84+
})
85+
}
86+
87+
/// 碎片计数(组结算触发信号;按组+status 统计)。
88+
pub fn count_fragments(&self, group_id: Option<i64>, status: Option<&str>) -> Result<i64> {
89+
self.with_conn(|conn| {
90+
let mut sql = "SELECT COUNT(*) FROM fragments WHERE 1=1".to_string();
91+
if group_id.is_some() {
92+
sql.push_str(" AND group_id = ?1");
93+
}
94+
if status.is_some() {
95+
sql.push_str(if group_id.is_some() { " AND status = ?2" } else { " AND status = ?1" });
96+
}
97+
let mut stmt = conn.prepare(&sql)?;
98+
let count = match (group_id, status) {
99+
(Some(g), Some(s)) => stmt.query_row(params![g, s], |r| r.get(0))?,
100+
(Some(g), None) => stmt.query_row(params![g], |r| r.get(0))?,
101+
(None, Some(s)) => stmt.query_row(params![s], |r| r.get(0))?,
102+
(None, None) => stmt.query_row([], |r| r.get(0))?,
103+
};
104+
Ok(count)
105+
})
106+
}
107+
108+
/// 移动碎片到组(None=移出;用户纠错/结算归组共用)。
109+
pub fn update_fragment_group(&self, id: i64, group_id: Option<i64>) -> Result<bool> {
110+
self.with_conn(|conn| {
111+
let affected = conn.execute(
112+
"UPDATE fragments SET group_id = ?1 WHERE id = ?2",
113+
params![group_id, id],
114+
)?;
115+
Ok(affected > 0)
116+
})
117+
}
118+
119+
/// 标记碎片状态(v0.11.3 结算归档:active↔archived)。
120+
pub fn set_fragment_status(&self, id: i64, status: &str) -> Result<bool> {
121+
self.with_conn(|conn| {
122+
let affected = conn.execute(
123+
"UPDATE fragments SET status = ?1 WHERE id = ?2",
124+
params![status, id],
125+
)?;
126+
Ok(affected > 0)
127+
})
128+
}
129+
}
130+
131+
/// 把 rusqlite 行映射为 Fragment。
132+
fn row_to_fragment(row: &rusqlite::Row<'_>) -> rusqlite::Result<Fragment> {
133+
Ok(Fragment {
134+
id: row.get(0)?,
135+
text: row.get(1)?,
136+
image_path: row.get(2)?,
137+
domain_tag: row.get(3)?,
138+
group_id: row.get(4)?,
139+
source: row.get(5)?,
140+
status: row.get(6)?,
141+
created_at: row.get(7)?,
142+
})
143+
}
144+
145+
/// 单测独立文件。
146+
#[cfg(test)]
147+
#[path = "db_fragments_tests.rs"]
148+
mod tests;

0 commit comments

Comments
 (0)