提交qiming-mcp-proxy
This commit is contained in:
85
qiming-mcp-proxy/oss-client/src/config.rs
Normal file
85
qiming-mcp-proxy/oss-client/src/config.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! OSS配置管理模块
|
||||
|
||||
use crate::error::{OssError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 默认配置常量
|
||||
pub mod defaults {
|
||||
pub const ENDPOINT: &str = "oss-rg-china-mainland.aliyuncs.com";
|
||||
pub const PUBLIC_BUCKET: &str = "nuwa-packages";
|
||||
pub const PRIVATE_BUCKET: &str = "edu-nuwa-packages";
|
||||
pub const REGION: &str = "oss-rg-china-mainland";
|
||||
pub const UPLOAD_DIRECTORY: &str = "edu";
|
||||
}
|
||||
|
||||
/// OSS配置结构体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OssConfig {
|
||||
/// OSS endpoint (默认: oss-rg-china-mainland.aliyuncs.com)
|
||||
pub endpoint: String,
|
||||
/// 存储桶名称 (默认: nuwa-packages)
|
||||
pub bucket: String,
|
||||
/// 访问密钥ID (必须通过环境变量设置)
|
||||
pub access_key_id: String,
|
||||
/// 访问密钥Secret (必须通过环境变量设置)
|
||||
pub access_key_secret: String,
|
||||
/// 区域 (默认: oss-rg-china-mainland)
|
||||
pub region: String,
|
||||
/// 上传目录前缀 (默认: edu)
|
||||
pub upload_directory: String,
|
||||
}
|
||||
|
||||
impl OssConfig {
|
||||
/// 创建自定义配置(所有字段需显式提供)
|
||||
pub fn new(
|
||||
endpoint: String,
|
||||
bucket: String,
|
||||
access_key_id: String,
|
||||
access_key_secret: String,
|
||||
region: String,
|
||||
upload_directory: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
endpoint,
|
||||
bucket,
|
||||
access_key_id,
|
||||
access_key_secret,
|
||||
region,
|
||||
upload_directory,
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证配置有效性
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.access_key_id.is_empty() {
|
||||
return Err(OssError::Config("access_key_id 不能为空".to_string()));
|
||||
}
|
||||
if self.access_key_secret.is_empty() {
|
||||
return Err(OssError::Config("access_key_secret 不能为空".to_string()));
|
||||
}
|
||||
if self.endpoint.is_empty() {
|
||||
return Err(OssError::Config("endpoint 不能为空".to_string()));
|
||||
}
|
||||
if self.bucket.is_empty() {
|
||||
return Err(OssError::Config("bucket 不能为空".to_string()));
|
||||
}
|
||||
if self.region.is_empty() {
|
||||
return Err(OssError::Config("region 不能为空".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取完整的OSS URL
|
||||
pub fn get_base_url(&self) -> String {
|
||||
format!("https://{}.{}", self.bucket, self.endpoint)
|
||||
}
|
||||
|
||||
/// 获取带前缀的object key
|
||||
pub fn get_prefixed_key(&self, key: &str) -> String {
|
||||
if key.starts_with(&self.upload_directory) {
|
||||
key.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.upload_directory, key.trim_start_matches('/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
187
qiming-mcp-proxy/oss-client/src/error.rs
Normal file
187
qiming-mcp-proxy/oss-client/src/error.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! 错误处理模块
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// OSS操作错误类型
|
||||
#[derive(Error, Debug)]
|
||||
pub enum OssError {
|
||||
#[error("{0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Network(String),
|
||||
|
||||
#[error("{0}")]
|
||||
FileNotFound(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Permission(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Io(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Sdk(String),
|
||||
|
||||
#[error("{0}")]
|
||||
FileSizeExceeded(String),
|
||||
|
||||
#[error("{0}")]
|
||||
UnsupportedFileType(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidParameter(String),
|
||||
}
|
||||
|
||||
impl OssError {
|
||||
/// 创建配置错误
|
||||
pub fn config<T: Into<String>>(msg: T) -> Self {
|
||||
Self::Config(t!("errors.oss.config", detail = msg.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建网络错误
|
||||
pub fn network<T: Into<String>>(msg: T) -> Self {
|
||||
Self::Network(t!("errors.oss.network", detail = msg.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建文件不存在错误
|
||||
pub fn file_not_found<T: Into<String>>(msg: T) -> Self {
|
||||
Self::FileNotFound(t!("errors.oss.file_not_found", path = msg.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建权限错误
|
||||
pub fn permission<T: Into<String>>(msg: T) -> Self {
|
||||
Self::Permission(t!("errors.oss.permission", detail = msg.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建SDK错误
|
||||
pub fn sdk<T: Into<String>>(msg: T) -> Self {
|
||||
Self::Sdk(t!("errors.oss.sdk", detail = msg.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建文件大小超出限制错误
|
||||
pub fn file_size_exceeded<T: Into<String>>(msg: T) -> Self {
|
||||
Self::FileSizeExceeded(t!("errors.oss.file_size_exceeded", detail = msg.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建不支持的文件类型错误
|
||||
pub fn unsupported_file_type<T: Into<String>>(msg: T) -> Self {
|
||||
Self::UnsupportedFileType(
|
||||
t!("errors.oss.unsupported_file_type", detail = msg.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建超时错误
|
||||
pub fn timeout<T: Into<String>>(msg: T) -> Self {
|
||||
Self::Timeout(t!("errors.oss.timeout", detail = msg.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建无效参数错误
|
||||
pub fn invalid_parameter<T: Into<String>>(msg: T) -> Self {
|
||||
Self::InvalidParameter(t!("errors.oss.invalid_parameter", detail = msg.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建IO错误
|
||||
pub fn io_error<T: Into<String>>(msg: T) -> Self {
|
||||
Self::Io(t!("errors.oss.io", detail = msg.into()).to_string())
|
||||
}
|
||||
|
||||
/// 判断是否为配置错误
|
||||
pub fn is_config_error(&self) -> bool {
|
||||
matches!(self, Self::Config(_))
|
||||
}
|
||||
|
||||
/// 判断是否为网络错误
|
||||
pub fn is_network_error(&self) -> bool {
|
||||
matches!(self, Self::Network(_))
|
||||
}
|
||||
|
||||
/// 判断是否为文件不存在错误
|
||||
pub fn is_file_not_found(&self) -> bool {
|
||||
matches!(self, Self::FileNotFound(_))
|
||||
}
|
||||
|
||||
/// 判断是否为权限错误
|
||||
pub fn is_permission_error(&self) -> bool {
|
||||
matches!(self, Self::Permission(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// 从标准库错误转换
|
||||
impl From<std::io::Error> for OssError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::io_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result类型别名
|
||||
pub type Result<T> = std::result::Result<T, OssError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io;
|
||||
|
||||
#[test]
|
||||
fn test_error_creation() {
|
||||
let config_err = OssError::config("test config error");
|
||||
assert!(config_err.is_config_error());
|
||||
assert_eq!(config_err.to_string(), "配置错误: test config error");
|
||||
|
||||
let network_err = OssError::network("test network error");
|
||||
assert!(network_err.is_network_error());
|
||||
assert_eq!(network_err.to_string(), "网络错误: test network error");
|
||||
|
||||
let file_err = OssError::file_not_found("test.txt");
|
||||
assert!(file_err.is_file_not_found());
|
||||
assert_eq!(file_err.to_string(), "文件不存在: test.txt");
|
||||
|
||||
let permission_err = OssError::permission("access denied");
|
||||
assert!(permission_err.is_permission_error());
|
||||
assert_eq!(permission_err.to_string(), "权限不足: access denied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_error_conversion() {
|
||||
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
|
||||
let oss_err: OssError = io_err.into();
|
||||
|
||||
match oss_err {
|
||||
OssError::Io(_) => {} // 正确
|
||||
_ => panic!("IO错误转换失败"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_display() {
|
||||
let err = OssError::FileSizeExceeded("文件大小超过100MB".to_string());
|
||||
assert_eq!(err.to_string(), "文件大小超出限制: 文件大小超过100MB");
|
||||
|
||||
let err = OssError::UnsupportedFileType("不支持.xyz格式".to_string());
|
||||
assert_eq!(err.to_string(), "不支持的文件类型: 不支持.xyz格式");
|
||||
|
||||
let err = OssError::Timeout("操作超时30秒".to_string());
|
||||
assert_eq!(err.to_string(), "操作超时: 操作超时30秒");
|
||||
|
||||
let err = OssError::InvalidParameter("object_key不能为空".to_string());
|
||||
assert_eq!(err.to_string(), "无效的参数: object_key不能为空");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_type_checking() {
|
||||
let config_err = OssError::Config("test".to_string());
|
||||
assert!(config_err.is_config_error());
|
||||
assert!(!config_err.is_network_error());
|
||||
assert!(!config_err.is_file_not_found());
|
||||
assert!(!config_err.is_permission_error());
|
||||
|
||||
let network_err = OssError::Network("test".to_string());
|
||||
assert!(!network_err.is_config_error());
|
||||
assert!(network_err.is_network_error());
|
||||
assert!(!network_err.is_file_not_found());
|
||||
assert!(!network_err.is_permission_error());
|
||||
}
|
||||
}
|
||||
175
qiming-mcp-proxy/oss-client/src/lib.rs
Normal file
175
qiming-mcp-proxy/oss-client/src/lib.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! 简洁的阿里云OSS操作库
|
||||
//!
|
||||
//! 提供基本的文件上传、下载、删除功能,以及预签名URL生成。
|
||||
//!
|
||||
//! # 快速开始
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use oss_client::{PrivateOssClient, OssConfig, OssClientTrait};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> oss_client::Result<()> {
|
||||
//! // 创建配置
|
||||
//! let config = OssConfig::new(
|
||||
//! "oss-rg-china-mainland.aliyuncs.com".to_string(),
|
||||
//! "bucket_name".to_string(),
|
||||
//! "access_key_id".to_string(),
|
||||
//! "access_key_secret".to_string(),
|
||||
//! "oss-rg-china-mainland".to_string(),
|
||||
//! "upload_directory".to_string(),
|
||||
//! );
|
||||
//!
|
||||
//! // 创建客户端
|
||||
//! let client = PrivateOssClient::new(config)?;
|
||||
//!
|
||||
//! // 上传文件
|
||||
//! let url = client.upload_file("local/file.txt", "remote/file.txt").await?;
|
||||
//! println!("File uploaded successfully: {}", url);
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
// 初始化 i18n,使用 crate 内置翻译文件
|
||||
#[macro_use]
|
||||
extern crate rust_i18n;
|
||||
|
||||
// 初始化翻译文件,使用 crate 内置 locales(支持独立发布)
|
||||
i18n!("locales", fallback = "en");
|
||||
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod private_client;
|
||||
pub mod public_client;
|
||||
pub mod utils;
|
||||
|
||||
// 重新导出主要类型
|
||||
pub use config::{OssConfig, defaults};
|
||||
pub use error::{OssError, Result};
|
||||
pub use private_client::PrivateOssClient;
|
||||
pub use public_client::PublicOssClient;
|
||||
|
||||
// 重新导出常用工具函数
|
||||
pub use utils::{
|
||||
detect_mime_type, detect_mime_type_by_extension, format_file_size, generate_random_filename,
|
||||
get_file_extension, get_filename, get_filename_without_extension, is_audio_file,
|
||||
is_document_file, is_image_file, is_video_file, parse_file_size, replace_oss_domain,
|
||||
replace_oss_domains_batch, sanitize_filename,
|
||||
};
|
||||
|
||||
/// OSS客户端公共接口trait
|
||||
///
|
||||
/// 定义了OSS客户端的基本操作接口,包括文件操作、签名URL生成等
|
||||
/// 私有bucket和公有bucket客户端都实现这个trait
|
||||
#[async_trait::async_trait]
|
||||
pub trait OssClientTrait: Send + Sync {
|
||||
/// 获取配置信息
|
||||
fn get_config(&self) -> &OssConfig;
|
||||
|
||||
/// 获取基础URL
|
||||
fn get_base_url(&self) -> String;
|
||||
|
||||
/// 生成上传签名URL
|
||||
///
|
||||
/// # 参数
|
||||
/// * `object_key` - 对象键
|
||||
/// * `expires_in` - 过期时间
|
||||
/// * `content_type` - 内容类型(可选)
|
||||
///
|
||||
/// # 返回
|
||||
/// * 带签名的上传URL
|
||||
fn generate_upload_url(
|
||||
&self,
|
||||
object_key: &str,
|
||||
expires_in: std::time::Duration,
|
||||
content_type: Option<&str>,
|
||||
) -> Result<String>;
|
||||
|
||||
/// 生成下载签名URL
|
||||
///
|
||||
/// # 参数
|
||||
/// * `object_key` - 对象键
|
||||
/// * `expires_in` - 过期时间(可选)
|
||||
///
|
||||
/// # 返回
|
||||
/// * 带签名的下载URL
|
||||
fn generate_download_url(
|
||||
&self,
|
||||
object_key: &str,
|
||||
expires_in: Option<std::time::Duration>,
|
||||
) -> Result<String>;
|
||||
|
||||
/// 上传文件
|
||||
///
|
||||
/// # 参数
|
||||
/// * `local_path` - 本地文件路径
|
||||
/// * `object_key` - 对象键
|
||||
///
|
||||
/// # 返回
|
||||
/// * 上传后的文件URL
|
||||
async fn upload_file(&self, local_path: &str, object_key: &str) -> Result<String>;
|
||||
|
||||
/// 上传内容
|
||||
///
|
||||
/// # 参数
|
||||
/// * `content` - 要上传的内容
|
||||
/// * `object_key` - 对象键
|
||||
/// * `content_type` - 内容类型(可选)
|
||||
///
|
||||
/// # 返回
|
||||
/// * 上传后的文件URL
|
||||
async fn upload_content(
|
||||
&self,
|
||||
content: &[u8],
|
||||
object_key: &str,
|
||||
content_type: Option<&str>,
|
||||
) -> Result<String>;
|
||||
|
||||
/// 删除文件
|
||||
///
|
||||
/// # 参数
|
||||
/// * `object_key` - 对象键
|
||||
///
|
||||
/// # 返回
|
||||
/// * 删除操作结果
|
||||
async fn delete_file(&self, object_key: &str) -> Result<()>;
|
||||
|
||||
/// 检查文件是否存在
|
||||
///
|
||||
/// # 参数
|
||||
/// * `object_key` - 对象键
|
||||
///
|
||||
/// # 返回
|
||||
/// * 文件是否存在
|
||||
async fn file_exists(&self, object_key: &str) -> Result<bool>;
|
||||
|
||||
/// 测试连接
|
||||
///
|
||||
/// # 返回
|
||||
/// * 连接测试结果
|
||||
async fn test_connection(&self) -> Result<()>;
|
||||
|
||||
/// 生成唯一的object key
|
||||
///
|
||||
/// # 参数
|
||||
/// * `prefix` - 前缀
|
||||
/// * `filename` - 原始文件名(可选)
|
||||
///
|
||||
/// # 返回
|
||||
/// * 唯一的对象键
|
||||
fn generate_object_key(&self, prefix: &str, filename: Option<&str>) -> String;
|
||||
}
|
||||
|
||||
/// 获取库版本信息
|
||||
pub fn version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// 获取库名称
|
||||
pub fn name() -> &'static str {
|
||||
env!("CARGO_PKG_NAME")
|
||||
}
|
||||
|
||||
/// 获取库描述
|
||||
pub fn description() -> &'static str {
|
||||
env!("CARGO_PKG_DESCRIPTION")
|
||||
}
|
||||
219
qiming-mcp-proxy/oss-client/src/private_client.rs
Normal file
219
qiming-mcp-proxy/oss-client/src/private_client.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! 私有Bucket客户端实现
|
||||
|
||||
use aliyun_oss_rust_sdk::oss::OSS;
|
||||
use aliyun_oss_rust_sdk::request::RequestBuilder;
|
||||
use aliyun_oss_rust_sdk::url::UrlApi;
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tokio::fs;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::OssClientTrait;
|
||||
use crate::config::OssConfig;
|
||||
use crate::error::{OssError, Result};
|
||||
use crate::utils::{self, detect_mime_type, sanitize_filename};
|
||||
|
||||
/// 私有OSS客户端(签名访问)
|
||||
#[derive(Debug)]
|
||||
pub struct PrivateOssClient {
|
||||
client: OSS,
|
||||
config: OssConfig,
|
||||
}
|
||||
|
||||
impl PrivateOssClient {
|
||||
/// 创建新的私有OSS客户端
|
||||
pub fn new(config: OssConfig) -> Result<Self> {
|
||||
config.validate()?;
|
||||
|
||||
let client = OSS::new(
|
||||
&config.access_key_id,
|
||||
&config.access_key_secret,
|
||||
&config.endpoint,
|
||||
&config.bucket,
|
||||
);
|
||||
|
||||
Ok(Self { client, config })
|
||||
}
|
||||
|
||||
/// 获取配置信息
|
||||
pub fn get_config(&self) -> &OssConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 获取基础URL
|
||||
pub fn get_base_url(&self) -> String {
|
||||
self.config.get_base_url()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl OssClientTrait for PrivateOssClient {
|
||||
fn get_config(&self) -> &OssConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
fn get_base_url(&self) -> String {
|
||||
self.config.get_base_url()
|
||||
}
|
||||
|
||||
fn generate_upload_url(
|
||||
&self,
|
||||
object_key: &str,
|
||||
expires_in: std::time::Duration,
|
||||
content_type: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
|
||||
let mut builder = RequestBuilder::new().with_expire(expires_in.as_secs() as i64);
|
||||
if let Some(ct) = content_type {
|
||||
builder = builder.with_content_type(ct);
|
||||
} else {
|
||||
builder = builder.with_content_type("application/octet-stream");
|
||||
}
|
||||
|
||||
let url = self.client.sign_upload_url(&prefixed_key, &builder);
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn generate_download_url(
|
||||
&self,
|
||||
object_key: &str,
|
||||
expires_in: Option<std::time::Duration>,
|
||||
) -> Result<String> {
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
let duration = expires_in.unwrap_or_else(|| Duration::from_secs(7 * 24 * 3600));
|
||||
let builder = RequestBuilder::new().with_expire(duration.as_secs() as i64);
|
||||
let url = self.client.sign_download_url(&prefixed_key, &builder);
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn upload_file(&self, local_path: &str, object_key: &str) -> Result<String> {
|
||||
if !Path::new(local_path).exists() {
|
||||
return Err(OssError::file_not_found(format!(
|
||||
"本地文件不存在: {local_path}"
|
||||
)));
|
||||
}
|
||||
|
||||
let content_type = detect_mime_type(local_path);
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
let builder = RequestBuilder::new().with_content_type(&content_type);
|
||||
|
||||
let local_path_string = local_path.to_string();
|
||||
match self
|
||||
.client
|
||||
.put_object_from_file(&prefixed_key, &local_path_string, builder)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let url = format!("{}/{}", self.get_base_url(), prefixed_key);
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
Ok(url)
|
||||
}
|
||||
Err(e) => Err(OssError::sdk(format!("上传文件失败: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_content(
|
||||
&self,
|
||||
content: &[u8],
|
||||
object_key: &str,
|
||||
content_type: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
let temp_file =
|
||||
tempfile::NamedTempFile::new().map_err(|e| OssError::io_error(e.to_string()))?;
|
||||
fs::write(temp_file.path(), content)
|
||||
.await
|
||||
.map_err(|e| OssError::io_error(e.to_string()))?;
|
||||
|
||||
let mut builder = RequestBuilder::new();
|
||||
if let Some(ct) = content_type {
|
||||
builder = builder.with_content_type(ct);
|
||||
} else {
|
||||
builder = builder.with_content_type("application/octet-stream");
|
||||
}
|
||||
|
||||
let temp_path_string = temp_file.path().to_str().unwrap().to_string();
|
||||
match self
|
||||
.client
|
||||
.put_object_from_file(&prefixed_key, &temp_path_string, builder)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let url = format!("{}/{}", self.get_base_url(), prefixed_key);
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
Ok(url)
|
||||
}
|
||||
Err(e) => Err(OssError::sdk(format!("上传内容失败: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_file(&self, object_key: &str) -> Result<()> {
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
let builder = RequestBuilder::new();
|
||||
match self.client.delete_object(&prefixed_key, builder).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(OssError::sdk(format!("删除文件失败: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn file_exists(&self, object_key: &str) -> Result<bool> {
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
let builder = RequestBuilder::new();
|
||||
// 使用 HEAD 请求检查对象是否存在,避免下载主体
|
||||
match self
|
||||
.client
|
||||
.get_object_metadata(&prefixed_key, builder)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
warn!("File existence check failed: {}", e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_connection(&self) -> Result<()> {
|
||||
let test_key = format!("health-check-{}", chrono::Utc::now().timestamp_millis());
|
||||
let test_content = b"OSS connection test";
|
||||
|
||||
match <PrivateOssClient as OssClientTrait>::upload_content(
|
||||
self,
|
||||
test_content,
|
||||
&test_key,
|
||||
Some("text/plain"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let _ = <PrivateOssClient as OssClientTrait>::delete_file(self, &test_key).await;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(OssError::network(format!("无法连接到OSS: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_object_key(&self, prefix: &str, filename: Option<&str>) -> String {
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let uid = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
|
||||
let filename = if let Some(original) = filename {
|
||||
let clean_name = sanitize_filename(original);
|
||||
if let Some(dot_pos) = clean_name.rfind('.') {
|
||||
let name_part = &clean_name[..dot_pos];
|
||||
let ext_part = &clean_name[dot_pos..];
|
||||
format!("{name_part}_{timestamp}_{uid}{ext_part}")
|
||||
} else {
|
||||
format!("{clean_name}_{timestamp}_{uid}.")
|
||||
}
|
||||
} else {
|
||||
format!("file_{timestamp}_{uid}")
|
||||
};
|
||||
|
||||
format!("{prefix}/{filename}")
|
||||
}
|
||||
}
|
||||
642
qiming-mcp-proxy/oss-client/src/public_client.rs
Normal file
642
qiming-mcp-proxy/oss-client/src/public_client.rs
Normal file
@@ -0,0 +1,642 @@
|
||||
//! 公有Bucket客户端实现
|
||||
//!
|
||||
//! 专门用于处理公有bucket的公开访问服务,无需签名验证
|
||||
|
||||
use crate::OssClientTrait;
|
||||
use crate::config::OssConfig;
|
||||
use crate::error::{OssError, Result};
|
||||
use crate::utils::{self, detect_mime_type, sanitize_filename};
|
||||
use aliyun_oss_rust_sdk::oss::OSS;
|
||||
use aliyun_oss_rust_sdk::request::RequestBuilder;
|
||||
use aliyun_oss_rust_sdk::url::UrlApi;
|
||||
use chrono::Utc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// 公有Bucket客户端
|
||||
///
|
||||
/// 专门用于处理公有bucket的公开访问服务
|
||||
/// 所有操作都使用公有bucket,无需签名验证
|
||||
#[derive(Debug)]
|
||||
pub struct PublicOssClient {
|
||||
config: OssConfig,
|
||||
}
|
||||
|
||||
impl PublicOssClient {
|
||||
/// 创建新的公有Bucket客户端
|
||||
pub fn new(config: OssConfig) -> Result<Self> {
|
||||
config.validate()?;
|
||||
Ok(Self { config })
|
||||
}
|
||||
|
||||
/// 获取配置信息
|
||||
pub fn get_config(&self) -> &OssConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 获取公有bucket的基础URL
|
||||
pub fn get_base_url(&self) -> String {
|
||||
self.config.get_base_url()
|
||||
}
|
||||
|
||||
/// 生成公有bucket的公开下载URL(无需签名,永久有效)
|
||||
///
|
||||
/// # 参数
|
||||
/// * `object_key` - 对象键,如 "documents/manual.pdf"
|
||||
///
|
||||
/// # 返回
|
||||
/// * 公开访问的下载URL,任何人都可以访问
|
||||
///
|
||||
/// # 示例
|
||||
/// ```rust,no_run
|
||||
/// use oss_client::{PublicOssClient, OssConfig};
|
||||
///
|
||||
/// let config = OssConfig::new(
|
||||
/// "oss-rg-china-mainland.aliyuncs.com".to_string(),
|
||||
/// "bucket".to_string(),
|
||||
/// "".to_string(),
|
||||
/// "".to_string(),
|
||||
/// "oss-rg-china-mainland".to_string(),
|
||||
/// "upload_directory".to_string(),
|
||||
/// );
|
||||
/// let client = PublicOssClient::new(config)?;
|
||||
/// let url = client.generate_public_download_url("documents/manual.pdf")?;
|
||||
/// println!("Public download URL: {}", url);
|
||||
/// # Ok::<(), oss_client::OssError>(())
|
||||
/// ```
|
||||
pub fn generate_public_download_url(&self, object_key: &str) -> Result<String> {
|
||||
// 获取带前缀的object key
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
|
||||
// 使用公有bucket生成公开URL
|
||||
let url = format!("{}/{}", self.get_base_url(), prefixed_key);
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
info!("Generate public bucket public download URL: {}", url);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// 生成公有bucket的公开访问URL(无需签名,永久有效)
|
||||
///
|
||||
/// # 参数
|
||||
/// * `object_key` - 对象键,如 "images/logo.png"
|
||||
///
|
||||
/// # 返回
|
||||
/// * 公开访问的URL,任何人都可以访问
|
||||
///
|
||||
/// # 示例
|
||||
/// ```rust,no_run
|
||||
/// use oss_client::{PublicOssClient, OssConfig};
|
||||
///
|
||||
/// let config = OssConfig::new(
|
||||
/// "oss-rg-china-mainland.aliyuncs.com".to_string(),
|
||||
/// "bucket".to_string(),
|
||||
/// "".to_string(),
|
||||
/// "".to_string(),
|
||||
/// "oss-rg-china-mainland".to_string(),
|
||||
/// "upload_directory".to_string(),
|
||||
/// );
|
||||
/// let client = PublicOssClient::new(config)?;
|
||||
/// let url = client.generate_public_access_url("images/logo.png")?;
|
||||
/// println!("Public access URL: {}", url);
|
||||
/// # Ok::<(), oss_client::OssError>(())
|
||||
/// ```
|
||||
pub fn generate_public_access_url(&self, object_key: &str) -> Result<String> {
|
||||
// 获取带前缀的object key
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
|
||||
// 使用公有bucket生成公开URL
|
||||
let url = format!("{}/{}", self.get_base_url(), prefixed_key);
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
info!("Generate public bucket public access URL: {}", url);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// 批量生成公有bucket的公开访问URL
|
||||
///
|
||||
/// # 参数
|
||||
/// * `object_keys` - 对象键列表
|
||||
///
|
||||
/// # 返回
|
||||
/// * 对象键到公开URL的映射
|
||||
///
|
||||
/// # 示例
|
||||
/// ```rust,no_run
|
||||
/// use oss_client::{PublicOssClient, OssConfig};
|
||||
///
|
||||
/// let config = OssConfig::new(
|
||||
/// "oss-rg-china-mainland.aliyuncs.com".to_string(),
|
||||
/// "bucket".to_string(),
|
||||
/// "".to_string(),
|
||||
/// "".to_string(),
|
||||
/// "oss-rg-china-mainland".to_string(),
|
||||
/// "upload_directory".to_string(),
|
||||
/// );
|
||||
/// let client = PublicOssClient::new(config)?;
|
||||
/// let keys = vec!["doc1.pdf", "doc2.pdf", "image.jpg"];
|
||||
/// let urls = client.generate_public_urls_batch(&keys)?;
|
||||
///
|
||||
/// for (key, url) in urls {
|
||||
/// println!("{}: {}", key, url);
|
||||
/// }
|
||||
/// # Ok::<(), oss_client::OssError>(())
|
||||
/// ```
|
||||
pub fn generate_public_urls_batch(
|
||||
&self,
|
||||
object_keys: &[&str],
|
||||
) -> Result<std::collections::HashMap<String, String>> {
|
||||
let mut url_map = std::collections::HashMap::new();
|
||||
|
||||
for &key in object_keys {
|
||||
let url = self.generate_public_access_url(key)?;
|
||||
url_map.insert(key.to_string(), url);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Generate public bucket public URLs in batches, totaling {}",
|
||||
object_keys.len()
|
||||
);
|
||||
Ok(url_map)
|
||||
}
|
||||
|
||||
/// 获取公有bucket的基础信息
|
||||
///
|
||||
/// # 返回
|
||||
/// * 包含bucket名称、endpoint等信息的字符串
|
||||
pub fn get_bucket_info(&self) -> String {
|
||||
format!(
|
||||
"Bucket: {} (Endpoint: {}, Region: {})",
|
||||
self.config.bucket, self.config.endpoint, self.config.region
|
||||
)
|
||||
}
|
||||
|
||||
// 以下通用接口建议通过 OssClientTrait 使用
|
||||
|
||||
/// 获取公有bucket中文件的元信息
|
||||
///
|
||||
/// # 参数
|
||||
/// * `object_key` - 对象键
|
||||
///
|
||||
/// # 返回
|
||||
/// * 文件元信息(如果存在)
|
||||
///
|
||||
/// # 注意
|
||||
/// 这个方法通过HTTP HEAD请求获取文件元信息
|
||||
/// 由于是公有bucket,任何人都可以执行此操作
|
||||
pub async fn get_file_metadata(
|
||||
&self,
|
||||
object_key: &str,
|
||||
) -> Result<Option<std::collections::HashMap<String, String>>> {
|
||||
// 获取带前缀的object key
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
|
||||
// 构建完整的URL
|
||||
let url = format!("{}/{}", self.get_base_url(), prefixed_key);
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
|
||||
// 使用HTTP HEAD请求获取文件元信息
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.head(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OssError::network(format!("获取文件元信息失败: {e}")))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let headers = response.headers();
|
||||
let mut metadata = std::collections::HashMap::new();
|
||||
|
||||
// 提取常用的元信息
|
||||
if let Some(content_length) = headers.get("content-length") {
|
||||
metadata.insert(
|
||||
"content-length".to_string(),
|
||||
content_length.to_str().unwrap_or("").to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(content_type) = headers.get("content-type") {
|
||||
metadata.insert(
|
||||
"content-type".to_string(),
|
||||
content_type.to_str().unwrap_or("").to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(last_modified) = headers.get("last-modified") {
|
||||
metadata.insert(
|
||||
"last-modified".to_string(),
|
||||
last_modified.to_str().unwrap_or("").to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(etag) = headers.get("etag") {
|
||||
metadata.insert("etag".to_string(), etag.to_str().unwrap_or("").to_string());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Get public bucket file meta information: {} -> {} fields",
|
||||
prefixed_key,
|
||||
metadata.len()
|
||||
);
|
||||
Ok(Some(metadata))
|
||||
} else {
|
||||
info!("The public bucket file does not exist: {}", prefixed_key);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成唯一的object key
|
||||
///
|
||||
/// # 参数
|
||||
/// * `prefix` - 前缀
|
||||
/// * `filename` - 原始文件名(可选)
|
||||
///
|
||||
/// # 返回
|
||||
/// * 唯一的对象键
|
||||
pub fn generate_object_key(&self, prefix: &str, filename: Option<&str>) -> String {
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let uid = uuid::Uuid::new_v4().to_string()[..8].to_string(); // 取前8位作为短UID
|
||||
|
||||
// 如果有原始文件名,使用原始文件名和后缀
|
||||
let filename = if let Some(original) = filename {
|
||||
let clean_name = sanitize_filename(original);
|
||||
|
||||
// 分离文件名和扩展名
|
||||
if let Some(dot_pos) = clean_name.rfind('.') {
|
||||
let name_part = &clean_name[..dot_pos];
|
||||
let ext_part = &clean_name[dot_pos..]; // 包含点号
|
||||
format!("{name_part}_{timestamp}_{uid}{ext_part}")
|
||||
} else {
|
||||
// 没有扩展名,保持原名
|
||||
format!("{clean_name}_{timestamp}_{uid}.")
|
||||
}
|
||||
} else {
|
||||
// 没有原始文件名,生成默认名称
|
||||
format!("file_{timestamp}_{uid}")
|
||||
};
|
||||
|
||||
format!("{prefix}/{filename}")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl OssClientTrait for PublicOssClient {
|
||||
fn get_config(&self) -> &OssConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
fn get_base_url(&self) -> String {
|
||||
self.config.get_base_url()
|
||||
}
|
||||
|
||||
fn generate_upload_url(
|
||||
&self,
|
||||
object_key: &str,
|
||||
expires_in: std::time::Duration,
|
||||
content_type: Option<&str>,
|
||||
) -> Result<String> {
|
||||
// 获取带前缀的object key
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
|
||||
// 创建请求构建器
|
||||
let mut builder = aliyun_oss_rust_sdk::request::RequestBuilder::new()
|
||||
.with_expire(expires_in.as_secs() as i64);
|
||||
|
||||
// 设置Content-Type
|
||||
if let Some(ct) = content_type {
|
||||
builder = builder.with_content_type(ct);
|
||||
} else {
|
||||
builder = builder.with_content_type("application/octet-stream");
|
||||
}
|
||||
|
||||
// 创建OSS客户端(使用公有bucket)
|
||||
let oss_client = aliyun_oss_rust_sdk::oss::OSS::new(
|
||||
&self.config.access_key_id,
|
||||
&self.config.access_key_secret,
|
||||
&self.config.endpoint,
|
||||
&self.config.bucket,
|
||||
);
|
||||
|
||||
// 生成签名URL
|
||||
let url = oss_client.sign_upload_url(&prefixed_key, &builder);
|
||||
// 替换域名
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn generate_download_url(
|
||||
&self,
|
||||
object_key: &str,
|
||||
_expires_in: Option<std::time::Duration>,
|
||||
) -> Result<String> {
|
||||
// 公有bucket的下载URL不需要签名,直接返回公开URL
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
let url = format!("{}/{}", self.get_base_url(), prefixed_key);
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn upload_file(&self, local_path: &str, object_key: &str) -> Result<String> {
|
||||
// 检查文件是否存在
|
||||
if !std::path::Path::new(local_path).exists() {
|
||||
return Err(OssError::file_not_found(format!(
|
||||
"本地文件不存在: {local_path}"
|
||||
)));
|
||||
}
|
||||
|
||||
// 检测MIME类型
|
||||
let content_type = detect_mime_type(local_path);
|
||||
|
||||
// 获取带前缀的object key
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
|
||||
// 创建OSS客户端(使用公有bucket)
|
||||
let oss_client = aliyun_oss_rust_sdk::oss::OSS::new(
|
||||
&self.config.access_key_id,
|
||||
&self.config.access_key_secret,
|
||||
&self.config.endpoint,
|
||||
&self.config.bucket,
|
||||
);
|
||||
|
||||
// 创建请求构建器
|
||||
let builder = RequestBuilder::new().with_content_type(&content_type);
|
||||
|
||||
// 执行上传
|
||||
let local_path_string = local_path.to_string();
|
||||
match oss_client
|
||||
.put_object_from_file(&prefixed_key, &local_path_string, builder)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let url = format!("{}/{}", self.get_base_url(), prefixed_key);
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
Ok(url)
|
||||
}
|
||||
Err(e) => Err(OssError::sdk(format!("上传文件到公有bucket失败: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_content(
|
||||
&self,
|
||||
content: &[u8],
|
||||
object_key: &str,
|
||||
content_type: Option<&str>,
|
||||
) -> Result<String> {
|
||||
// 获取带前缀的object key
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
|
||||
// 创建临时文件
|
||||
let temp_file =
|
||||
tempfile::NamedTempFile::new().map_err(|e| OssError::io_error(e.to_string()))?;
|
||||
|
||||
// 写入内容到临时文件
|
||||
tokio::fs::write(temp_file.path(), content)
|
||||
.await
|
||||
.map_err(|e| OssError::io_error(e.to_string()))?;
|
||||
|
||||
// 创建OSS客户端(使用公有bucket)
|
||||
let oss_client = OSS::new(
|
||||
&self.config.access_key_id,
|
||||
&self.config.access_key_secret,
|
||||
&self.config.endpoint,
|
||||
&self.config.bucket,
|
||||
);
|
||||
|
||||
// 创建请求构建器
|
||||
let mut builder = RequestBuilder::new();
|
||||
if let Some(ct) = content_type {
|
||||
builder = builder.with_content_type(ct);
|
||||
} else {
|
||||
builder = builder.with_content_type("application/octet-stream");
|
||||
}
|
||||
|
||||
// 执行上传
|
||||
let temp_path_string = temp_file.path().to_str().unwrap().to_string();
|
||||
match oss_client
|
||||
.put_object_from_file(&prefixed_key, &temp_path_string, builder)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let url = format!("{}/{}", self.get_base_url(), prefixed_key);
|
||||
let url = utils::replace_oss_domain(&url);
|
||||
Ok(url)
|
||||
}
|
||||
Err(e) => Err(OssError::sdk(format!("上传内容到公有bucket失败: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_file(&self, object_key: &str) -> Result<()> {
|
||||
// 获取带前缀的object key
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
|
||||
// 创建OSS客户端(使用公有bucket)
|
||||
let oss_client = OSS::new(
|
||||
&self.config.access_key_id,
|
||||
&self.config.access_key_secret,
|
||||
&self.config.endpoint,
|
||||
&self.config.bucket,
|
||||
);
|
||||
|
||||
// 创建请求构建器
|
||||
let builder = RequestBuilder::new();
|
||||
|
||||
// 执行删除
|
||||
match oss_client.delete_object(&prefixed_key, builder).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(OssError::sdk(format!("删除公有bucket文件失败: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn file_exists(&self, object_key: &str) -> Result<bool> {
|
||||
// 获取带前缀的object key
|
||||
let prefixed_key = self.config.get_prefixed_key(object_key);
|
||||
|
||||
// 创建OSS客户端(使用公有bucket)
|
||||
let oss_client = OSS::new(
|
||||
&self.config.access_key_id,
|
||||
&self.config.access_key_secret,
|
||||
&self.config.endpoint,
|
||||
&self.config.bucket,
|
||||
);
|
||||
|
||||
// 创建请求构建器
|
||||
let builder = RequestBuilder::new();
|
||||
|
||||
// 使用 HEAD 获取元信息来检查是否存在(避免下载主体)
|
||||
match oss_client.get_object_metadata(&prefixed_key, builder).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
warn!("File existence check failed: {}", e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_connection(&self) -> Result<()> {
|
||||
// 通过尝试上传一个小的测试文件来验证连接
|
||||
let test_key = format!("health-check-{}", chrono::Utc::now().timestamp_millis());
|
||||
let test_content = b"OSS connection test";
|
||||
|
||||
match <PublicOssClient as OssClientTrait>::upload_content(
|
||||
self,
|
||||
test_content,
|
||||
&test_key,
|
||||
Some("text/plain"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// 尝试删除测试文件(忽略删除失败)
|
||||
let _ = <PublicOssClient as OssClientTrait>::delete_file(self, &test_key).await;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(OssError::network(format!("无法连接到公有bucket: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_object_key(&self, prefix: &str, filename: Option<&str>) -> String {
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let uid = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
|
||||
let filename = if let Some(original) = filename {
|
||||
let clean_name = sanitize_filename(original);
|
||||
if let Some(dot_pos) = clean_name.rfind('.') {
|
||||
let name_part = &clean_name[..dot_pos];
|
||||
let ext_part = &clean_name[dot_pos..];
|
||||
format!("{name_part}_{timestamp}_{uid}{ext_part}")
|
||||
} else {
|
||||
format!("{clean_name}_{timestamp}_{uid}.")
|
||||
}
|
||||
} else {
|
||||
format!("file_{timestamp}_{uid}")
|
||||
};
|
||||
|
||||
format!("{prefix}/{filename}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_public_client_creation() {
|
||||
let config = OssConfig::new(
|
||||
crate::config::defaults::ENDPOINT.to_string(),
|
||||
crate::config::defaults::PUBLIC_BUCKET.to_string(),
|
||||
"test_key_id".to_string(),
|
||||
"test_key_secret".to_string(),
|
||||
crate::config::defaults::REGION.to_string(),
|
||||
crate::config::defaults::UPLOAD_DIRECTORY.to_string(),
|
||||
);
|
||||
let client = PublicOssClient::new(config).unwrap();
|
||||
assert_eq!(client.get_config().bucket, "nuwa-packages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_public_download_url() {
|
||||
let config = OssConfig::new(
|
||||
crate::config::defaults::ENDPOINT.to_string(),
|
||||
crate::config::defaults::PUBLIC_BUCKET.to_string(),
|
||||
"test_key_id".to_string(),
|
||||
"test_key_secret".to_string(),
|
||||
crate::config::defaults::REGION.to_string(),
|
||||
crate::config::defaults::UPLOAD_DIRECTORY.to_string(),
|
||||
);
|
||||
let client = PublicOssClient::new(config).unwrap();
|
||||
|
||||
let url = client
|
||||
.generate_public_download_url("test/file.txt")
|
||||
.unwrap();
|
||||
// 验证URL包含正确的路径,但域名可能被替换为自定义域名
|
||||
assert!(url.contains("edu/test/file.txt"));
|
||||
// 由于 replace_oss_domain 可能替换域名,我们只验证路径部分
|
||||
// 公开URL不应该包含签名参数
|
||||
assert!(!url.contains("Expires="));
|
||||
assert!(!url.contains("Signature="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_public_access_url() {
|
||||
let config = OssConfig::new(
|
||||
crate::config::defaults::ENDPOINT.to_string(),
|
||||
crate::config::defaults::PUBLIC_BUCKET.to_string(),
|
||||
"test_key_id".to_string(),
|
||||
"test_key_secret".to_string(),
|
||||
crate::config::defaults::REGION.to_string(),
|
||||
crate::config::defaults::UPLOAD_DIRECTORY.to_string(),
|
||||
);
|
||||
let client = PublicOssClient::new(config).unwrap();
|
||||
|
||||
let url = client.generate_public_access_url("test/image.jpg").unwrap();
|
||||
// 验证URL包含正确的路径,但域名可能被替换为自定义域名
|
||||
assert!(url.contains("edu/test/image.jpg"));
|
||||
// 由于 replace_oss_domain 可能替换域名,我们只验证路径部分
|
||||
// 公开URL不应该包含签名参数
|
||||
assert!(!url.contains("Expires="));
|
||||
assert!(!url.contains("Signature="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_public_urls_batch() {
|
||||
let config = OssConfig::new(
|
||||
crate::config::defaults::ENDPOINT.to_string(),
|
||||
crate::config::defaults::PUBLIC_BUCKET.to_string(),
|
||||
"test_key_id".to_string(),
|
||||
"test_key_secret".to_string(),
|
||||
crate::config::defaults::REGION.to_string(),
|
||||
crate::config::defaults::UPLOAD_DIRECTORY.to_string(),
|
||||
);
|
||||
let client = PublicOssClient::new(config).unwrap();
|
||||
|
||||
let keys = vec!["doc1.pdf", "doc2.pdf", "image.jpg"];
|
||||
let urls = client.generate_public_urls_batch(&keys).unwrap();
|
||||
|
||||
assert_eq!(urls.len(), 3);
|
||||
for (key, url) in urls {
|
||||
// 验证URL包含正确的路径,但域名可能被替换为自定义域名
|
||||
assert!(url.contains(&format!("edu/{key}")));
|
||||
// 由于 replace_oss_domain 可能替换域名,我们只验证路径部分
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_bucket_info() {
|
||||
let config = OssConfig::new(
|
||||
crate::config::defaults::ENDPOINT.to_string(),
|
||||
crate::config::defaults::PUBLIC_BUCKET.to_string(),
|
||||
"test_key_id".to_string(),
|
||||
"test_key_secret".to_string(),
|
||||
crate::config::defaults::REGION.to_string(),
|
||||
crate::config::defaults::UPLOAD_DIRECTORY.to_string(),
|
||||
);
|
||||
let bucket = config.bucket.clone();
|
||||
let endpoint = config.endpoint.clone();
|
||||
let region = config.region.clone();
|
||||
let client = PublicOssClient::new(config).unwrap();
|
||||
|
||||
let info = client.get_bucket_info();
|
||||
// 验证信息包含配置的bucket和endpoint,但不硬编码具体的值
|
||||
assert!(info.contains(&bucket));
|
||||
assert!(info.contains(&endpoint));
|
||||
assert!(info.contains(®ion));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_object_key() {
|
||||
let config = OssConfig::new(
|
||||
crate::config::defaults::ENDPOINT.to_string(),
|
||||
crate::config::defaults::PUBLIC_BUCKET.to_string(),
|
||||
"test_key_id".to_string(),
|
||||
"test_key_secret".to_string(),
|
||||
crate::config::defaults::REGION.to_string(),
|
||||
crate::config::defaults::UPLOAD_DIRECTORY.to_string(),
|
||||
);
|
||||
let client = PublicOssClient::new(config).unwrap();
|
||||
|
||||
// 测试生成带文件名的对象键
|
||||
let key1 = client.generate_object_key("documents", Some("manual.pdf"));
|
||||
assert!(key1.starts_with("documents/"));
|
||||
assert!(key1.contains("manual"));
|
||||
assert!(key1.ends_with(".pdf"));
|
||||
|
||||
// 测试生成不带文件名的对象键
|
||||
let key2 = client.generate_object_key("images", None);
|
||||
assert!(key2.starts_with("images/"));
|
||||
assert!(key2.contains("file_"));
|
||||
}
|
||||
}
|
||||
501
qiming-mcp-proxy/oss-client/src/utils.rs
Normal file
501
qiming-mcp-proxy/oss-client/src/utils.rs
Normal file
@@ -0,0 +1,501 @@
|
||||
//! 工具函数模块
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
/// MIME类型映射表
|
||||
fn get_mime_type_map() -> HashMap<&'static str, &'static str> {
|
||||
let mut map = HashMap::new();
|
||||
|
||||
// 图片类型
|
||||
map.insert("jpg", "image/jpeg");
|
||||
map.insert("jpeg", "image/jpeg");
|
||||
map.insert("png", "image/png");
|
||||
map.insert("gif", "image/gif");
|
||||
map.insert("webp", "image/webp");
|
||||
map.insert("svg", "image/svg+xml");
|
||||
map.insert("bmp", "image/bmp");
|
||||
map.insert("tiff", "image/tiff");
|
||||
map.insert("ico", "image/x-icon");
|
||||
|
||||
// 文档类型
|
||||
map.insert("pdf", "application/pdf");
|
||||
map.insert("doc", "application/msword");
|
||||
map.insert(
|
||||
"docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
);
|
||||
map.insert("xls", "application/vnd.ms-excel");
|
||||
map.insert(
|
||||
"xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
map.insert("ppt", "application/vnd.ms-powerpoint");
|
||||
map.insert(
|
||||
"pptx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
);
|
||||
|
||||
// 文本类型
|
||||
map.insert("txt", "text/plain");
|
||||
map.insert("html", "text/html");
|
||||
map.insert("htm", "text/html");
|
||||
map.insert("css", "text/css");
|
||||
map.insert("js", "application/javascript");
|
||||
map.insert("json", "application/json");
|
||||
map.insert("xml", "application/xml");
|
||||
map.insert("csv", "text/csv");
|
||||
map.insert("md", "text/markdown");
|
||||
|
||||
// 压缩文件
|
||||
map.insert("zip", "application/zip");
|
||||
map.insert("rar", "application/x-rar-compressed");
|
||||
map.insert("7z", "application/x-7z-compressed");
|
||||
map.insert("tar", "application/x-tar");
|
||||
map.insert("gz", "application/gzip");
|
||||
|
||||
// 音频类型
|
||||
map.insert("mp3", "audio/mpeg");
|
||||
map.insert("wav", "audio/wav");
|
||||
map.insert("ogg", "audio/ogg");
|
||||
map.insert("m4a", "audio/mp4");
|
||||
map.insert("aac", "audio/aac");
|
||||
map.insert("flac", "audio/flac");
|
||||
|
||||
// 视频类型
|
||||
map.insert("mp4", "video/mp4");
|
||||
map.insert("avi", "video/x-msvideo");
|
||||
map.insert("mov", "video/quicktime");
|
||||
map.insert("wmv", "video/x-ms-wmv");
|
||||
map.insert("flv", "video/x-flv");
|
||||
map.insert("webm", "video/webm");
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
/// 检测文件MIME类型
|
||||
pub fn detect_mime_type(file_path: &str) -> String {
|
||||
let path = Path::new(file_path);
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
let mime_map = get_mime_type_map();
|
||||
mime_map
|
||||
.get(extension.as_str())
|
||||
.unwrap_or(&"application/octet-stream")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 根据扩展名检测MIME类型
|
||||
pub fn detect_mime_type_by_extension(extension: &str) -> String {
|
||||
let ext = extension.trim_start_matches('.').to_lowercase();
|
||||
let mime_map = get_mime_type_map();
|
||||
mime_map
|
||||
.get(ext.as_str())
|
||||
.unwrap_or(&"application/octet-stream")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 判断是否为图片文件
|
||||
pub fn is_image_file(file_path: &str) -> bool {
|
||||
let mime_type = detect_mime_type(file_path);
|
||||
mime_type.starts_with("image/")
|
||||
}
|
||||
|
||||
/// 判断是否为文档文件
|
||||
pub fn is_document_file(file_path: &str) -> bool {
|
||||
let mime_type = detect_mime_type(file_path);
|
||||
mime_type.starts_with("application/")
|
||||
&& (mime_type.contains("pdf")
|
||||
|| mime_type.contains("word")
|
||||
|| mime_type.contains("excel")
|
||||
|| mime_type.contains("powerpoint")
|
||||
|| mime_type.contains("document"))
|
||||
}
|
||||
|
||||
/// 判断是否为音频文件
|
||||
pub fn is_audio_file(file_path: &str) -> bool {
|
||||
let mime_type = detect_mime_type(file_path);
|
||||
mime_type.starts_with("audio/")
|
||||
}
|
||||
|
||||
/// 判断是否为视频文件
|
||||
pub fn is_video_file(file_path: &str) -> bool {
|
||||
let mime_type = detect_mime_type(file_path);
|
||||
mime_type.starts_with("video/")
|
||||
}
|
||||
|
||||
/// 清理文件名,移除特殊字符
|
||||
pub fn sanitize_filename(filename: &str) -> String {
|
||||
filename
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 格式化文件大小
|
||||
pub fn format_file_size(size: u64) -> String {
|
||||
let size = size as f64;
|
||||
if size < 1024.0 {
|
||||
format!("{size} B")
|
||||
} else if size < 1024.0 * 1024.0 {
|
||||
format!("{:.2} KB", size / 1024.0)
|
||||
} else if size < 1024.0 * 1024.0 * 1024.0 {
|
||||
format!("{:.2} MB", size / (1024.0 * 1024.0))
|
||||
} else if size < 1024.0 * 1024.0 * 1024.0 * 1024.0 {
|
||||
format!("{:.2} GB", size / (1024.0 * 1024.0 * 1024.0))
|
||||
} else {
|
||||
format!("{:.2} TB", size / (1024.0 * 1024.0 * 1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析文件大小字符串(如"100MB")为字节数
|
||||
pub fn parse_file_size(size_str: &str) -> Result<u64, String> {
|
||||
let size_str = size_str.trim().to_uppercase();
|
||||
|
||||
if size_str.is_empty() {
|
||||
return Err("文件大小字符串不能为空".to_string());
|
||||
}
|
||||
|
||||
// 提取数字部分和单位部分
|
||||
let (number_part, unit_part) = if size_str.ends_with("TB") {
|
||||
(&size_str[..size_str.len() - 2], "TB")
|
||||
} else if size_str.ends_with("GB") {
|
||||
(&size_str[..size_str.len() - 2], "GB")
|
||||
} else if size_str.ends_with("MB") {
|
||||
(&size_str[..size_str.len() - 2], "MB")
|
||||
} else if size_str.ends_with("KB") {
|
||||
(&size_str[..size_str.len() - 2], "KB")
|
||||
} else if size_str.ends_with("B") {
|
||||
(&size_str[..size_str.len() - 1], "B")
|
||||
} else {
|
||||
// 没有单位,默认为字节
|
||||
(size_str.as_str(), "B")
|
||||
};
|
||||
|
||||
let number: f64 = number_part
|
||||
.parse()
|
||||
.map_err(|_| format!("无效的数字: {number_part}"))?;
|
||||
|
||||
if number < 0.0 {
|
||||
return Err("文件大小不能为负数".to_string());
|
||||
}
|
||||
|
||||
let bytes = match unit_part {
|
||||
"B" => number,
|
||||
"KB" => number * 1024.0,
|
||||
"MB" => number * 1024.0 * 1024.0,
|
||||
"GB" => number * 1024.0 * 1024.0 * 1024.0,
|
||||
"TB" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
|
||||
_ => return Err(format!("不支持的单位: {unit_part}")),
|
||||
};
|
||||
|
||||
Ok(bytes as u64)
|
||||
}
|
||||
|
||||
/// 生成随机文件名
|
||||
pub fn generate_random_filename(extension: Option<&str>) -> String {
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let uid = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
|
||||
match extension {
|
||||
Some(ext) => {
|
||||
let clean_ext = ext.trim_start_matches('.');
|
||||
format!("{timestamp}_{uid}.{clean_ext}")
|
||||
}
|
||||
None => format!("{timestamp}_{uid}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取文件扩展名
|
||||
pub fn get_file_extension(file_path: &str) -> Option<String> {
|
||||
Path::new(file_path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_lowercase())
|
||||
}
|
||||
|
||||
/// 获取文件名(不包含路径)
|
||||
pub fn get_filename(file_path: &str) -> Option<String> {
|
||||
Path::new(file_path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.to_string())
|
||||
}
|
||||
|
||||
/// 获取文件名(不包含扩展名)
|
||||
pub fn get_filename_without_extension(file_path: &str) -> Option<String> {
|
||||
Path::new(file_path)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(|stem| stem.to_string())
|
||||
}
|
||||
|
||||
/// 替换OSS域名前缀,解决跨域问题
|
||||
///
|
||||
/// 将阿里云OSS的域名替换为自定义域名,避免跨域问题
|
||||
///
|
||||
/// # 参数
|
||||
/// * `url` - 原始OSS URL
|
||||
///
|
||||
/// # 返回值
|
||||
/// * 替换后的URL,如果没有匹配的域名则返回原URL
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// use oss_client::utils::replace_oss_domain;
|
||||
///
|
||||
/// let original_url = "https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/image.jpg";
|
||||
/// let replaced_url = replace_oss_domain(original_url);
|
||||
/// assert_eq!(replaced_url, "https://statics-ali.nuwax.com/image.jpg");
|
||||
/// ```
|
||||
pub fn replace_oss_domain(url: &str) -> String {
|
||||
//把固定的公网 bucket 替换为自定义域名
|
||||
const OLD_DOMAIN: &str = "https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com";
|
||||
const NEW_DOMAIN: &str = "https://statics-ali.nuwax.com";
|
||||
debug!("Replace OSS domain name: {}", url);
|
||||
let new_url = if url.starts_with(OLD_DOMAIN) {
|
||||
url.replacen(OLD_DOMAIN, NEW_DOMAIN, 1)
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
debug!("Replaced OSS domain name: {}", new_url);
|
||||
new_url
|
||||
}
|
||||
|
||||
/// 批量替换OSS域名前缀
|
||||
///
|
||||
/// 对多个URL进行域名替换
|
||||
///
|
||||
/// # 参数
|
||||
/// * `urls` - URL列表的引用
|
||||
///
|
||||
/// # 返回值
|
||||
/// * 替换后的URL列表
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// use oss_client::utils::replace_oss_domains_batch;
|
||||
///
|
||||
/// let urls = vec![
|
||||
/// "https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/image1.jpg".to_string(),
|
||||
/// "https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/image2.jpg".to_string(),
|
||||
/// ];
|
||||
/// let replaced_urls = replace_oss_domains_batch(&urls);
|
||||
/// assert_eq!(replaced_urls[0], "https://statics-ali.nuwax.com/image1.jpg");
|
||||
/// assert_eq!(replaced_urls[1], "https://statics-ali.nuwax.com/image2.jpg");
|
||||
/// ```
|
||||
pub fn replace_oss_domains_batch(urls: &[String]) -> Vec<String> {
|
||||
urls.iter().map(|url| replace_oss_domain(url)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_mime_type() {
|
||||
assert_eq!(detect_mime_type("test.jpg"), "image/jpeg");
|
||||
assert_eq!(detect_mime_type("test.png"), "image/png");
|
||||
assert_eq!(detect_mime_type("test.pdf"), "application/pdf");
|
||||
assert_eq!(
|
||||
detect_mime_type("test.docx"),
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
);
|
||||
assert_eq!(detect_mime_type("test.mp3"), "audio/mpeg");
|
||||
assert_eq!(detect_mime_type("test.mp4"), "video/mp4");
|
||||
assert_eq!(detect_mime_type("test.unknown"), "application/octet-stream");
|
||||
assert_eq!(detect_mime_type("test"), "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_mime_type_by_extension() {
|
||||
assert_eq!(detect_mime_type_by_extension("jpg"), "image/jpeg");
|
||||
assert_eq!(detect_mime_type_by_extension(".png"), "image/png");
|
||||
assert_eq!(detect_mime_type_by_extension("PDF"), "application/pdf");
|
||||
assert_eq!(
|
||||
detect_mime_type_by_extension("unknown"),
|
||||
"application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_type_detection() {
|
||||
assert!(is_image_file("test.jpg"));
|
||||
assert!(is_image_file("test.png"));
|
||||
assert!(!is_image_file("test.pdf"));
|
||||
|
||||
assert!(is_document_file("test.pdf"));
|
||||
assert!(is_document_file("test.docx"));
|
||||
assert!(!is_document_file("test.jpg"));
|
||||
|
||||
assert!(is_audio_file("test.mp3"));
|
||||
assert!(is_audio_file("test.wav"));
|
||||
assert!(!is_audio_file("test.jpg"));
|
||||
|
||||
assert!(is_video_file("test.mp4"));
|
||||
assert!(is_video_file("test.avi"));
|
||||
assert!(!is_video_file("test.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename() {
|
||||
assert_eq!(sanitize_filename("test file.txt"), "test_file.txt");
|
||||
assert_eq!(sanitize_filename("test@#$%file.txt"), "test____file.txt");
|
||||
assert_eq!(
|
||||
sanitize_filename("normal-file_name.txt"),
|
||||
"normal-file_name.txt"
|
||||
);
|
||||
|
||||
// 中文字符实际上会被保留,因为它们通过了is_alphanumeric()检查
|
||||
let result = sanitize_filename("中文文件名.txt");
|
||||
assert!(result.contains(".txt"));
|
||||
assert!(result.contains("中文文件名"));
|
||||
|
||||
// 特殊字符会被替换为下划线
|
||||
assert_eq!(sanitize_filename("file@name.txt"), "file_name.txt");
|
||||
assert_eq!(
|
||||
sanitize_filename("file name with spaces.txt"),
|
||||
"file_name_with_spaces.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_file_size() {
|
||||
assert_eq!(format_file_size(0), "0 B");
|
||||
assert_eq!(format_file_size(512), "512 B");
|
||||
assert_eq!(format_file_size(1024), "1.00 KB");
|
||||
assert_eq!(format_file_size(1536), "1.50 KB");
|
||||
assert_eq!(format_file_size(1024 * 1024), "1.00 MB");
|
||||
assert_eq!(format_file_size(1024 * 1024 * 1024), "1.00 GB");
|
||||
assert_eq!(format_file_size(1024_u64.pow(4)), "1.00 TB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_file_size() {
|
||||
assert_eq!(parse_file_size("100").unwrap(), 100);
|
||||
assert_eq!(parse_file_size("100B").unwrap(), 100);
|
||||
assert_eq!(parse_file_size("1KB").unwrap(), 1024);
|
||||
assert_eq!(parse_file_size("1MB").unwrap(), 1024 * 1024);
|
||||
assert_eq!(parse_file_size("1GB").unwrap(), 1024 * 1024 * 1024);
|
||||
assert_eq!(parse_file_size("1TB").unwrap(), 1024_u64.pow(4));
|
||||
|
||||
assert_eq!(
|
||||
parse_file_size("1.5MB").unwrap(),
|
||||
(1.5 * 1024.0 * 1024.0) as u64
|
||||
);
|
||||
assert_eq!(parse_file_size("100mb").unwrap(), 100 * 1024 * 1024);
|
||||
|
||||
assert!(parse_file_size("").is_err());
|
||||
assert!(parse_file_size("abc").is_err());
|
||||
assert!(parse_file_size("-100MB").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_random_filename() {
|
||||
let filename1 = generate_random_filename(Some("txt"));
|
||||
let filename2 = generate_random_filename(Some(".jpg"));
|
||||
let filename3 = generate_random_filename(None);
|
||||
|
||||
assert!(filename1.ends_with(".txt"));
|
||||
assert!(filename2.ends_with(".jpg"));
|
||||
assert!(!filename3.contains("."));
|
||||
|
||||
// 确保生成的文件名不同
|
||||
assert_ne!(filename1, filename2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_path_utilities() {
|
||||
assert_eq!(get_file_extension("test.txt"), Some("txt".to_string()));
|
||||
assert_eq!(get_file_extension("test.TAR.GZ"), Some("gz".to_string()));
|
||||
assert_eq!(get_file_extension("test"), None);
|
||||
|
||||
assert_eq!(
|
||||
get_filename("/path/to/test.txt"),
|
||||
Some("test.txt".to_string())
|
||||
);
|
||||
assert_eq!(get_filename("test.txt"), Some("test.txt".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
get_filename_without_extension("/path/to/test.txt"),
|
||||
Some("test".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
get_filename_without_extension("test"),
|
||||
Some("test".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_oss_domain() {
|
||||
// 测试正常替换
|
||||
let original_url = "https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/image.jpg";
|
||||
let replaced_url = replace_oss_domain(original_url);
|
||||
assert_eq!(replaced_url, "https://statics-ali.nuwax.com/image.jpg");
|
||||
|
||||
// 测试带路径的URL
|
||||
let original_url_with_path =
|
||||
"https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/folder/subfolder/image.png";
|
||||
let replaced_url_with_path = replace_oss_domain(original_url_with_path);
|
||||
assert_eq!(
|
||||
replaced_url_with_path,
|
||||
"https://statics-ali.nuwax.com/folder/subfolder/image.png"
|
||||
);
|
||||
|
||||
// 测试带查询参数的URL
|
||||
let original_url_with_query = "https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/image.jpg?version=1.0&size=large";
|
||||
let replaced_url_with_query = replace_oss_domain(original_url_with_query);
|
||||
assert_eq!(
|
||||
replaced_url_with_query,
|
||||
"https://statics-ali.nuwax.com/image.jpg?version=1.0&size=large"
|
||||
);
|
||||
|
||||
// 测试不匹配的域名
|
||||
let other_url = "https://other-domain.com/image.jpg";
|
||||
let unchanged_url = replace_oss_domain(other_url);
|
||||
assert_eq!(unchanged_url, other_url);
|
||||
|
||||
// 测试空字符串
|
||||
let empty_url = "";
|
||||
let unchanged_empty = replace_oss_domain(empty_url);
|
||||
assert_eq!(unchanged_empty, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_oss_domains_batch() {
|
||||
let urls = vec![
|
||||
"https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/image1.jpg".to_string(),
|
||||
"https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/image2.jpg".to_string(),
|
||||
"https://other-domain.com/image3.jpg".to_string(),
|
||||
"https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/folder/image4.png"
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let replaced_urls = replace_oss_domains_batch(&urls);
|
||||
|
||||
assert_eq!(replaced_urls[0], "https://statics-ali.nuwax.com/image1.jpg");
|
||||
assert_eq!(replaced_urls[1], "https://statics-ali.nuwax.com/image2.jpg");
|
||||
assert_eq!(replaced_urls[2], "https://other-domain.com/image3.jpg"); // 不匹配的域名保持不变
|
||||
assert_eq!(
|
||||
replaced_urls[3],
|
||||
"https://statics-ali.nuwax.com/folder/image4.png"
|
||||
);
|
||||
|
||||
// 测试空列表
|
||||
let empty_urls: Vec<String> = vec![];
|
||||
let replaced_empty = replace_oss_domains_batch(&empty_urls);
|
||||
assert_eq!(replaced_empty.len(), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user