新增: 飞书文件消息入站与补充注入链完善
- 文件消息入站:20MB 上限落盘引用,模型经 read_file 按需读盘;下载带 3 次退避+120s 超时防线,复用媒体域 hash 去重/OSS 双写 - 群聊门控对齐 u-claw 模式:前移至类型分流前,@自己/@所有人响应,未艾特静默旁观(原门控后置致未支持类型误回提示卡) - 修复补充注入丢失:pending_injects 未消化计数+收敛出口续轮+桌面注入 IPC+前端本地气泡 - df-ai-core 测试构造器补 source 字段(溯源字段遗漏,全量门禁暴露)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user