新增: 飞书文件消息入站与补充注入链完善

- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写
- 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡)
- 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡
- df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
This commit is contained in:
lxy
2026-10-15 14:27:22 +08:00
parent 7548896f6d
commit 795935bf6a
10 changed files with 395 additions and 79 deletions
+47 -7
View File
@@ -98,6 +98,20 @@ impl MediaStore {
media_type: &str,
original_name: Option<&str>,
) -> Result<String, String> {
self.save_with_path(bytes, media_type, original_name)
.await
.map(|(url, _)| url)
}
/// 落盘并返回 (引用 url, 本机绝对路径)。文件消息入站用:模型 read_file 只认本机
/// 路径,OSS `https://` 引用对本机读取无意义,须另取落盘路径;图片入站只要引用,
/// 走 save 薄包装。落盘/双写/降级语义与 save 完全一致
pub async fn save_with_path(
&self,
bytes: &[u8],
media_type: &str,
original_name: Option<&str>,
) -> Result<(String, std::path::PathBuf), String> {
let key = content_key(bytes, media_type, original_name);
let dir = self.data_dir.join("media");
tokio::fs::create_dir_all(&dir)
@@ -112,26 +126,33 @@ impl MediaStore {
return Err(format!("媒体写入失败: {e}"));
}
}
match &self.oss {
let url = match &self.oss {
Some(cfg) => match upload_to_oss(cfg, &key, bytes, media_type).await {
Ok(()) => Ok(public_url(cfg, &key)),
Ok(()) => public_url(cfg, &key),
Err(e) => {
tracing::warn!(key = %key, err = %e, "OSS 上传失败,引用降级本机 file://");
Ok(local_ref(&path))
local_ref(&path)
}
},
None => Ok(local_ref(&path)),
}
None => local_ref(&path),
};
Ok((url, path))
}
}
/// 内容 sha256 全量 hex。落盘键与展示名回退共用同一 hash 口径(飞书文件入站无原始名
/// 时展示名取 `file_<hash 前 8>`,与落盘 hash 命名同源可对账)
pub fn content_hash(bytes: &[u8]) -> String {
use sha2::Digest as _;
hex::encode(sha2::Sha256::digest(bytes))
}
/// 内容寻址文件名:`<sha256前16hex>[_<清洗原始名>]<ext>`(对齐 u-claw safeAttachName
/// 模式)——hash 前缀保去重(同内容必同文件)且排序聚簇,原始名缀后保目录可读。
/// 平铺不分会话目录:会话归属经 DB 消息引用反查,文件系统不编码会话(否则同图跨会话
/// 重复存储,去重失效);个人桌面应用媒体量级百~千,hash 前缀平铺可管理,分桶待量级上来再说
fn content_key(bytes: &[u8], media_type: &str, original_name: Option<&str>) -> String {
use sha2::Digest as _;
let digest = hex::encode(&sha2::Sha256::digest(bytes)[..8]);
let digest = content_hash(bytes)[..16].to_string();
// 扩展名:原始名自带 ext 优先(保真),否则按 media_type 规范化
let name = original_name.map(sanitize_name).filter(|s| !s.is_empty());
let ext = name
@@ -280,6 +301,25 @@ mod tests {
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[tokio::test]
async fn save_with_path_returns_readable_local_path() {
// 文件消息入站契约:引用 url 之外另给本机路径(模型 read_file 只认本机路径),
// 通用文件落盘字节无损可读
let dir = std::env::temp_dir().join(format!("df-media-test-{}", df_types::types::new_id()));
let store = MediaStore::new(dir.clone(), None);
let (url, path) = store
.save_with_path(b"doc-bytes", "application/octet-stream", Some("合同.pdf"))
.await
.unwrap();
assert!(url.starts_with("file://"));
assert_eq!(
path.file_name().unwrap().to_string_lossy(),
format!("{}_合同.pdf", &content_hash(b"doc-bytes")[..16])
);
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"doc-bytes");
tokio::fs::remove_dir_all(&dir).await.ok();
}
#[test]
fn content_key_stable_and_ext_aware() {
let k1 = content_key(b"abc", "image/png", None);