新增: OSS 资产库与桌面本地文件读取,交互与云同步增强

This commit is contained in:
lxy
2026-08-27 22:22:04 +08:00
parent e839c62bba
commit daaccb8ad7
21 changed files with 1388 additions and 38 deletions
+122
View File
@@ -7,6 +7,7 @@
* - AI 流式通过 emit("ai-delta", token) 实时推送到前端
* - 前端检测 __TAURI__ 环境,Web 模式回退到 TS store
* ===================================================================== */
use base64::Engine;
use serde::{Deserialize, Serialize};
use tauri::State;
use tauri::Emitter;
@@ -247,3 +248,124 @@ pub fn write_file(path: String, content: String) -> Result<(), String> {
pub fn read_file(path: String) -> Result<String, String> {
std::fs::read_to_string(&path).map_err(|e| format!("读取失败: {}", e))
}
/* ---------- 本地文件读取(桌面拖拽/系统对话框选择后的路径 → 字节) ---------- */
#[derive(Serialize)]
pub struct LocalFileData {
pub name: String, // 文件名(不含目录)
pub mime: String, // 按扩展名推断,未知为空串
pub base64: String, // 文件字节的标准 base64
}
/// 按扩展名推断 MIME 类型(未知返回空串)
fn mime_by_ext(path: &std::path::Path) -> String {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"mp4" => "video/mp4",
"webm" => "video/webm",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"pdf" => "application/pdf",
"md" => "text/markdown",
"txt" => "text/plain",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc" => "application/msword",
"json" => "application/json",
_ => "",
}
.to_string()
}
/// 目录/文件名是否应跳过(隐藏项 + 常见垃圾目录)
fn is_junk(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy();
name.starts_with('.')
|| matches!(
name.as_ref(),
"node_modules" | "target" | "__MACOSX"
)
}
/// 递归展开目录:最大深度 3,跳过隐藏/垃圾目录,文件总数上限 200
fn collect_files(
path: &std::path::Path,
depth: u32,
out: &mut Vec<std::path::PathBuf>,
) -> Result<(), String> {
const MAX_FILES: usize = 200;
if out.len() >= MAX_FILES {
return Err("文件过多(>200),请分批选择".into());
}
if path.is_dir() {
if depth >= 3 {
return Ok(()); // 超过最大深度,不再下钻
}
let entries = std::fs::read_dir(path).map_err(|e| format!("读取目录失败: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("读取目录失败: {}", e))?;
let name = entry.file_name();
if is_junk(&name) {
continue;
}
collect_files(&entry.path(), depth + 1, out)?;
}
} else if path.is_file() {
out.push(path.to_path_buf());
}
Ok(())
}
/// 批量读取本地文件。路径来自系统文件对话框或拖拽事件,由用户主动选择,
/// 读取后内容以 base64 回传前端构造 File 对象。
/// 目录会递归展开(深度 3,跳过隐藏与垃圾目录);单个文件读取失败直接跳过,全部失败才报错。
#[tauri::command]
pub async fn read_files(paths: Vec<String>) -> Result<Vec<LocalFileData>, String> {
// 目录展开是纯磁盘 IO,放阻塞线程池避免卡 async 运行时
let expanded = tokio::task::spawn_blocking(move || -> Result<Vec<std::path::PathBuf>, String> {
let mut files = Vec::new();
for p in &paths {
collect_files(std::path::Path::new(p), 0, &mut files)?;
}
Ok(files)
})
.await
.map_err(|e| format!("任务执行失败: {}", e))??;
let mut out = Vec::new();
for path in expanded {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let mime = mime_by_ext(&path);
// 逐个读文件是阻塞 IO,同样放阻塞线程池
let read = tokio::task::spawn_blocking(move || std::fs::read(&path))
.await
.map_err(|e| format!("任务执行失败: {}", e))?;
if let Ok(bytes) = read {
out.push(LocalFileData {
name,
mime,
base64: base64::engine::general_purpose::STANDARD.encode(&bytes),
});
}
}
if out.is_empty() {
return Err("没有可读取的文件".into());
}
Ok(out)
}
+4
View File
@@ -6,11 +6,13 @@ mod color;
mod commands;
mod model;
mod op;
mod oss;
pub fn run() {
let app_state = commands::AppState::new();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(app_state)
.invoke_handler(tauri::generate_handler![
commands::ping,
@@ -27,6 +29,8 @@ pub fn run() {
commands::ai_proxy_stream,
commands::write_file,
commands::read_file,
commands::read_files,
oss::oss_upload,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+287
View File
@@ -0,0 +1,287 @@
/* =====================================================================
* oss.rs — 对象存储上传(阿里云 OSS / 七牛云 Kodo
* 在 Rust 端发起 HTTP,规避浏览器 CORS 与密钥暴露:
* - 阿里云:PUT Object + Authorization 头 V1 签名(HMAC-SHA1
* - 七牛云:表单直传 multipart/form-data + 上传凭证 UpToken
* 前端通过 oss_upload 命令传入 provider/配置/对象 key/文件 base64,返回可访问 URL
* ===================================================================== */
use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE as B64_URLSAFE};
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// 七牛区域探测结果缓存(<ak>:<bucket> -> 上传地址)。
/// 多文件批量上传时避免每个文件都多一次探测 HTTP 往返。
static QINIU_REGION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
fn qiniu_region_cache() -> &'static Mutex<HashMap<String, String>> {
QINIU_REGION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
type HmacSha1 = Hmac<Sha1>;
/// 上传参数(provider 决定使用哪组字段)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OssUploadArgs {
/// "aliyun" | "qiniu"
pub provider: String,
/// 对象 key(前端已拼好目录前缀,如 "images/2026/xxx.png"
pub key: String,
/// 文件字节的 base64(标准 base64,非 urlsafe
pub data_base64: String,
/// MIME 类型(如 image/png);缺省 application/octet-stream
pub content_type: Option<String>,
/* ---- 阿里云 OSS ---- */
pub access_key_id: Option<String>,
pub access_key_secret: Option<String>,
/// endpoint,如 oss-cn-hangzhou.aliyuncs.com(不含 bucket、不含协议)
pub endpoint: Option<String>,
pub bucket: Option<String>,
/* ---- 七牛云 Kodo ---- */
pub access_key: Option<String>,
pub secret_key: Option<String>,
/// 上传域名,如 https://upload.qiniup.com(缺省用该值)
pub up_host: Option<String>,
/// 绑定的公开访问域名,如 https://cdn.example.com(用于拼最终 URL
pub domain: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct OssUploadResult {
pub url: String,
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// RFC1123 GMT 日期(如 "Wed, 28 Dec 2022 10:27:41 GMT"
fn http_date() -> String {
httpdate::fmt_http_date(SystemTime::now())
}
fn hmac_sha1(key: &[u8], msg: &[u8]) -> Vec<u8> {
let mut mac = HmacSha1::new_from_slice(key).expect("HMAC 接受任意长度 key");
mac.update(msg);
mac.finalize().into_bytes().to_vec()
}
/// 命令入口:按 provider 分发
#[tauri::command]
pub async fn oss_upload(args: OssUploadArgs) -> Result<OssUploadResult, String> {
let bytes = B64
.decode(args.data_base64.as_bytes())
.map_err(|e| format!("文件数据解码失败: {}", e))?;
let content_type = args
.content_type
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string());
match args.provider.as_str() {
"aliyun" => upload_aliyun(&args, bytes, &content_type).await,
"qiniu" => upload_qiniu(&args, bytes, &content_type).await,
other => Err(format!("不支持的 OSS 服务商: {}", other)),
}
}
/// 阿里云 OSSPUT ObjectAuthorization 头 V1 签名
async fn upload_aliyun(
args: &OssUploadArgs,
bytes: Vec<u8>,
content_type: &str,
) -> Result<OssUploadResult, String> {
let ak = args.access_key_id.as_deref().unwrap_or("").trim();
let sk = args.access_key_secret.as_deref().unwrap_or("").trim();
let endpoint = args.endpoint.as_deref().unwrap_or("").trim();
let bucket = args.bucket.as_deref().unwrap_or("").trim();
if ak.is_empty() || sk.is_empty() || endpoint.is_empty() || bucket.is_empty() {
return Err("阿里云配置不完整(需 AccessKeyId/Secret/endpoint/bucket".into());
}
let key = args.key.trim_start_matches('/');
let date = http_date();
// 签名串:VERB\nContent-MD5\nContent-Type\nDate\nCanonicalizedResource
// Content-MD5 留空;无 x-oss- 头
let canonical_resource = format!("/{}/{}", bucket, key);
let string_to_sign = format!(
"PUT\n\n{}\n{}\n{}",
content_type, date, canonical_resource
);
let signature = B64.encode(hmac_sha1(sk.as_bytes(), string_to_sign.as_bytes()));
let authorization = format!("OSS {}:{}", ak, signature);
// endpoint 可能带协议,规整为纯 host
let host = endpoint
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches('/');
let url = format!("https://{}.{}/{}", bucket, host, key);
let client = reqwest::Client::new();
let resp = client
.put(&url)
.header("Date", &date)
.header("Content-Type", content_type)
.header("Authorization", &authorization)
.body(bytes)
.send()
.await
.map_err(|e| format!("上传请求失败: {}", e))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("阿里云返回 {}{}", status.as_u16(), body));
}
Ok(OssUploadResult { url })
}
/// 七牛云 Kodo:表单直传 multipart/form-data + UpToken
async fn upload_qiniu(
args: &OssUploadArgs,
bytes: Vec<u8>,
content_type: &str,
) -> Result<OssUploadResult, String> {
let ak = args.access_key.as_deref().unwrap_or("").trim();
let sk = args.secret_key.as_deref().unwrap_or("").trim();
let bucket = args.bucket.as_deref().unwrap_or("").trim();
let domain = args.domain.as_deref().unwrap_or("").trim();
if ak.is_empty() || sk.is_empty() || bucket.is_empty() || domain.is_empty() {
return Err("七牛云配置不完整(需 AccessKey/SecretKey/bucket/domain".into());
}
let up_host = match args
.up_host
.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
Some(h) => h.to_string(),
// 未填写上传域名时自动探测区域:硬编码华东 upload.qiniup.com 会在
// 华南(z2)等非华东 bucket 上报 incorrect region,故走官方查询接口
None => qiniu_detect_up_host(ak, bucket).await?,
};
let key = args.key.trim_start_matches('/');
let token = qiniu_upload_token(ak, sk, bucket, key);
let file_part = reqwest::multipart::Part::bytes(bytes)
.file_name(key.to_string())
.mime_str(content_type)
.map_err(|e| format!("构造上传表单失败: {}", e))?;
let form = reqwest::multipart::Form::new()
.text("token", token)
.text("key", key.to_string())
.part("file", file_part);
let client = reqwest::Client::new();
let resp = client
.post(up_host)
.multipart(form)
.send()
.await
.map_err(|e| format!("上传请求失败: {}", e))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("七牛云返回 {}{}", status.as_u16(), body));
}
// 拼公开访问 URLdomain/key
let base = domain.trim_end_matches('/');
let url = if base.starts_with("http://") || base.starts_with("https://") {
format!("{}/{}", base, key)
} else {
format!("https://{}/{}", base, key)
};
Ok(OssUploadResult { url })
}
/// 七牛区域探测:按 ak/bucket 查询官方 uc 接口,返回上传地址(https://<域名>
async fn qiniu_detect_up_host(ak: &str, bucket: &str) -> Result<String, String> {
let cache_key = format!("{}:{}", ak, bucket);
// 先查缓存,命中直接返回
if let Some(host) = qiniu_region_cache()
.lock()
.ok()
.and_then(|map| map.get(&cache_key).cloned())
{
return Ok(host);
}
// 公开接口无需签名;锁内只做 map 读写不 await
let url = format!(
"https://uc.qiniuapi.com/v4/query?ak={}&bucket={}",
ak, bucket
);
let resp = reqwest::Client::new()
.get(&url)
.send()
.await
.map_err(|e| format!("无法自动探测七牛上传区域(可手动填写上传域名):{}", e))?;
if !resp.status().is_success() {
return Err(format!(
"无法自动探测七牛上传区域(可手动填写上传域名):接口返回 {}",
resp.status().as_u16()
));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("无法自动探测七牛上传区域(可手动填写上传域名):{}", e))?;
// 取 hosts[0].up.domains 中第一个非空域名,健壮处理字段缺失/为空的情况
let domain = json["hosts"]
.as_array()
.and_then(|hosts| hosts.first())
.and_then(|h| h["up"]["domains"].as_array())
.and_then(|domains| {
domains
.iter()
.filter_map(|d| d.as_str())
.find(|d| !d.trim().is_empty())
})
.map(|d| d.trim().to_string());
let domain = match domain {
Some(d) => d,
None => {
return Err(
"无法自动探测七牛上传区域(可手动填写上传域名):接口未返回可用域名".into(),
)
}
};
let host = format!("https://{}", domain);
if let Ok(mut map) = qiniu_region_cache().lock() {
map.insert(cache_key, host.clone());
}
Ok(host)
}
/// 七牛上传凭证:AccessKey:urlsafe_b64(hmac_sha1(sk, encodedPolicy)):encodedPolicy
fn qiniu_upload_token(ak: &str, sk: &str, bucket: &str, key: &str) -> String {
let deadline = now_unix() + 3600; // 1 小时有效
// scope 指定为 bucket:key,前端 key 必须与之一致
let put_policy = format!(
"{{\"scope\":\"{}:{}\",\"deadline\":{}}}",
bucket, key, deadline
);
let encoded_policy = B64_URLSAFE.encode(put_policy.as_bytes());
let sign = hmac_sha1(sk.as_bytes(), encoded_policy.as_bytes());
let encoded_sign = B64_URLSAFE.encode(sign);
format!("{}:{}:{}", ak, encoded_sign, encoded_policy)
}