diff --git a/.kiro/specs/image-upload-feature/design.md b/.kiro/specs/image-upload-feature/design.md new file mode 100644 index 00000000..592bd670 --- /dev/null +++ b/.kiro/specs/image-upload-feature/design.md @@ -0,0 +1,295 @@ +# Design Document + +## Overview + +本设计文档描述图片上传功能的技术实现方案。系统采用策略模式实现多存储提供者支持,后端使用 ASP.NET Core 实现上传接口,前端使用 Vue 3 + Element Plus 实现上传组件。 + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontend (Vue 3) │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ ImageUpload │ │ Business Pages │ │ +│ │ Component │◄───│ (10 pages) │ │ +│ └────────┬────────┘ └─────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ upload.ts API │ │ +│ └────────┬────────┘ │ +└───────────┼─────────────────────────────────────────────────────┘ + │ HTTP POST /api/admin/upload/image + ▼ +┌───────────────────────────────────────────────────────────────────┐ +│ Backend (ASP.NET Core) │ +│ ┌─────────────────┐ │ +│ │ UploadController│ │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ IUploadService │◄───│ UploadService │ │ +│ └─────────────────┘ └────────┬────────┘ │ +│ │ │ +│ ┌──────────────────────┼──────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ +│ │ IStorageProvider│ │LocalStorage │ │TencentCos │ │ +│ │ (Interface) │◄───│Provider │ │Provider │ │ +│ └─────────────────┘ └─────────────────┘ └──────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +## Components and Interfaces + +### Backend Components + +#### 1. IStorageProvider 接口 + +```csharp +public interface IStorageProvider +{ + /// + /// 存储类型标识 + /// + string StorageType { get; } + + /// + /// 上传文件 + /// + Task UploadAsync(Stream fileStream, string fileName, string contentType); + + /// + /// 删除文件 + /// + Task DeleteAsync(string fileUrl); +} +``` + +#### 2. LocalStorageProvider 本地存储 + +```csharp +public class LocalStorageProvider : IStorageProvider +{ + public string StorageType => "1"; + + // 保存到 wwwroot/uploads/{yyyy}/{MM}/{dd}/{filename} + // 返回 /uploads/{yyyy}/{MM}/{dd}/{filename} +} +``` + +#### 3. TencentCosProvider 腾讯云COS存储 + +```csharp +public class TencentCosProvider : IStorageProvider +{ + public string StorageType => "3"; + + // 使用腾讯云COS SDK上传 + // 返回 {Domain}/uploads/{yyyy}/{MM}/{dd}/{filename} +} +``` + +#### 4. IUploadService 上传服务接口 + +```csharp +public interface IUploadService +{ + /// + /// 上传图片 + /// + Task UploadImageAsync(IFormFile file); + + /// + /// 批量上传图片 + /// + Task> UploadImagesAsync(List files); +} +``` + +#### 5. UploadService 上传服务实现 + +```csharp +public class UploadService : IUploadService +{ + // 根据配置选择存储提供者 + // 验证文件格式和大小 + // 生成唯一文件名 + // 调用存储提供者上传 +} +``` + +#### 6. UploadController 上传控制器 + +```csharp +[Route("api/admin/upload")] +public class UploadController : BusinessControllerBase +{ + [HttpPost("image")] + public async Task UploadImage(IFormFile file); + + [HttpPost("images")] + public async Task UploadImages(List files); +} +``` + +### Frontend Components + +#### 1. ImageUpload 组件 (已创建) + +位置: `src/components/ImageUpload/index.vue` + +Props: +- `modelValue`: string - 图片URL (v-model) +- `disabled`: boolean - 是否禁用 +- `placeholder`: string - 占位文字 +- `showUrlInput`: boolean - 是否显示URL输入框 +- `accept`: string - 接受的文件类型 +- `maxSize`: number - 最大文件大小(MB) +- `tip`: string - 提示文字 + +Events: +- `update:modelValue` - URL变化 +- `change` - 图片变化 +- `upload-success` - 上传成功 +- `upload-error` - 上传失败 + +#### 2. upload.ts API (已创建) + +位置: `src/api/upload.ts` + +```typescript +export function uploadFile(file: File, onProgress?: (percent: number) => void): Promise> +export function uploadFiles(files: File[], onProgress?: (percent: number) => void): Promise> +``` + +## Data Models + +### Backend Models + +```csharp +/// +/// 上传响应 +/// +public class UploadResponse +{ + /// + /// 文件URL + /// + public string Url { get; set; } = string.Empty; + + /// + /// 文件名 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// 文件大小(字节) + /// + public long FileSize { get; set; } +} + +/// +/// 上传结果(内部使用) +/// +public class UploadResult +{ + public bool Success { get; set; } + public string? Url { get; set; } + public string? ErrorMessage { get; set; } +} +``` + +### 上传配置 (已存在) + +```csharp +public class UploadSetting +{ + public string? Type { get; set; } // 1=本地, 3=腾讯云 + public string? Bucket { get; set; } // COS Bucket名称 + public string? Region { get; set; } // COS 地域 + public string? AccessKeyId { get; set; } // COS SecretId + public string? AccessKeySecret { get; set; } // COS SecretKey + public string? Domain { get; set; } // 访问域名 +} +``` + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: 文件格式验证 + +*For any* 上传的文件,如果文件扩展名不在允许列表(jpg, jpeg, png, gif, webp)中,THE Upload_Service SHALL 返回格式错误。 + +**Validates: Requirements 1.2, 1.4** + +### Property 2: 文件大小验证 + +*For any* 上传的文件,如果文件大小超过10MB,THE Upload_Service SHALL 返回大小超限错误。 + +**Validates: Requirements 1.3** + +### Property 3: 文件名唯一性 + +*For any* 两次上传操作,即使上传相同的文件,生成的文件名 SHALL 不同。 + +**Validates: Requirements 1.5** + +### Property 4: 存储策略一致性 + +*For any* 上传操作,返回的URL格式 SHALL 与当前配置的存储类型一致: +- 本地存储: URL以 `/uploads/` 开头 +- 腾讯云COS: URL以配置的 Domain 开头 + +**Validates: Requirements 2.3, 3.3** + +### Property 5: 上传成功返回有效URL + +*For any* 成功的上传操作,返回的URL SHALL 是可访问的有效图片地址。 + +**Validates: Requirements 1.1** + +## Error Handling + +| 错误场景 | 错误码 | 错误信息 | +|---------|--------|---------| +| 文件为空 | 400 | 请选择要上传的文件 | +| 文件格式不支持 | 400 | 只支持 jpg、jpeg、png、gif、webp 格式的图片 | +| 文件大小超限 | 400 | 文件大小不能超过10MB | +| 存储配置无效 | 500 | 存储配置无效,请检查上传配置 | +| COS上传失败 | 500 | 上传到云存储失败: {具体错误} | +| 本地存储失败 | 500 | 保存文件失败: {具体错误} | + +## Testing Strategy + +### 单元测试 + +1. **UploadService 测试** + - 测试文件格式验证 + - 测试文件大小验证 + - 测试文件名生成唯一性 + - 测试存储提供者选择逻辑 + +2. **LocalStorageProvider 测试** + - 测试目录创建 + - 测试文件保存 + - 测试URL生成 + +3. **TencentCosProvider 测试** + - 测试配置读取 + - 测试上传逻辑(使用Mock) + +### 集成测试 + +1. **API 集成测试** + - 测试上传接口完整流程 + - 测试错误响应格式 + +### 前端测试 + +1. **ImageUpload 组件测试** + - 测试文件选择 + - 测试上传进度显示 + - 测试v-model绑定 diff --git a/.kiro/specs/image-upload-feature/requirements.md b/.kiro/specs/image-upload-feature/requirements.md new file mode 100644 index 00000000..edf327fe --- /dev/null +++ b/.kiro/specs/image-upload-feature/requirements.md @@ -0,0 +1,93 @@ +# Requirements Document + +## Introduction + +实现后台管理系统的图片上传功能,支持本地存储和腾讯云COS存储两种方式。系统需要根据上传配置自动选择存储方式,并提供统一的前端上传组件供各业务模块使用。 + +## Glossary + +- **Upload_Service**: 文件上传服务,负责处理文件上传逻辑 +- **Storage_Provider**: 存储提供者,包括本地存储和腾讯云COS +- **ImageUpload_Component**: 前端图片上传组件 +- **Upload_Config**: 上传配置,存储在数据库config表中 + +## Requirements + +### Requirement 1: 后端上传接口 + +**User Story:** As a 管理员, I want to 上传图片到服务器, so that 我可以在各业务模块中使用图片。 + +#### Acceptance Criteria + +1. WHEN 管理员上传图片文件 THEN THE Upload_Service SHALL 接收文件并返回图片URL +2. WHEN 上传的文件不是图片格式 THEN THE Upload_Service SHALL 返回错误提示"只支持图片格式" +3. WHEN 上传的文件超过10MB THEN THE Upload_Service SHALL 返回错误提示"文件大小不能超过10MB" +4. THE Upload_Service SHALL 支持 jpg、jpeg、png、gif、webp 格式的图片 +5. THE Upload_Service SHALL 生成唯一的文件名避免文件覆盖 + +### Requirement 2: 本地存储实现 + +**User Story:** As a 系统管理员, I want to 将图片存储到服务器本地, so that 无需配置云存储即可使用上传功能。 + +#### Acceptance Criteria + +1. WHEN Upload_Config 的 type 为 "1" THEN THE Storage_Provider SHALL 将文件保存到服务器本地目录 +2. THE Storage_Provider SHALL 按日期创建子目录(如 uploads/2026/01/19/) +3. THE Storage_Provider SHALL 返回可访问的图片URL路径 +4. WHEN 本地存储目录不存在 THEN THE Storage_Provider SHALL 自动创建目录 + +### Requirement 3: 腾讯云COS存储实现 + +**User Story:** As a 系统管理员, I want to 将图片上传到腾讯云COS, so that 可以利用CDN加速和更好的存储可靠性。 + +#### Acceptance Criteria + +1. WHEN Upload_Config 的 type 为 "3" THEN THE Storage_Provider SHALL 将文件上传到腾讯云COS +2. THE Storage_Provider SHALL 使用配置中的 Bucket、Region、AccessKeyId、AccessKeySecret 连接COS +3. THE Storage_Provider SHALL 返回配置的 Domain 拼接文件路径作为访问URL +4. IF COS上传失败 THEN THE Storage_Provider SHALL 返回具体的错误信息 +5. THE Storage_Provider SHALL 按日期创建COS对象路径(如 uploads/2026/01/19/xxx.jpg) + +### Requirement 4: 存储策略切换 + +**User Story:** As a 系统管理员, I want to 在上传配置页面切换存储方式, so that 可以灵活选择存储策略。 + +#### Acceptance Criteria + +1. WHEN 管理员修改上传配置 THEN THE Upload_Service SHALL 立即使用新的存储策略 +2. THE Upload_Service SHALL 在每次上传时读取最新的配置 +3. IF 配置的存储类型无效 THEN THE Upload_Service SHALL 默认使用本地存储 + +### Requirement 5: 前端上传组件 + +**User Story:** As a 前端开发者, I want to 使用统一的图片上传组件, so that 可以快速在各业务模块中集成图片上传功能。 + +#### Acceptance Criteria + +1. THE ImageUpload_Component SHALL 支持点击上传和拖拽上传 +2. THE ImageUpload_Component SHALL 支持手动输入图片URL +3. THE ImageUpload_Component SHALL 显示上传进度 +4. THE ImageUpload_Component SHALL 支持图片预览和删除 +5. THE ImageUpload_Component SHALL 通过 v-model 双向绑定图片URL +6. WHEN 上传成功 THEN THE ImageUpload_Component SHALL 自动更新绑定的URL值 +7. WHEN 上传失败 THEN THE ImageUpload_Component SHALL 显示错误提示 + +### Requirement 6: 业务页面改造 + +**User Story:** As a 管理员, I want to 在各业务表单中直接上传图片, so that 不需要手动复制粘贴图片URL。 + +#### Acceptance Criteria + +1. THE System SHALL 将以下页面的图片输入改为使用 ImageUpload_Component: + - GoodsAddDialog.vue (imgUrl, imgUrlDetail) + - GoodsEditDialog.vue (imgUrl, imgUrlDetail) + - PrizeAddDialog.vue (imgUrl, imgUrlDetail) + - PrizeEditDialog.vue (imgUrl, imgUrlDetail) + - DiamondFormDialog.vue (normalImage, normalSelectImage, firstChargeImage, firstSelectChargeImage) + - FloatBallFormDialog.vue (image, imageBj, imageDetails) + - AdvertFormDialog.vue (imageUrl) + - SignConfigFormDialog.vue (icon) + - WelfareHouseFormDialog.vue (image) + - base.vue (erweima, share_image) +2. THE System SHALL 保持原有的表单验证逻辑不变 +3. THE System SHALL 兼容已有的图片URL数据 diff --git a/.kiro/specs/image-upload-feature/tasks.md b/.kiro/specs/image-upload-feature/tasks.md new file mode 100644 index 00000000..1b3cf0e2 --- /dev/null +++ b/.kiro/specs/image-upload-feature/tasks.md @@ -0,0 +1,175 @@ +# Implementation Plan: 图片上传功能 + +## Overview + +实现后台管理系统的图片上传功能,包括后端上传接口(支持本地存储和腾讯云COS)、前端上传组件优化、以及业务页面改造。 + +## Tasks + +- [x] 1. 后端基础设施搭建 + - [x] 1.1 创建上传相关的数据模型 + - 创建 `UploadResponse` 响应模型 + - 创建 `UploadResult` 内部结果模型 + - 位置: `HoneyBox.Admin.Business/Models/Upload/` + - _Requirements: 1.1_ + + - [x] 1.2 创建存储提供者接口 IStorageProvider + - 定义 `StorageType` 属性 + - 定义 `UploadAsync` 方法 + - 定义 `DeleteAsync` 方法 + - 位置: `HoneyBox.Admin.Business/Services/Interfaces/` + - _Requirements: 2.1, 3.1_ + + - [x] 1.3 创建上传服务接口 IUploadService + - 定义 `UploadImageAsync` 方法 + - 定义 `UploadImagesAsync` 方法 + - 位置: `HoneyBox.Admin.Business/Services/Interfaces/` + - _Requirements: 1.1_ + +- [x] 2. 本地存储实现 + - [x] 2.1 实现 LocalStorageProvider + - 实现文件保存到 wwwroot/uploads/{yyyy}/{MM}/{dd}/ + - 实现目录自动创建 + - 实现唯一文件名生成 + - 返回相对URL路径 + - 位置: `HoneyBox.Admin.Business/Services/Storage/` + - _Requirements: 2.1, 2.2, 2.3, 2.4, 1.5_ + + - [x] 2.2 编写 LocalStorageProvider 单元测试 + - 测试目录创建 + - 测试文件保存 + - 测试URL生成格式 + - **Property 4: 存储策略一致性 - 本地存储URL格式** + - **Validates: Requirements 2.3** + +- [x] 3. 腾讯云COS存储实现 + - [x] 3.1 添加腾讯云COS SDK依赖 + - 添加 `Tencent.QCloud.Cos.Sdk` NuGet包 + - _Requirements: 3.1_ + + - [x] 3.2 实现 TencentCosProvider + - 从配置读取COS参数 + - 实现文件上传到COS + - 实现按日期创建对象路径 + - 返回Domain拼接的完整URL + - 位置: `HoneyBox.Admin.Business/Services/Storage/` + - _Requirements: 3.1, 3.2, 3.3, 3.5_ + + - [x] 3.3 编写 TencentCosProvider 单元测试 + - 测试配置读取 + - 测试URL生成格式 + - 测试错误处理 + - **Property 4: 存储策略一致性 - COS URL格式** + - **Validates: Requirements 3.3, 3.4** + +- [x] 4. 上传服务实现 + - [x] 4.1 实现 UploadService + - 实现文件格式验证(jpg, jpeg, png, gif, webp) + - 实现文件大小验证(最大10MB) + - 实现根据配置选择存储提供者 + - 实现默认使用本地存储的降级逻辑 + - 位置: `HoneyBox.Admin.Business/Services/` + - _Requirements: 1.2, 1.3, 1.4, 4.1, 4.2, 4.3_ + + - [x] 4.2 编写 UploadService 单元测试 + - **Property 1: 文件格式验证** + - **Property 2: 文件大小验证** + - **Property 3: 文件名唯一性** + - **Validates: Requirements 1.2, 1.3, 1.4, 1.5** + +- [x] 5. 上传API控制器 + - [x] 5.1 创建 UploadController + - 实现 POST /api/admin/upload/image 单文件上传 + - 实现 POST /api/admin/upload/images 批量上传 + - 添加权限验证 + - 位置: `HoneyBox.Admin.Business/Controllers/` + - _Requirements: 1.1_ + + - [x] 5.2 注册服务到依赖注入容器 + - 注册 IUploadService + - 注册 IStorageProvider 实现 + - 位置: `HoneyBox.Admin.Business/Modules/` + - _Requirements: 4.1_ + +- [x] 6. Checkpoint - 后端功能验证 + - 确保所有后端测试通过 + - 使用Postman或Swagger测试上传接口 + - 验证本地存储和COS存储都能正常工作 + - 如有问题请询问用户 + +- [x] 7. 前端上传组件优化 + - [x] 7.1 优化 ImageUpload 组件 + - 添加文件类型前端校验 + - 添加文件大小前端校验 + - 优化上传失败的错误提示 + - 位置: `admin-web/src/components/ImageUpload/index.vue` + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_ + +- [x] 8. 业务页面改造 - 商品管理 + - [x] 8.1 改造 GoodsAddDialog.vue + - 将 imgUrl 输入改为 ImageUpload 组件 + - 将 imgUrlDetail 输入改为 ImageUpload 组件 + - 删除原有的 image-upload-wrapper 样式 + - _Requirements: 6.1, 6.2, 6.3_ + + - [x] 8.2 改造 GoodsEditDialog.vue + - 将 imgUrl 输入改为 ImageUpload 组件 + - 将 imgUrlDetail 输入改为 ImageUpload 组件 + - 删除原有的 image-upload-wrapper 样式 + - _Requirements: 6.1, 6.2, 6.3_ + + - [x] 8.3 改造 PrizeAddDialog.vue + - 将 imgUrl 输入改为 ImageUpload 组件 + - 将 imgUrlDetail 输入改为 ImageUpload 组件 + - _Requirements: 6.1, 6.2, 6.3_ + + - [x] 8.4 改造 PrizeEditDialog.vue + - 将 imgUrl 输入改为 ImageUpload 组件 + - 将 imgUrlDetail 输入改为 ImageUpload 组件 + - _Requirements: 6.1, 6.2, 6.3_ + +- [x] 9. 业务页面改造 - 钻石充值 + - [x] 9.1 改造 DiamondFormDialog.vue + - 将 normalImage 输入改为 ImageUpload 组件 + - 将 normalSelectImage 输入改为 ImageUpload 组件 + - 将 firstChargeImage 输入改为 ImageUpload 组件 + - 将 firstSelectChargeImage 输入改为 ImageUpload 组件 + - _Requirements: 6.1, 6.2, 6.3_ + +- [x] 10. 业务页面改造 - 内容辅助模块 + - [x] 10.1 改造 FloatBallFormDialog.vue + - 将 image 输入改为 ImageUpload 组件 + - 将 imageBj 输入改为 ImageUpload 组件 + - 将 imageDetails 输入改为 ImageUpload 组件 + - _Requirements: 6.1, 6.2, 6.3_ + + - [x] 10.2 改造 AdvertFormDialog.vue + - 将 imageUrl 输入改为 ImageUpload 组件 + - _Requirements: 6.1, 6.2, 6.3_ + + - [x] 10.3 改造 WelfareHouseFormDialog.vue + - 将 image 输入改为 ImageUpload 组件 + - _Requirements: 6.1, 6.2, 6.3_ + +- [x] 11. 业务页面改造 - 系统配置 + - [x] 11.1 改造 SignConfigFormDialog.vue + - 将 icon 输入改为 ImageUpload 组件 + - _Requirements: 6.1, 6.2, 6.3_ + + - [x] 11.2 改造 base.vue (基础配置) + - 将 erweima 输入改为 ImageUpload 组件 + - 将 share_image 输入改为 ImageUpload 组件 + - _Requirements: 6.1, 6.2, 6.3_ + +- [x] 12. Final Checkpoint - 完整功能验证 + - 确保所有改造的页面正常工作 + - 验证图片上传、预览、删除功能 + - 验证已有图片URL数据能正常显示 + - 如有问题请询问用户 + +## Notes + +- 所有任务均为必需,包括测试任务 +- 后端使用策略模式,方便后续扩展其他存储提供者(如阿里云OSS) +- 前端 ImageUpload 组件已创建基础版本,需要根据后端接口进行优化 +- 业务页面改造时需保持原有表单验证逻辑不变 diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Controllers/UploadController.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Controllers/UploadController.cs new file mode 100644 index 00000000..1d349067 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Controllers/UploadController.cs @@ -0,0 +1,61 @@ +using HoneyBox.Admin.Business.Attributes; +using HoneyBox.Admin.Business.Models; +using HoneyBox.Admin.Business.Services.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace HoneyBox.Admin.Business.Controllers; + +/// +/// 文件上传控制器 +/// +[Route("api/admin/upload")] +public class UploadController : BusinessControllerBase +{ + private readonly IUploadService _uploadService; + + public UploadController(IUploadService uploadService) + { + _uploadService = uploadService; + } + + /// + /// 上传单个图片 + /// + /// 图片文件 + /// 上传结果 + [HttpPost("image")] + [BusinessPermission("upload:image")] + public async Task UploadImage(IFormFile file) + { + try + { + var result = await _uploadService.UploadImageAsync(file); + return Ok(result, "上传成功"); + } + catch (BusinessException ex) + { + return Error(ex.Code, ex.Message); + } + } + + /// + /// 批量上传图片 + /// + /// 图片文件列表 + /// 上传结果列表 + [HttpPost("images")] + [BusinessPermission("upload:image")] + public async Task UploadImages(List files) + { + try + { + var results = await _uploadService.UploadImagesAsync(files); + return Ok(results, "上传成功"); + } + catch (BusinessException ex) + { + return Error(ex.Code, ex.Message); + } + } +} diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Extensions/ServiceCollectionExtensions.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Extensions/ServiceCollectionExtensions.cs index 88bfc7c5..f129f07c 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/Extensions/ServiceCollectionExtensions.cs +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,7 @@ using System.Reflection; +using HoneyBox.Admin.Business.Models.Config; +using HoneyBox.Admin.Business.Services.Interfaces; +using HoneyBox.Admin.Business.Services.Storage; using Microsoft.Extensions.DependencyInjection; namespace HoneyBox.Admin.Business.Extensions; @@ -23,9 +26,36 @@ public static class ServiceCollectionExtensions // 自动注册业务服务 RegisterBusinessServices(services, businessAssembly); + // 注册存储提供者 + RegisterStorageProviders(services); + return services; } + /// + /// 注册存储提供者 + /// + private static void RegisterStorageProviders(IServiceCollection services) + { + // 注册本地存储提供者 + services.AddScoped(); + + // 注册腾讯云COS存储提供者 + services.AddScoped(sp => + { + var logger = sp.GetRequiredService>(); + var configService = sp.GetRequiredService(); + + // 创建获取配置的委托 + Func getUploadSetting = () => + { + return configService.GetConfigAsync(ConfigKeys.Uploads).GetAwaiter().GetResult(); + }; + + return new TencentCosProvider(logger, getUploadSetting); + }); + } + /// /// 自动注册业务服务 /// 扫描程序集中所有实现了 I*Service 接口的服务类并注册 diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/HoneyBox.Admin.Business.csproj b/server/HoneyBox/src/HoneyBox.Admin.Business/HoneyBox.Admin.Business.csproj index 9ad81395..798dc440 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/HoneyBox.Admin.Business.csproj +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/HoneyBox.Admin.Business.csproj @@ -12,6 +12,9 @@ + + + @@ -20,6 +23,9 @@ + + + diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Models/BusinessException.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Models/BusinessException.cs index f63637bc..b63d98a0 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/Models/BusinessException.cs +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Models/BusinessException.cs @@ -71,4 +71,14 @@ public static class BusinessErrorCodes /// 服务器内部错误 /// public const int InternalError = 50001; + + /// + /// 操作失败 + /// + public const int OperationFailed = 50002; + + /// + /// 配置错误 + /// + public const int ConfigurationError = 50003; } diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IStorageProvider.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IStorageProvider.cs new file mode 100644 index 00000000..c8e7dcaf --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IStorageProvider.cs @@ -0,0 +1,32 @@ +using HoneyBox.Admin.Business.Models.Upload; + +namespace HoneyBox.Admin.Business.Services.Interfaces; + +/// +/// 存储提供者接口 +/// +public interface IStorageProvider +{ + /// + /// 存储类型标识 + /// "1" = 本地存储 + /// "3" = 腾讯云COS + /// + string StorageType { get; } + + /// + /// 上传文件 + /// + /// 文件流 + /// 文件名 + /// 内容类型 + /// 上传结果 + Task UploadAsync(Stream fileStream, string fileName, string contentType); + + /// + /// 删除文件 + /// + /// 文件URL + /// 是否成功 + Task DeleteAsync(string fileUrl); +} diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IUploadService.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IUploadService.cs new file mode 100644 index 00000000..e5b6cd77 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IUploadService.cs @@ -0,0 +1,24 @@ +using HoneyBox.Admin.Business.Models.Upload; +using Microsoft.AspNetCore.Http; + +namespace HoneyBox.Admin.Business.Services.Interfaces; + +/// +/// 上传服务接口 +/// +public interface IUploadService +{ + /// + /// 上传图片 + /// + /// 上传的文件 + /// 上传响应 + Task UploadImageAsync(IFormFile file); + + /// + /// 批量上传图片 + /// + /// 上传的文件列表 + /// 上传响应列表 + Task> UploadImagesAsync(List files); +} diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/LocalStorageProvider.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/LocalStorageProvider.cs new file mode 100644 index 00000000..e93e1460 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/LocalStorageProvider.cs @@ -0,0 +1,127 @@ +using HoneyBox.Admin.Business.Models.Upload; +using HoneyBox.Admin.Business.Services.Interfaces; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; + +namespace HoneyBox.Admin.Business.Services.Storage; + +/// +/// 本地存储提供者 +/// 将文件保存到 wwwroot/uploads/{yyyy}/{MM}/{dd}/ 目录 +/// +public class LocalStorageProvider : IStorageProvider +{ + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + private const string UploadBasePath = "uploads"; + + public LocalStorageProvider( + IWebHostEnvironment environment, + ILogger logger) + { + _environment = environment; + _logger = logger; + } + + /// + public string StorageType => "1"; + + /// + public async Task UploadAsync(Stream fileStream, string fileName, string contentType) + { + try + { + // 生成日期目录路径: uploads/2026/01/19/ + var now = DateTime.Now; + var datePath = Path.Combine( + now.Year.ToString(), + now.Month.ToString("D2"), + now.Day.ToString("D2")); + + // 生成唯一文件名 + var uniqueFileName = GenerateUniqueFileName(fileName); + + // 构建完整的物理路径 + var relativePath = Path.Combine(UploadBasePath, datePath); + var physicalPath = Path.Combine(_environment.WebRootPath, relativePath); + + // 确保目录存在 + EnsureDirectoryExists(physicalPath); + + // 完整文件路径 + var fullFilePath = Path.Combine(physicalPath, uniqueFileName); + + // 保存文件 + await using var fileStreamOutput = new FileStream(fullFilePath, FileMode.Create, FileAccess.Write); + await fileStream.CopyToAsync(fileStreamOutput); + + // 生成相对URL路径 (使用正斜杠) + var url = $"/{UploadBasePath}/{datePath.Replace(Path.DirectorySeparatorChar, '/')}/{uniqueFileName}"; + + _logger.LogInformation("本地存储上传成功: {FileName} -> {Url}", fileName, url); + + return UploadResult.Ok(url); + } + catch (Exception ex) + { + _logger.LogError(ex, "本地存储上传失败: {FileName}", fileName); + return UploadResult.Fail($"保存文件失败: {ex.Message}"); + } + } + + /// + public Task DeleteAsync(string fileUrl) + { + try + { + if (string.IsNullOrWhiteSpace(fileUrl)) + { + return Task.FromResult(false); + } + + // 将URL转换为物理路径 + // URL格式: /uploads/2026/01/19/xxx.jpg + var relativePath = fileUrl.TrimStart('/').Replace('/', Path.DirectorySeparatorChar); + var physicalPath = Path.Combine(_environment.WebRootPath, relativePath); + + if (File.Exists(physicalPath)) + { + File.Delete(physicalPath); + _logger.LogInformation("本地存储删除成功: {Url}", fileUrl); + return Task.FromResult(true); + } + + _logger.LogWarning("本地存储删除失败,文件不存在: {Url}", fileUrl); + return Task.FromResult(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "本地存储删除失败: {Url}", fileUrl); + return Task.FromResult(false); + } + } + + /// + /// 生成唯一文件名 + /// 格式: {timestamp}_{guid}{extension} + /// + private static string GenerateUniqueFileName(string originalFileName) + { + var extension = Path.GetExtension(originalFileName).ToLowerInvariant(); + var timestamp = DateTime.Now.ToString("yyyyMMddHHmmssfff"); + var guid = Guid.NewGuid().ToString("N")[..8]; // 取GUID前8位 + return $"{timestamp}_{guid}{extension}"; + } + + /// + /// 确保目录存在,不存在则创建 + /// + private void EnsureDirectoryExists(string path) + { + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + _logger.LogDebug("创建目录: {Path}", path); + } + } +} diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/TencentCosProvider.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/TencentCosProvider.cs new file mode 100644 index 00000000..ae7bc623 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/TencentCosProvider.cs @@ -0,0 +1,241 @@ +using COSXML; +using COSXML.Auth; +using COSXML.Model.Object; +using HoneyBox.Admin.Business.Models.Config; +using HoneyBox.Admin.Business.Models.Upload; +using HoneyBox.Admin.Business.Services.Interfaces; +using Microsoft.Extensions.Logging; + +namespace HoneyBox.Admin.Business.Services.Storage; + +/// +/// 腾讯云COS存储提供者 +/// 将文件上传到腾讯云COS对象存储 +/// +public class TencentCosProvider : IStorageProvider +{ + private readonly ILogger _logger; + private readonly Func _getUploadSetting; + private const string UploadBasePath = "uploads"; + + public TencentCosProvider( + ILogger logger, + Func getUploadSetting) + { + _logger = logger; + _getUploadSetting = getUploadSetting; + } + + /// + public string StorageType => "3"; + + /// + public async Task UploadAsync(Stream fileStream, string fileName, string contentType) + { + try + { + var setting = _getUploadSetting(); + if (setting == null) + { + return UploadResult.Fail("存储配置无效,请检查上传配置"); + } + + // 验证必要的配置参数 + var validationError = ValidateConfig(setting); + if (validationError != null) + { + return UploadResult.Fail(validationError); + } + + // 生成日期目录路径: uploads/2026/01/19/ + var now = DateTime.Now; + var datePath = $"{now.Year}/{now.Month:D2}/{now.Day:D2}"; + + // 生成唯一文件名 + var uniqueFileName = GenerateUniqueFileName(fileName); + + // 构建COS对象路径 + var objectKey = $"{UploadBasePath}/{datePath}/{uniqueFileName}"; + + // 创建COS客户端 + var cosXml = CreateCosXmlServer(setting); + + // 将流转换为字节数组 + byte[] fileBytes; + using (var memoryStream = new MemoryStream()) + { + await fileStream.CopyToAsync(memoryStream); + fileBytes = memoryStream.ToArray(); + } + + // 上传文件 + var putObjectRequest = new PutObjectRequest(setting.Bucket!, objectKey, fileBytes); + putObjectRequest.SetRequestHeader("Content-Type", contentType); + + var result = cosXml.PutObject(putObjectRequest); + + if (result.IsSuccessful()) + { + // 生成访问URL + var url = GenerateAccessUrl(setting.Domain!, objectKey); + _logger.LogInformation("腾讯云COS上传成功: {FileName} -> {Url}", fileName, url); + return UploadResult.Ok(url); + } + else + { + var errorMessage = $"上传到云存储失败: {result.httpMessage}"; + _logger.LogError("腾讯云COS上传失败: {FileName}, 错误: {Error}", fileName, errorMessage); + return UploadResult.Fail(errorMessage); + } + } + catch (COSXML.CosException.CosClientException clientEx) + { + var errorMessage = $"上传到云存储失败: 客户端错误 - {clientEx.Message}"; + _logger.LogError(clientEx, "腾讯云COS客户端错误: {FileName}", fileName); + return UploadResult.Fail(errorMessage); + } + catch (COSXML.CosException.CosServerException serverEx) + { + var errorMessage = $"上传到云存储失败: 服务端错误 - {serverEx.GetInfo()}"; + _logger.LogError(serverEx, "腾讯云COS服务端错误: {FileName}", fileName); + return UploadResult.Fail(errorMessage); + } + catch (Exception ex) + { + var errorMessage = $"上传到云存储失败: {ex.Message}"; + _logger.LogError(ex, "腾讯云COS上传异常: {FileName}", fileName); + return UploadResult.Fail(errorMessage); + } + } + + /// + public Task DeleteAsync(string fileUrl) + { + try + { + if (string.IsNullOrWhiteSpace(fileUrl)) + { + return Task.FromResult(false); + } + + var setting = _getUploadSetting(); + if (setting == null) + { + _logger.LogWarning("腾讯云COS删除失败,配置无效"); + return Task.FromResult(false); + } + + // 从URL中提取对象路径 + var objectKey = ExtractObjectKeyFromUrl(fileUrl, setting.Domain); + if (string.IsNullOrEmpty(objectKey)) + { + _logger.LogWarning("腾讯云COS删除失败,无法解析对象路径: {Url}", fileUrl); + return Task.FromResult(false); + } + + var cosXml = CreateCosXmlServer(setting); + var deleteObjectRequest = new DeleteObjectRequest(setting.Bucket!, objectKey); + var result = cosXml.DeleteObject(deleteObjectRequest); + + if (result.IsSuccessful()) + { + _logger.LogInformation("腾讯云COS删除成功: {Url}", fileUrl); + return Task.FromResult(true); + } + + _logger.LogWarning("腾讯云COS删除失败: {Url}", fileUrl); + return Task.FromResult(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "腾讯云COS删除异常: {Url}", fileUrl); + return Task.FromResult(false); + } + } + + /// + /// 验证配置参数 + /// + private static string? ValidateConfig(UploadSetting setting) + { + if (string.IsNullOrWhiteSpace(setting.Bucket)) + return "存储配置无效: Bucket不能为空"; + if (string.IsNullOrWhiteSpace(setting.Region)) + return "存储配置无效: Region不能为空"; + if (string.IsNullOrWhiteSpace(setting.AccessKeyId)) + return "存储配置无效: AccessKeyId不能为空"; + if (string.IsNullOrWhiteSpace(setting.AccessKeySecret)) + return "存储配置无效: AccessKeySecret不能为空"; + if (string.IsNullOrWhiteSpace(setting.Domain)) + return "存储配置无效: Domain不能为空"; + return null; + } + + /// + /// 创建COS客户端 + /// + private static CosXml CreateCosXmlServer(UploadSetting setting) + { + var config = new CosXmlConfig.Builder() + .IsHttps(true) + .SetRegion(setting.Region!) + .Build(); + + var credentialProvider = new DefaultQCloudCredentialProvider( + setting.AccessKeyId!, + setting.AccessKeySecret!, + 600); // 临时密钥有效期600秒 + + return new CosXmlServer(config, credentialProvider); + } + + /// + /// 生成唯一文件名 + /// 格式: {timestamp}_{guid}{extension} + /// + public static string GenerateUniqueFileName(string originalFileName) + { + var extension = Path.GetExtension(originalFileName).ToLowerInvariant(); + var timestamp = DateTime.Now.ToString("yyyyMMddHHmmssfff"); + var guid = Guid.NewGuid().ToString("N")[..8]; // 取GUID前8位 + return $"{timestamp}_{guid}{extension}"; + } + + /// + /// 生成访问URL + /// + public static string GenerateAccessUrl(string domain, string objectKey) + { + // 确保domain以https://开头,不以/结尾 + var normalizedDomain = domain.TrimEnd('/'); + if (!normalizedDomain.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !normalizedDomain.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + normalizedDomain = $"https://{normalizedDomain}"; + } + + // 确保objectKey以/开头 + var normalizedKey = objectKey.StartsWith('/') ? objectKey : $"/{objectKey}"; + + return $"{normalizedDomain}{normalizedKey}"; + } + + /// + /// 从URL中提取对象路径 + /// + private static string? ExtractObjectKeyFromUrl(string fileUrl, string? domain) + { + if (string.IsNullOrWhiteSpace(domain)) + return null; + + try + { + var uri = new Uri(fileUrl); + return uri.AbsolutePath.TrimStart('/'); + } + catch + { + return null; + } + } +} diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/UploadService.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/UploadService.cs new file mode 100644 index 00000000..b487ce88 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/UploadService.cs @@ -0,0 +1,206 @@ +using HoneyBox.Admin.Business.Models; +using HoneyBox.Admin.Business.Models.Config; +using HoneyBox.Admin.Business.Models.Upload; +using HoneyBox.Admin.Business.Services.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace HoneyBox.Admin.Business.Services; + +/// +/// 上传服务实现 +/// 负责文件验证、存储提供者选择和文件上传 +/// +public class UploadService : IUploadService +{ + private readonly IAdminConfigService _configService; + private readonly IEnumerable _storageProviders; + private readonly ILogger _logger; + + /// + /// 允许的图片格式 + /// + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".gif", ".webp" + }; + + /// + /// 允许的MIME类型 + /// + private static readonly HashSet AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase) + { + "image/jpeg", "image/png", "image/gif", "image/webp" + }; + + /// + /// 最大文件大小 (10MB) + /// + private const long MaxFileSize = 10 * 1024 * 1024; + + /// + /// 默认存储类型 (本地存储) + /// + private const string DefaultStorageType = "1"; + + public UploadService( + IAdminConfigService configService, + IEnumerable storageProviders, + ILogger logger) + { + _configService = configService; + _storageProviders = storageProviders; + _logger = logger; + } + + /// + public async Task UploadImageAsync(IFormFile file) + { + // 验证文件 + var validationError = ValidateFile(file); + if (validationError != null) + { + throw new BusinessException(BusinessErrorCodes.ValidationFailed, validationError); + } + + // 获取存储提供者 + var provider = await GetStorageProviderAsync(); + + // 上传文件 + await using var stream = file.OpenReadStream(); + var result = await provider.UploadAsync(stream, file.FileName, file.ContentType); + + if (!result.Success) + { + throw new BusinessException(BusinessErrorCodes.OperationFailed, result.ErrorMessage ?? "上传失败"); + } + + return new UploadResponse + { + Url = result.Url!, + FileName = file.FileName, + FileSize = file.Length + }; + } + + /// + public async Task> UploadImagesAsync(List files) + { + if (files == null || files.Count == 0) + { + throw new BusinessException(BusinessErrorCodes.ValidationFailed, "请选择要上传的文件"); + } + + var results = new List(); + foreach (var file in files) + { + var response = await UploadImageAsync(file); + results.Add(response); + } + + return results; + } + + /// + /// 验证文件 + /// + /// 上传的文件 + /// 错误信息,null表示验证通过 + public static string? ValidateFile(IFormFile? file) + { + // 检查文件是否为空 + if (file == null || file.Length == 0) + { + return "请选择要上传的文件"; + } + + // 检查文件大小 + if (file.Length > MaxFileSize) + { + return "文件大小不能超过10MB"; + } + + // 检查文件扩展名 + var extension = Path.GetExtension(file.FileName); + if (string.IsNullOrEmpty(extension) || !AllowedExtensions.Contains(extension)) + { + return "只支持 jpg、jpeg、png、gif、webp 格式的图片"; + } + + // 检查MIME类型 + if (!string.IsNullOrEmpty(file.ContentType) && !AllowedMimeTypes.Contains(file.ContentType)) + { + return "只支持 jpg、jpeg、png、gif、webp 格式的图片"; + } + + return null; + } + + /// + /// 检查文件扩展名是否有效 + /// + /// 文件扩展名(包含点号) + /// 是否有效 + public static bool IsValidExtension(string? extension) + { + if (string.IsNullOrEmpty(extension)) + { + return false; + } + return AllowedExtensions.Contains(extension); + } + + /// + /// 检查文件大小是否有效 + /// + /// 文件大小(字节) + /// 是否有效 + public static bool IsValidFileSize(long fileSize) + { + return fileSize > 0 && fileSize <= MaxFileSize; + } + + /// + /// 生成唯一文件名 + /// 格式: {timestamp}_{guid}{extension} + /// + /// 原始文件名 + /// 唯一文件名 + public static string GenerateUniqueFileName(string originalFileName) + { + var extension = Path.GetExtension(originalFileName).ToLowerInvariant(); + var timestamp = DateTime.Now.ToString("yyyyMMddHHmmssfff"); + var guid = Guid.NewGuid().ToString("N")[..8]; // 取GUID前8位 + return $"{timestamp}_{guid}{extension}"; + } + + /// + /// 获取存储提供者 + /// 根据配置选择存储提供者,如果配置无效则使用本地存储 + /// + private async Task GetStorageProviderAsync() + { + // 获取上传配置 + var uploadSetting = await _configService.GetConfigAsync(ConfigKeys.Uploads); + var storageType = uploadSetting?.Type ?? DefaultStorageType; + + // 查找对应的存储提供者 + var provider = _storageProviders.FirstOrDefault(p => p.StorageType == storageType); + + // 如果找不到对应的提供者,使用本地存储作为降级 + if (provider == null) + { + _logger.LogWarning("未找到存储类型 {StorageType} 的提供者,使用本地存储", storageType); + provider = _storageProviders.FirstOrDefault(p => p.StorageType == DefaultStorageType); + } + + // 如果连本地存储都没有,抛出异常 + if (provider == null) + { + throw new BusinessException(BusinessErrorCodes.ConfigurationError, "存储配置无效,请检查上传配置"); + } + + _logger.LogDebug("使用存储提供者: {StorageType}", provider.StorageType); + return provider; + } +} diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/upload.ts b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/upload.ts new file mode 100644 index 00000000..d140bab3 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/upload.ts @@ -0,0 +1,71 @@ +import { request, type ApiResponse } from '@/utils/request' + +/** 上传响应 */ +export interface UploadResponse { + /** 文件URL */ + url: string + /** 文件名 */ + fileName: string + /** 文件大小 */ + fileSize: number +} + +/** + * 上传文件 + * @param file 文件对象 + * @param onProgress 上传进度回调 + * @returns 上传结果 + */ +export function uploadFile( + file: File, + onProgress?: (percent: number) => void +): Promise> { + const formData = new FormData() + formData.append('file', file) + + return request({ + url: '/admin/upload/image', + method: 'POST', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data' + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total && onProgress) { + const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total) + onProgress(percent) + } + } + }) +} + +/** + * 批量上传文件 + * @param files 文件列表 + * @param onProgress 上传进度回调 + * @returns 上传结果列表 + */ +export function uploadFiles( + files: File[], + onProgress?: (percent: number) => void +): Promise> { + const formData = new FormData() + files.forEach((file) => { + formData.append('files', file) + }) + + return request({ + url: '/admin/upload/images', + method: 'POST', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data' + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total && onProgress) { + const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total) + onProgress(percent) + } + } + }) +} diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/ImageUpload/README.md b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/ImageUpload/README.md new file mode 100644 index 00000000..edac5715 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/ImageUpload/README.md @@ -0,0 +1,142 @@ +# ImageUpload 图片上传组件 + +通用图片上传组件,支持点击上传、拖拽上传、URL输入三种方式。 + +## 功能特性 + +- ✅ 支持点击上传和拖拽上传 +- ✅ 支持手动输入图片URL +- ✅ 显示上传进度 +- ✅ 支持图片预览和删除 +- ✅ 通过 v-model 双向绑定图片URL +- ✅ 上传成功自动更新绑定的URL值 +- ✅ 上传失败显示详细错误提示 +- ✅ 前端文件类型校验(jpg、jpeg、png、gif、webp) +- ✅ 前端文件大小校验(默认10MB) + +## 基本用法 + +```vue + + + + + + + +``` + +## Props + +| 属性 | 说明 | 类型 | 默认值 | +|------|------|------|--------| +| modelValue | 图片URL (v-model) | string | '' | +| disabled | 是否禁用 | boolean | false | +| placeholder | 上传区域占位文字 | string | '点击或拖拽上传' | +| urlPlaceholder | URL输入框占位文字 | string | '或输入图片URL' | +| showUrlInput | 是否显示URL输入框 | boolean | true | +| accept | 接受的文件类型 | string | 'image/jpeg,image/png,image/gif,image/webp' | +| maxSize | 最大文件大小(MB) | number | 10 | +| tip | 提示文字 | string | '' | + +## Events + +| 事件名 | 说明 | 回调参数 | +|--------|------|----------| +| update:modelValue | 图片URL变化时触发 | (url: string) | +| change | 图片变化时触发 | (url: string) | +| upload-success | 上传成功时触发 | (response: UploadResponse) | +| upload-error | 上传失败时触发 | (error: any) | + +## 文件校验 + +### 支持的图片格式 +- jpg / jpeg +- png +- gif +- webp + +### 文件大小限制 +- 默认最大 10MB +- 可通过 `maxSize` 属性自定义 + +### 错误提示 +组件会在以下情况显示错误提示: +- 文件格式不支持:显示"只支持 jpg、jpeg、png、gif、webp 格式的图片" +- 文件大小超限:显示"文件大小不能超过 10MB,当前文件大小为 X.XXMB" +- 网络错误:显示"网络错误,请检查网络连接" +- 上传超时:显示"上传超时,请检查网络后重试" +- 后端错误:显示后端返回的具体错误信息 + +## 使用示例 + +### 基础用法 +```vue + +``` + +### 隐藏URL输入框 +```vue + +``` + +### 自定义提示 +```vue + +``` + +### 限制文件大小 +```vue + +``` + +### 禁用状态 +```vue + +``` + +## 改造指南 + +将现有的图片输入改为使用 ImageUpload 组件: + +### 改造前 +```vue + + + + + + +``` + +### 改造后 +```vue + + + +``` + +## 需要改造的页面清单 + +| 模块 | 文件 | 图片字段 | +|------|------|----------| +| 商品管理 | GoodsAddDialog.vue | imgUrl, imgUrlDetail | +| 商品管理 | GoodsEditDialog.vue | imgUrl, imgUrlDetail | +| 奖品管理 | PrizeAddDialog.vue | imgUrl, imgUrlDetail | +| 奖品管理 | PrizeEditDialog.vue | imgUrl, imgUrlDetail | +| 钻石充值 | DiamondFormDialog.vue | normalImage, normalSelectImage, firstChargeImage, firstSelectChargeImage | +| 悬浮球 | FloatBallFormDialog.vue | image, imageBj, imageDetails | +| 广告管理 | AdvertFormDialog.vue | imageUrl | +| 签到配置 | SignConfigFormDialog.vue | icon | +| 福利屋 | WelfareHouseFormDialog.vue | image | +| 基础配置 | base.vue | erweima, share_image | diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/ImageUpload/index.vue b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/ImageUpload/index.vue new file mode 100644 index 00000000..1aa1b1ac --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/ImageUpload/index.vue @@ -0,0 +1,454 @@ + + + + + + + + + + + 加载失败 + + + + + + + + + + + + + + + + {{ uploading ? '上传中...' : placeholder }} + + + + + + + + + {{ errorMessage }} + + + + + + + + 确定 + + + + + + + {{ tip }} + + + + + + diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/index.ts b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/index.ts new file mode 100644 index 00000000..62683415 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/index.ts @@ -0,0 +1,22 @@ +// 公共组件导出 +import type { App } from 'vue' +import ImageUpload from './ImageUpload/index.vue' + +// 组件列表 +const components = { + ImageUpload +} + +// 全局注册 +export function registerComponents(app: App) { + Object.entries(components).forEach(([name, component]) => { + app.component(name, component) + }) +} + +// 按需导出 +export { ImageUpload } + +export default { + install: registerComponents +} diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/main.ts b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/main.ts index eb112b5a..fe050851 100644 --- a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/main.ts +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/main.ts @@ -8,6 +8,7 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn' import App from './App.vue' import router from './router' import permission from './directives/permission' +import { registerComponents } from './components' import './styles/index.css' @@ -21,6 +22,9 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) { // 注册权限指令 app.directive('permission', permission) +// 注册公共组件 +registerComponents(app) + app.use(createPinia()) app.use(router) app.use(ElementPlus, { locale: zhCn }) diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/advert/components/AdvertFormDialog.vue b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/advert/components/AdvertFormDialog.vue index bee64c2a..c6886bb7 100644 --- a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/advert/components/AdvertFormDialog.vue +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/advert/components/AdvertFormDialog.vue @@ -78,28 +78,12 @@ - - - - - - - - - - - + @@ -127,7 +111,7 @@ diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/floatball/components/FloatBallFormDialog.vue b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/floatball/components/FloatBallFormDialog.vue index e778384a..e2f8df67 100644 --- a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/floatball/components/FloatBallFormDialog.vue +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/floatball/components/FloatBallFormDialog.vue @@ -28,75 +28,28 @@ - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + @@ -231,7 +184,7 @@ diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/LocalStorageProviderTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/LocalStorageProviderTests.cs new file mode 100644 index 00000000..d6dc8f1d --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/LocalStorageProviderTests.cs @@ -0,0 +1,451 @@ +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Admin.Business.Services.Storage; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// LocalStorageProvider 单元测试 +/// +public class LocalStorageProviderTests : IDisposable +{ + private readonly Mock _mockEnvironment; + private readonly Mock> _mockLogger; + private readonly string _testWebRootPath; + private readonly LocalStorageProvider _provider; + + public LocalStorageProviderTests() + { + _mockEnvironment = new Mock(); + _mockLogger = new Mock>(); + + // 创建临时测试目录 + _testWebRootPath = Path.Combine(Path.GetTempPath(), $"LocalStorageTest_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testWebRootPath); + + _mockEnvironment.Setup(e => e.WebRootPath).Returns(_testWebRootPath); + + _provider = new LocalStorageProvider(_mockEnvironment.Object, _mockLogger.Object); + } + + public void Dispose() + { + // 清理测试目录 + if (Directory.Exists(_testWebRootPath)) + { + try + { + Directory.Delete(_testWebRootPath, true); + } + catch + { + // 忽略清理错误 + } + } + } + + #region StorageType Tests + + [Fact] + public void StorageType_ShouldReturn1() + { + // Assert + Assert.Equal("1", _provider.StorageType); + } + + #endregion + + #region Directory Creation Tests + + [Fact] + public async Task UploadAsync_ShouldCreateDateBasedDirectory() + { + // Arrange + var content = "test content"u8.ToArray(); + using var stream = new MemoryStream(content); + var fileName = "test.jpg"; + + // Act + var result = await _provider.UploadAsync(stream, fileName, "image/jpeg"); + + // Assert + Assert.True(result.Success); + + // 验证目录结构 + var now = DateTime.Now; + var expectedDir = Path.Combine(_testWebRootPath, "uploads", + now.Year.ToString(), + now.Month.ToString("D2"), + now.Day.ToString("D2")); + Assert.True(Directory.Exists(expectedDir)); + } + + [Fact] + public async Task UploadAsync_ShouldCreateNestedDirectories() + { + // Arrange - 确保目录不存在 + var uploadsDir = Path.Combine(_testWebRootPath, "uploads"); + if (Directory.Exists(uploadsDir)) + { + Directory.Delete(uploadsDir, true); + } + + var content = "test content"u8.ToArray(); + using var stream = new MemoryStream(content); + var fileName = "test.png"; + + // Act + var result = await _provider.UploadAsync(stream, fileName, "image/png"); + + // Assert + Assert.True(result.Success); + Assert.True(Directory.Exists(uploadsDir)); + } + + #endregion + + #region File Save Tests + + [Fact] + public async Task UploadAsync_ShouldSaveFileContent() + { + // Arrange + var expectedContent = "test file content for verification"u8.ToArray(); + using var stream = new MemoryStream(expectedContent); + var fileName = "content_test.jpg"; + + // Act + var result = await _provider.UploadAsync(stream, fileName, "image/jpeg"); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Url); + + // 验证文件内容 + var physicalPath = Path.Combine(_testWebRootPath, result.Url!.TrimStart('/').Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(physicalPath)); + + var savedContent = await File.ReadAllBytesAsync(physicalPath); + Assert.Equal(expectedContent, savedContent); + } + + [Fact] + public async Task UploadAsync_ShouldPreserveFileExtension() + { + // Arrange + var content = "test"u8.ToArray(); + using var stream = new MemoryStream(content); + var fileName = "image.webp"; + + // Act + var result = await _provider.UploadAsync(stream, fileName, "image/webp"); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Url); + Assert.EndsWith(".webp", result.Url); + } + + [Theory] + [InlineData("test.jpg", ".jpg")] + [InlineData("test.JPEG", ".jpeg")] + [InlineData("test.PNG", ".png")] + [InlineData("test.gif", ".gif")] + [InlineData("test.WebP", ".webp")] + public async Task UploadAsync_ShouldNormalizeExtensionToLowercase(string fileName, string expectedExtension) + { + // Arrange + var content = "test"u8.ToArray(); + using var stream = new MemoryStream(content); + + // Act + var result = await _provider.UploadAsync(stream, fileName, "image/jpeg"); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Url); + Assert.EndsWith(expectedExtension, result.Url); + } + + #endregion + + #region URL Format Tests + + [Fact] + public async Task UploadAsync_UrlShouldStartWithUploads() + { + // Arrange + var content = "test"u8.ToArray(); + using var stream = new MemoryStream(content); + var fileName = "test.jpg"; + + // Act + var result = await _provider.UploadAsync(stream, fileName, "image/jpeg"); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Url); + Assert.StartsWith("/uploads/", result.Url); + } + + [Fact] + public async Task UploadAsync_UrlShouldContainDatePath() + { + // Arrange + var content = "test"u8.ToArray(); + using var stream = new MemoryStream(content); + var fileName = "test.jpg"; + var now = DateTime.Now; + + // Act + var result = await _provider.UploadAsync(stream, fileName, "image/jpeg"); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Url); + + var expectedDatePath = $"/{now.Year}/{now.Month:D2}/{now.Day:D2}/"; + Assert.Contains(expectedDatePath, result.Url); + } + + [Fact] + public async Task UploadAsync_UrlShouldUseForwardSlashes() + { + // Arrange + var content = "test"u8.ToArray(); + using var stream = new MemoryStream(content); + var fileName = "test.jpg"; + + // Act + var result = await _provider.UploadAsync(stream, fileName, "image/jpeg"); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Url); + Assert.DoesNotContain("\\", result.Url); + } + + #endregion + + #region Delete Tests + + [Fact] + public async Task DeleteAsync_ShouldDeleteExistingFile() + { + // Arrange - 先上传一个文件 + var content = "test"u8.ToArray(); + using var stream = new MemoryStream(content); + var uploadResult = await _provider.UploadAsync(stream, "to_delete.jpg", "image/jpeg"); + Assert.True(uploadResult.Success); + + // Act + var deleteResult = await _provider.DeleteAsync(uploadResult.Url!); + + // Assert + Assert.True(deleteResult); + + var physicalPath = Path.Combine(_testWebRootPath, uploadResult.Url!.TrimStart('/').Replace('/', Path.DirectorySeparatorChar)); + Assert.False(File.Exists(physicalPath)); + } + + [Fact] + public async Task DeleteAsync_ShouldReturnFalseForNonExistentFile() + { + // Arrange + var nonExistentUrl = "/uploads/2026/01/19/nonexistent.jpg"; + + // Act + var result = await _provider.DeleteAsync(nonExistentUrl); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task DeleteAsync_ShouldReturnFalseForEmptyUrl() + { + // Act + var result = await _provider.DeleteAsync(""); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task DeleteAsync_ShouldReturnFalseForNullUrl() + { + // Act + var result = await _provider.DeleteAsync(null!); + + // Assert + Assert.False(result); + } + + #endregion +} + +/// +/// LocalStorageProvider 属性测试 +/// **Property 4: 存储策略一致性 - 本地存储URL格式** +/// **Validates: Requirements 2.3** +/// +public class LocalStorageProviderPropertyTests : IDisposable +{ + private readonly string _testWebRootPath; + + public LocalStorageProviderPropertyTests() + { + _testWebRootPath = Path.Combine(Path.GetTempPath(), $"LocalStoragePropertyTest_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testWebRootPath); + } + + public void Dispose() + { + if (Directory.Exists(_testWebRootPath)) + { + try + { + Directory.Delete(_testWebRootPath, true); + } + catch + { + // 忽略清理错误 + } + } + } + + /// + /// **Feature: image-upload-feature, Property 4: 存储策略一致性 - 本地存储URL格式** + /// *For any* 上传操作,返回的URL格式 SHALL 与本地存储类型一致:URL以 `/uploads/` 开头 + /// **Validates: Requirements 2.3** + /// + [Property(MaxTest = 100)] + public bool LocalStorage_UrlFormat_ShouldStartWithUploads(PositiveInt seed) + { + var mockEnvironment = new Mock(); + var mockLogger = new Mock>(); + + var testDir = Path.Combine(_testWebRootPath, $"test_{seed.Get}"); + Directory.CreateDirectory(testDir); + mockEnvironment.Setup(e => e.WebRootPath).Returns(testDir); + + var provider = new LocalStorageProvider(mockEnvironment.Object, mockLogger.Object); + + var extensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; + var extension = extensions[seed.Get % extensions.Length]; + var fileName = $"test{extension}"; + + var content = System.Text.Encoding.UTF8.GetBytes($"test content {seed.Get}"); + using var stream = new MemoryStream(content); + + var result = provider.UploadAsync(stream, fileName, "image/jpeg").GetAwaiter().GetResult(); + + if (!result.Success || result.Url == null) + return false; + + // 验证URL以 /uploads/ 开头 + return result.Url.StartsWith("/uploads/"); + } + + /// + /// **Feature: image-upload-feature, Property 4: 存储策略一致性 - 本地存储URL格式** + /// *For any* 上传操作,返回的URL SHALL 包含日期路径格式 yyyy/MM/dd + /// **Validates: Requirements 2.2, 2.3** + /// + [Property(MaxTest = 100)] + public bool LocalStorage_UrlFormat_ShouldContainDatePath(PositiveInt seed) + { + var mockEnvironment = new Mock(); + var mockLogger = new Mock>(); + + var testDir = Path.Combine(_testWebRootPath, $"date_test_{seed.Get}"); + Directory.CreateDirectory(testDir); + mockEnvironment.Setup(e => e.WebRootPath).Returns(testDir); + + var provider = new LocalStorageProvider(mockEnvironment.Object, mockLogger.Object); + + var fileName = $"test_{seed.Get}.jpg"; + var content = System.Text.Encoding.UTF8.GetBytes($"content {seed.Get}"); + using var stream = new MemoryStream(content); + + var result = provider.UploadAsync(stream, fileName, "image/jpeg").GetAwaiter().GetResult(); + + if (!result.Success || result.Url == null) + return false; + + var now = DateTime.Now; + var expectedDatePath = $"/{now.Year}/{now.Month:D2}/{now.Day:D2}/"; + + return result.Url.Contains(expectedDatePath); + } + + /// + /// **Feature: image-upload-feature, Property 3: 文件名唯一性** + /// *For any* 两次上传操作,即使上传相同的文件,生成的文件名 SHALL 不同 + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 50)] + public bool LocalStorage_FileNames_ShouldBeUnique(PositiveInt seed) + { + var mockEnvironment = new Mock(); + var mockLogger = new Mock>(); + + var testDir = Path.Combine(_testWebRootPath, $"unique_test_{seed.Get}"); + Directory.CreateDirectory(testDir); + mockEnvironment.Setup(e => e.WebRootPath).Returns(testDir); + + var provider = new LocalStorageProvider(mockEnvironment.Object, mockLogger.Object); + + var fileName = "same_file.jpg"; + var content = "same content"u8.ToArray(); + + // 第一次上传 + using var stream1 = new MemoryStream(content); + var result1 = provider.UploadAsync(stream1, fileName, "image/jpeg").GetAwaiter().GetResult(); + + // 第二次上传(相同文件名) + using var stream2 = new MemoryStream(content); + var result2 = provider.UploadAsync(stream2, fileName, "image/jpeg").GetAwaiter().GetResult(); + + if (!result1.Success || !result2.Success) + return false; + + // 验证两次上传的URL不同 + return result1.Url != result2.Url; + } + + /// + /// **Feature: image-upload-feature, Property 4: 存储策略一致性** + /// *For any* 成功上传的文件,URL对应的物理文件 SHALL 存在 + /// **Validates: Requirements 2.3** + /// + [Property(MaxTest = 50)] + public bool LocalStorage_UploadedFile_ShouldExistOnDisk(PositiveInt seed) + { + var mockEnvironment = new Mock(); + var mockLogger = new Mock>(); + + var testDir = Path.Combine(_testWebRootPath, $"exist_test_{seed.Get}"); + Directory.CreateDirectory(testDir); + mockEnvironment.Setup(e => e.WebRootPath).Returns(testDir); + + var provider = new LocalStorageProvider(mockEnvironment.Object, mockLogger.Object); + + var fileName = $"file_{seed.Get}.jpg"; + var content = System.Text.Encoding.UTF8.GetBytes($"content for {seed.Get}"); + using var stream = new MemoryStream(content); + + var result = provider.UploadAsync(stream, fileName, "image/jpeg").GetAwaiter().GetResult(); + + if (!result.Success || result.Url == null) + return false; + + // 验证物理文件存在 + var physicalPath = Path.Combine(testDir, result.Url.TrimStart('/').Replace('/', Path.DirectorySeparatorChar)); + return File.Exists(physicalPath); + } +} diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/TencentCosProviderTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/TencentCosProviderTests.cs new file mode 100644 index 00000000..22f0e888 --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/TencentCosProviderTests.cs @@ -0,0 +1,305 @@ +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Admin.Business.Models.Config; +using HoneyBox.Admin.Business.Services.Storage; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// TencentCosProvider 单元测试 +/// 注意: 由于COS SDK依赖,UploadAsync和DeleteAsync方法的测试需要在集成测试中进行 +/// 这里只测试静态辅助方法和URL生成逻辑 +/// +public class TencentCosProviderTests +{ + #region URL Generation Tests + + [Theory] + [InlineData("https://cdn.example.com", "uploads/2026/01/19/test.jpg", "https://cdn.example.com/uploads/2026/01/19/test.jpg")] + [InlineData("https://cdn.example.com/", "uploads/2026/01/19/test.jpg", "https://cdn.example.com/uploads/2026/01/19/test.jpg")] + [InlineData("cdn.example.com", "uploads/2026/01/19/test.jpg", "https://cdn.example.com/uploads/2026/01/19/test.jpg")] + [InlineData("http://cdn.example.com", "uploads/2026/01/19/test.jpg", "http://cdn.example.com/uploads/2026/01/19/test.jpg")] + [InlineData("https://cdn.example.com", "/uploads/2026/01/19/test.jpg", "https://cdn.example.com/uploads/2026/01/19/test.jpg")] + public void GenerateAccessUrl_ShouldFormatCorrectly(string domain, string objectKey, string expectedUrl) + { + // Act + var result = TencentCosProvider.GenerateAccessUrl(domain, objectKey); + + // Assert + Assert.Equal(expectedUrl, result); + } + + [Fact] + public void GenerateUniqueFileName_ShouldPreserveExtension() + { + // Arrange + var originalFileName = "test.jpg"; + + // Act + var result = TencentCosProvider.GenerateUniqueFileName(originalFileName); + + // Assert + Assert.EndsWith(".jpg", result); + } + + [Theory] + [InlineData("test.JPG", ".jpg")] + [InlineData("test.JPEG", ".jpeg")] + [InlineData("test.PNG", ".png")] + [InlineData("test.GIF", ".gif")] + [InlineData("test.WebP", ".webp")] + public void GenerateUniqueFileName_ShouldNormalizeExtensionToLowercase(string fileName, string expectedExtension) + { + // Act + var result = TencentCosProvider.GenerateUniqueFileName(fileName); + + // Assert + Assert.EndsWith(expectedExtension, result); + } + + [Fact] + public void GenerateUniqueFileName_ShouldGenerateDifferentNamesForSameFile() + { + // Arrange + var fileName = "same_file.jpg"; + + // Act + var result1 = TencentCosProvider.GenerateUniqueFileName(fileName); + Thread.Sleep(1); // 确保时间戳不同 + var result2 = TencentCosProvider.GenerateUniqueFileName(fileName); + + // Assert + Assert.NotEqual(result1, result2); + } + + [Fact] + public void GenerateUniqueFileName_ShouldContainTimestamp() + { + // Arrange + var fileName = "test.jpg"; + var now = DateTime.Now; + + // Act + var result = TencentCosProvider.GenerateUniqueFileName(fileName); + + // Assert + // 文件名格式: {timestamp}_{guid}.jpg + // timestamp格式: yyyyMMddHHmmssfff + Assert.Contains(now.ToString("yyyyMMdd"), result); + } + + [Fact] + public void GenerateUniqueFileName_ShouldContainGuidPart() + { + // Arrange + var fileName = "test.jpg"; + + // Act + var result = TencentCosProvider.GenerateUniqueFileName(fileName); + + // Assert + // 文件名格式: {timestamp}_{guid}.jpg + // 应该包含下划线分隔符 + Assert.Contains("_", result); + // 下划线后应该有8个字符(GUID前8位)+ 扩展名 + var parts = result.Split('_'); + Assert.Equal(2, parts.Length); + } + + [Fact] + public void GenerateAccessUrl_ShouldHandleDomainWithoutProtocol() + { + // Arrange + var domain = "cdn.example.com"; + var objectKey = "uploads/test.jpg"; + + // Act + var result = TencentCosProvider.GenerateAccessUrl(domain, objectKey); + + // Assert + Assert.StartsWith("https://", result); + } + + [Fact] + public void GenerateAccessUrl_ShouldPreserveHttpProtocol() + { + // Arrange + var domain = "http://cdn.example.com"; + var objectKey = "uploads/test.jpg"; + + // Act + var result = TencentCosProvider.GenerateAccessUrl(domain, objectKey); + + // Assert + Assert.StartsWith("http://", result); + Assert.DoesNotContain("https://", result); + } + + [Fact] + public void GenerateAccessUrl_ShouldRemoveTrailingSlashFromDomain() + { + // Arrange + var domain = "https://cdn.example.com/"; + var objectKey = "uploads/test.jpg"; + + // Act + var result = TencentCosProvider.GenerateAccessUrl(domain, objectKey); + + // Assert + Assert.DoesNotContain("//uploads", result); + } + + [Fact] + public void GenerateAccessUrl_ShouldHandleObjectKeyWithLeadingSlash() + { + // Arrange + var domain = "https://cdn.example.com"; + var objectKey = "/uploads/test.jpg"; + + // Act + var result = TencentCosProvider.GenerateAccessUrl(domain, objectKey); + + // Assert + Assert.Equal("https://cdn.example.com/uploads/test.jpg", result); + } + + #endregion +} + + +/// +/// TencentCosProvider 属性测试 +/// **Property 4: 存储策略一致性 - COS URL格式** +/// **Validates: Requirements 3.3, 3.4** +/// +public class TencentCosProviderPropertyTests +{ + /// + /// **Feature: image-upload-feature, Property 4: 存储策略一致性 - COS URL格式** + /// *For any* 有效的Domain和objectKey,生成的URL SHALL 以配置的Domain开头 + /// **Validates: Requirements 3.3** + /// + [Property(MaxTest = 100)] + public bool CosStorage_UrlFormat_ShouldStartWithDomain(PositiveInt seed) + { + // 生成测试数据 + var domains = new[] + { + "https://cdn.example.com", + "https://bucket.cos.ap-guangzhou.myqcloud.com", + "cdn.test.com", + "http://cdn.local.com" + }; + var domain = domains[seed.Get % domains.Length]; + + var objectKey = $"uploads/2026/01/{(seed.Get % 28) + 1:D2}/test_{seed.Get}.jpg"; + + // Act + var url = TencentCosProvider.GenerateAccessUrl(domain, objectKey); + + // 验证URL以正确的协议和域名开头 + var normalizedDomain = domain.TrimEnd('/'); + if (!normalizedDomain.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !normalizedDomain.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + normalizedDomain = $"https://{normalizedDomain}"; + } + + return url.StartsWith(normalizedDomain); + } + + /// + /// **Feature: image-upload-feature, Property 4: 存储策略一致性 - COS URL格式** + /// *For any* 有效的Domain和objectKey,生成的URL SHALL 包含完整的对象路径 + /// **Validates: Requirements 3.3** + /// + [Property(MaxTest = 100)] + public bool CosStorage_UrlFormat_ShouldContainObjectKey(PositiveInt seed) + { + var domain = "https://cdn.example.com"; + var objectKey = $"uploads/2026/01/{(seed.Get % 28) + 1:D2}/file_{seed.Get}.jpg"; + + // Act + var url = TencentCosProvider.GenerateAccessUrl(domain, objectKey); + + // 验证URL包含对象路径 + var normalizedKey = objectKey.StartsWith('/') ? objectKey : $"/{objectKey}"; + return url.EndsWith(normalizedKey) || url.Contains(objectKey); + } + + /// + /// **Feature: image-upload-feature, Property 3: 文件名唯一性** + /// *For any* 两次调用GenerateUniqueFileName,即使使用相同的原始文件名,生成的文件名 SHALL 不同 + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool CosStorage_FileNames_ShouldBeUnique(PositiveInt seed) + { + var extensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; + var extension = extensions[seed.Get % extensions.Length]; + var fileName = $"test{extension}"; + + // 生成两个文件名 + var name1 = TencentCosProvider.GenerateUniqueFileName(fileName); + Thread.Sleep(1); // 确保时间戳不同 + var name2 = TencentCosProvider.GenerateUniqueFileName(fileName); + + // 验证两个文件名不同 + return name1 != name2; + } + + /// + /// **Feature: image-upload-feature, Property 4: 存储策略一致性 - COS URL格式** + /// *For any* 生成的文件名,SHALL 保留原始文件的扩展名(小写) + /// **Validates: Requirements 3.3** + /// + [Property(MaxTest = 100)] + public bool CosStorage_FileName_ShouldPreserveExtension(PositiveInt seed) + { + var extensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".JPG", ".PNG", ".WEBP" }; + var extension = extensions[seed.Get % extensions.Length]; + var fileName = $"original_{seed.Get}{extension}"; + + // Act + var uniqueName = TencentCosProvider.GenerateUniqueFileName(fileName); + + // 验证扩展名被保留(小写) + var expectedExtension = extension.ToLowerInvariant(); + return uniqueName.EndsWith(expectedExtension); + } + + /// + /// **Feature: image-upload-feature, Property 4: 存储策略一致性 - COS URL格式** + /// *For any* 生成的URL,SHALL 不包含双斜杠(除了协议部分) + /// **Validates: Requirements 3.3** + /// + [Property(MaxTest = 100)] + public bool CosStorage_UrlFormat_ShouldNotContainDoubleSlashes(PositiveInt seed) + { + var domains = new[] + { + "https://cdn.example.com", + "https://cdn.example.com/", + "cdn.test.com", + "cdn.test.com/" + }; + var domain = domains[seed.Get % domains.Length]; + + var objectKeys = new[] + { + "uploads/2026/01/19/test.jpg", + "/uploads/2026/01/19/test.jpg" + }; + var objectKey = objectKeys[seed.Get % objectKeys.Length]; + + // Act + var url = TencentCosProvider.GenerateAccessUrl(domain, objectKey); + + // 移除协议部分后检查是否有双斜杠 + var urlWithoutProtocol = url.Replace("https://", "").Replace("http://", ""); + return !urlWithoutProtocol.Contains("//"); + } +} diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/UploadServicePropertyTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/UploadServicePropertyTests.cs new file mode 100644 index 00000000..49cece59 --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/UploadServicePropertyTests.cs @@ -0,0 +1,208 @@ +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Admin.Business.Services; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// UploadService 属性测试 +/// **Validates: Requirements 1.2, 1.3, 1.4, 1.5** +/// +public class UploadServicePropertyTests +{ + /// + /// 允许的图片扩展名 + /// + private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; + + /// + /// 不允许的扩展名示例 + /// + private static readonly string[] DisallowedExtensions = { ".txt", ".pdf", ".exe", ".doc", ".html", ".js", ".css", ".zip", ".rar", ".mp4" }; + + #region Property 1: 文件格式验证 + + /// + /// **Feature: image-upload-feature, Property 1: 文件格式验证** + /// *For any* 上传的文件,如果文件扩展名不在允许列表(jpg, jpeg, png, gif, webp)中, + /// THE Upload_Service SHALL 返回格式错误。 + /// **Validates: Requirements 1.2, 1.4** + /// + [Property(MaxTest = 100)] + public bool InvalidExtension_ShouldBeRejected(PositiveInt seed) + { + // 从不允许的扩展名中选择一个 + var extension = DisallowedExtensions[seed.Get % DisallowedExtensions.Length]; + + // 验证该扩展名被拒绝 + return !UploadService.IsValidExtension(extension); + } + + /// + /// **Feature: image-upload-feature, Property 1: 文件格式验证** + /// *For any* 允许的图片扩展名,THE Upload_Service SHALL 接受该格式。 + /// **Validates: Requirements 1.4** + /// + [Property(MaxTest = 100)] + public bool ValidExtension_ShouldBeAccepted(PositiveInt seed) + { + // 从允许的扩展名中选择一个 + var extension = AllowedExtensions[seed.Get % AllowedExtensions.Length]; + + // 验证该扩展名被接受 + return UploadService.IsValidExtension(extension); + } + + /// + /// **Feature: image-upload-feature, Property 1: 文件格式验证** + /// *For any* 允许的图片扩展名(大写形式),THE Upload_Service SHALL 接受该格式(大小写不敏感)。 + /// **Validates: Requirements 1.4** + /// + [Property(MaxTest = 100)] + public bool ValidExtension_CaseInsensitive_ShouldBeAccepted(PositiveInt seed) + { + // 从允许的扩展名中选择一个并转为大写 + var extension = AllowedExtensions[seed.Get % AllowedExtensions.Length].ToUpperInvariant(); + + // 验证大写扩展名也被接受 + return UploadService.IsValidExtension(extension); + } + + #endregion + + #region Property 2: 文件大小验证 + + /// + /// **Feature: image-upload-feature, Property 2: 文件大小验证** + /// *For any* 上传的文件,如果文件大小超过10MB,THE Upload_Service SHALL 返回大小超限错误。 + /// **Validates: Requirements 1.3** + /// + [Property(MaxTest = 100)] + public bool FileSizeExceeding10MB_ShouldBeRejected(PositiveInt extraBytes) + { + // 10MB + 额外字节 + var maxSize = 10L * 1024 * 1024; + var fileSize = maxSize + extraBytes.Get; + + // 验证超过10MB的文件被拒绝 + return !UploadService.IsValidFileSize(fileSize); + } + + /// + /// **Feature: image-upload-feature, Property 2: 文件大小验证** + /// *For any* 文件大小在1字节到10MB之间,THE Upload_Service SHALL 接受该文件。 + /// **Validates: Requirements 1.3** + /// + [Property(MaxTest = 100)] + public bool FileSizeWithin10MB_ShouldBeAccepted(PositiveInt seed) + { + // 生成1字节到10MB之间的文件大小 + var maxSize = 10L * 1024 * 1024; + var fileSize = (seed.Get % maxSize) + 1; // 确保至少1字节 + + // 验证在范围内的文件被接受 + return UploadService.IsValidFileSize(fileSize); + } + + /// + /// **Feature: image-upload-feature, Property 2: 文件大小验证** + /// *For any* 文件大小为0或负数,THE Upload_Service SHALL 拒绝该文件。 + /// **Validates: Requirements 1.3** + /// + [Property(MaxTest = 100)] + public bool ZeroOrNegativeFileSize_ShouldBeRejected(NegativeInt negativeSize) + { + // 验证0和负数被拒绝 + return !UploadService.IsValidFileSize(0) && !UploadService.IsValidFileSize(negativeSize.Get); + } + + #endregion + + #region Property 3: 文件名唯一性 + + /// + /// **Feature: image-upload-feature, Property 3: 文件名唯一性** + /// *For any* 两次上传操作,即使上传相同的文件,生成的文件名 SHALL 不同。 + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool GeneratedFileNames_ShouldBeUnique(PositiveInt seed) + { + // 使用相同的原始文件名 + var extension = AllowedExtensions[seed.Get % AllowedExtensions.Length]; + var originalFileName = $"test_file{extension}"; + + // 生成两个唯一文件名 + var name1 = UploadService.GenerateUniqueFileName(originalFileName); + var name2 = UploadService.GenerateUniqueFileName(originalFileName); + + // 验证两个文件名不同 + return name1 != name2; + } + + /// + /// **Feature: image-upload-feature, Property 3: 文件名唯一性** + /// *For any* 原始文件名,生成的唯一文件名 SHALL 保留原始扩展名。 + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool GeneratedFileName_ShouldPreserveExtension(PositiveInt seed) + { + // 选择一个扩展名 + var extension = AllowedExtensions[seed.Get % AllowedExtensions.Length]; + var originalFileName = $"original_file_{seed.Get}{extension}"; + + // 生成唯一文件名 + var uniqueName = UploadService.GenerateUniqueFileName(originalFileName); + + // 验证扩展名被保留(转为小写) + return uniqueName.EndsWith(extension.ToLowerInvariant()); + } + + /// + /// **Feature: image-upload-feature, Property 3: 文件名唯一性** + /// *For any* 大写扩展名的文件,生成的唯一文件名 SHALL 将扩展名转为小写。 + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 100)] + public bool GeneratedFileName_ShouldNormalizeExtensionToLowercase(PositiveInt seed) + { + // 选择一个扩展名并转为大写 + var extension = AllowedExtensions[seed.Get % AllowedExtensions.Length].ToUpperInvariant(); + var originalFileName = $"FILE_{seed.Get}{extension}"; + + // 生成唯一文件名 + var uniqueName = UploadService.GenerateUniqueFileName(originalFileName); + + // 验证扩展名被转为小写 + return uniqueName.EndsWith(extension.ToLowerInvariant()); + } + + /// + /// **Feature: image-upload-feature, Property 3: 文件名唯一性** + /// *For any* 多次生成的文件名,所有文件名 SHALL 互不相同。 + /// **Validates: Requirements 1.5** + /// + [Property(MaxTest = 50)] + public bool MultipleGeneratedFileNames_ShouldAllBeUnique(PositiveInt seed) + { + var originalFileName = "test.jpg"; + var generatedNames = new HashSet(); + + // 生成多个文件名 + var count = (seed.Get % 10) + 5; // 5-14个文件名 + for (var i = 0; i < count; i++) + { + var name = UploadService.GenerateUniqueFileName(originalFileName); + if (!generatedNames.Add(name)) + { + return false; // 发现重复 + } + } + + return true; + } + + #endregion +} diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/UploadServiceTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/UploadServiceTests.cs new file mode 100644 index 00000000..f903f8b8 --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/UploadServiceTests.cs @@ -0,0 +1,400 @@ +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Admin.Business.Models; +using HoneyBox.Admin.Business.Models.Config; +using HoneyBox.Admin.Business.Models.Upload; +using HoneyBox.Admin.Business.Services; +using HoneyBox.Admin.Business.Services.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// UploadService 单元测试 +/// +public class UploadServiceTests +{ + private readonly Mock _mockConfigService; + private readonly Mock _mockLocalProvider; + private readonly Mock _mockCosProvider; + private readonly Mock> _mockLogger; + private readonly UploadService _service; + + public UploadServiceTests() + { + _mockConfigService = new Mock(); + _mockLocalProvider = new Mock(); + _mockCosProvider = new Mock(); + _mockLogger = new Mock>(); + + // 设置存储提供者类型 + _mockLocalProvider.Setup(p => p.StorageType).Returns("1"); + _mockCosProvider.Setup(p => p.StorageType).Returns("3"); + + var providers = new List { _mockLocalProvider.Object, _mockCosProvider.Object }; + _service = new UploadService(_mockConfigService.Object, providers, _mockLogger.Object); + } + + #region File Validation Tests + + [Fact] + public void ValidateFile_NullFile_ReturnsError() + { + // Act + var result = UploadService.ValidateFile(null); + + // Assert + Assert.Equal("请选择要上传的文件", result); + } + + [Fact] + public void ValidateFile_EmptyFile_ReturnsError() + { + // Arrange + var mockFile = new Mock(); + mockFile.Setup(f => f.Length).Returns(0); + + // Act + var result = UploadService.ValidateFile(mockFile.Object); + + // Assert + Assert.Equal("请选择要上传的文件", result); + } + + [Fact] + public void ValidateFile_FileTooLarge_ReturnsError() + { + // Arrange + var mockFile = new Mock(); + mockFile.Setup(f => f.Length).Returns(11 * 1024 * 1024); // 11MB + mockFile.Setup(f => f.FileName).Returns("test.jpg"); + mockFile.Setup(f => f.ContentType).Returns("image/jpeg"); + + // Act + var result = UploadService.ValidateFile(mockFile.Object); + + // Assert + Assert.Equal("文件大小不能超过10MB", result); + } + + [Fact] + public void ValidateFile_InvalidExtension_ReturnsError() + { + // Arrange + var mockFile = new Mock(); + mockFile.Setup(f => f.Length).Returns(1024); + mockFile.Setup(f => f.FileName).Returns("test.txt"); + mockFile.Setup(f => f.ContentType).Returns("text/plain"); + + // Act + var result = UploadService.ValidateFile(mockFile.Object); + + // Assert + Assert.Equal("只支持 jpg、jpeg、png、gif、webp 格式的图片", result); + } + + [Theory] + [InlineData("test.jpg", "image/jpeg")] + [InlineData("test.jpeg", "image/jpeg")] + [InlineData("test.png", "image/png")] + [InlineData("test.gif", "image/gif")] + [InlineData("test.webp", "image/webp")] + public void ValidateFile_ValidImageFormats_ReturnsNull(string fileName, string contentType) + { + // Arrange + var mockFile = new Mock(); + mockFile.Setup(f => f.Length).Returns(1024); + mockFile.Setup(f => f.FileName).Returns(fileName); + mockFile.Setup(f => f.ContentType).Returns(contentType); + + // Act + var result = UploadService.ValidateFile(mockFile.Object); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ValidateFile_MaxSizeExactly_ReturnsNull() + { + // Arrange - 正好10MB + var mockFile = new Mock(); + mockFile.Setup(f => f.Length).Returns(10 * 1024 * 1024); + mockFile.Setup(f => f.FileName).Returns("test.jpg"); + mockFile.Setup(f => f.ContentType).Returns("image/jpeg"); + + // Act + var result = UploadService.ValidateFile(mockFile.Object); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Extension Validation Tests + + [Theory] + [InlineData(".jpg", true)] + [InlineData(".jpeg", true)] + [InlineData(".png", true)] + [InlineData(".gif", true)] + [InlineData(".webp", true)] + [InlineData(".JPG", true)] + [InlineData(".JPEG", true)] + [InlineData(".PNG", true)] + [InlineData(".txt", false)] + [InlineData(".pdf", false)] + [InlineData(".exe", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsValidExtension_ReturnsExpectedResult(string? extension, bool expected) + { + // Act + var result = UploadService.IsValidExtension(extension); + + // Assert + Assert.Equal(expected, result); + } + + #endregion + + #region File Size Validation Tests + + [Theory] + [InlineData(1, true)] + [InlineData(1024, true)] + [InlineData(1024 * 1024, true)] + [InlineData(10 * 1024 * 1024, true)] + [InlineData(10 * 1024 * 1024 + 1, false)] + [InlineData(0, false)] + [InlineData(-1, false)] + public void IsValidFileSize_ReturnsExpectedResult(long fileSize, bool expected) + { + // Act + var result = UploadService.IsValidFileSize(fileSize); + + // Assert + Assert.Equal(expected, result); + } + + #endregion + + #region Unique FileName Tests + + [Fact] + public void GenerateUniqueFileName_PreservesExtension() + { + // Arrange + var originalFileName = "test.jpg"; + + // Act + var uniqueName = UploadService.GenerateUniqueFileName(originalFileName); + + // Assert + Assert.EndsWith(".jpg", uniqueName); + } + + [Fact] + public void GenerateUniqueFileName_NormalizesExtensionToLowercase() + { + // Arrange + var originalFileName = "test.JPG"; + + // Act + var uniqueName = UploadService.GenerateUniqueFileName(originalFileName); + + // Assert + Assert.EndsWith(".jpg", uniqueName); + } + + [Fact] + public void GenerateUniqueFileName_GeneratesDifferentNames() + { + // Arrange + var originalFileName = "test.jpg"; + + // Act + var name1 = UploadService.GenerateUniqueFileName(originalFileName); + var name2 = UploadService.GenerateUniqueFileName(originalFileName); + + // Assert + Assert.NotEqual(name1, name2); + } + + #endregion + + #region Storage Provider Selection Tests + + [Fact] + public async Task UploadImageAsync_UsesLocalStorageByDefault() + { + // Arrange + _mockConfigService.Setup(c => c.GetConfigAsync(ConfigKeys.Uploads)) + .ReturnsAsync((UploadSetting?)null); + + _mockLocalProvider.Setup(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(UploadResult.Ok("/uploads/2026/01/19/test.jpg")); + + var mockFile = CreateMockFile("test.jpg", "image/jpeg", 1024); + + // Act + var result = await _service.UploadImageAsync(mockFile.Object); + + // Assert + _mockLocalProvider.Verify(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _mockCosProvider.Verify(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task UploadImageAsync_UsesCosStorageWhenConfigured() + { + // Arrange + var uploadSetting = new UploadSetting { Type = "3" }; + _mockConfigService.Setup(c => c.GetConfigAsync(ConfigKeys.Uploads)) + .ReturnsAsync(uploadSetting); + + _mockCosProvider.Setup(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(UploadResult.Ok("https://cdn.example.com/uploads/2026/01/19/test.jpg")); + + var mockFile = CreateMockFile("test.jpg", "image/jpeg", 1024); + + // Act + var result = await _service.UploadImageAsync(mockFile.Object); + + // Assert + _mockCosProvider.Verify(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _mockLocalProvider.Verify(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task UploadImageAsync_FallsBackToLocalStorageForInvalidType() + { + // Arrange + var uploadSetting = new UploadSetting { Type = "999" }; // 无效类型 + _mockConfigService.Setup(c => c.GetConfigAsync(ConfigKeys.Uploads)) + .ReturnsAsync(uploadSetting); + + _mockLocalProvider.Setup(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(UploadResult.Ok("/uploads/2026/01/19/test.jpg")); + + var mockFile = CreateMockFile("test.jpg", "image/jpeg", 1024); + + // Act + var result = await _service.UploadImageAsync(mockFile.Object); + + // Assert + _mockLocalProvider.Verify(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + #endregion + + #region Upload Response Tests + + [Fact] + public async Task UploadImageAsync_ReturnsCorrectResponse() + { + // Arrange + _mockConfigService.Setup(c => c.GetConfigAsync(ConfigKeys.Uploads)) + .ReturnsAsync((UploadSetting?)null); + + _mockLocalProvider.Setup(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(UploadResult.Ok("/uploads/2026/01/19/test.jpg")); + + var mockFile = CreateMockFile("original.jpg", "image/jpeg", 2048); + + // Act + var result = await _service.UploadImageAsync(mockFile.Object); + + // Assert + Assert.Equal("/uploads/2026/01/19/test.jpg", result.Url); + Assert.Equal("original.jpg", result.FileName); + Assert.Equal(2048, result.FileSize); + } + + [Fact] + public async Task UploadImageAsync_ThrowsOnUploadFailure() + { + // Arrange + _mockConfigService.Setup(c => c.GetConfigAsync(ConfigKeys.Uploads)) + .ReturnsAsync((UploadSetting?)null); + + _mockLocalProvider.Setup(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(UploadResult.Fail("磁盘空间不足")); + + var mockFile = CreateMockFile("test.jpg", "image/jpeg", 1024); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _service.UploadImageAsync(mockFile.Object)); + Assert.Equal(BusinessErrorCodes.OperationFailed, ex.Code); + Assert.Equal("磁盘空间不足", ex.Message); + } + + #endregion + + #region Batch Upload Tests + + [Fact] + public async Task UploadImagesAsync_UploadsAllFiles() + { + // Arrange + _mockConfigService.Setup(c => c.GetConfigAsync(ConfigKeys.Uploads)) + .ReturnsAsync((UploadSetting?)null); + + var uploadCount = 0; + _mockLocalProvider.Setup(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => UploadResult.Ok($"/uploads/2026/01/19/file{++uploadCount}.jpg")); + + var files = new List + { + CreateMockFile("file1.jpg", "image/jpeg", 1024).Object, + CreateMockFile("file2.jpg", "image/jpeg", 2048).Object, + CreateMockFile("file3.jpg", "image/jpeg", 3072).Object + }; + + // Act + var results = await _service.UploadImagesAsync(files); + + // Assert + Assert.Equal(3, results.Count); + _mockLocalProvider.Verify(p => p.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(3)); + } + + [Fact] + public async Task UploadImagesAsync_ThrowsOnEmptyList() + { + // Arrange + var files = new List(); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _service.UploadImagesAsync(files)); + Assert.Equal(BusinessErrorCodes.ValidationFailed, ex.Code); + } + + [Fact] + public async Task UploadImagesAsync_ThrowsOnNullList() + { + // Act & Assert + var ex = await Assert.ThrowsAsync(() => _service.UploadImagesAsync(null!)); + Assert.Equal(BusinessErrorCodes.ValidationFailed, ex.Code); + } + + #endregion + + #region Helper Methods + + private static Mock CreateMockFile(string fileName, string contentType, long length) + { + var mockFile = new Mock(); + mockFile.Setup(f => f.FileName).Returns(fileName); + mockFile.Setup(f => f.ContentType).Returns(contentType); + mockFile.Setup(f => f.Length).Returns(length); + mockFile.Setup(f => f.OpenReadStream()).Returns(new MemoryStream(new byte[length])); + return mockFile; + } + + #endregion +}