This commit is contained in:
gpu 2026-01-19 15:05:52 +08:00
parent 8b17e1b84d
commit 29b231c417
31 changed files with 3481 additions and 426 deletions

View File

@ -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
{
/// <summary>
/// 存储类型标识
/// </summary>
string StorageType { get; }
/// <summary>
/// 上传文件
/// </summary>
Task<UploadResult> UploadAsync(Stream fileStream, string fileName, string contentType);
/// <summary>
/// 删除文件
/// </summary>
Task<bool> 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
{
/// <summary>
/// 上传图片
/// </summary>
Task<UploadResponse> UploadImageAsync(IFormFile file);
/// <summary>
/// 批量上传图片
/// </summary>
Task<List<UploadResponse>> UploadImagesAsync(List<IFormFile> files);
}
```
#### 5. UploadService 上传服务实现
```csharp
public class UploadService : IUploadService
{
// 根据配置选择存储提供者
// 验证文件格式和大小
// 生成唯一文件名
// 调用存储提供者上传
}
```
#### 6. UploadController 上传控制器
```csharp
[Route("api/admin/upload")]
public class UploadController : BusinessControllerBase
{
[HttpPost("image")]
public async Task<IActionResult> UploadImage(IFormFile file);
[HttpPost("images")]
public async Task<IActionResult> UploadImages(List<IFormFile> 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<ApiResponse<UploadResponse>>
export function uploadFiles(files: File[], onProgress?: (percent: number) => void): Promise<ApiResponse<UploadResponse[]>>
```
## Data Models
### Backend Models
```csharp
/// <summary>
/// 上传响应
/// </summary>
public class UploadResponse
{
/// <summary>
/// 文件URL
/// </summary>
public string Url { get; set; } = string.Empty;
/// <summary>
/// 文件名
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// 文件大小(字节)
/// </summary>
public long FileSize { get; set; }
}
/// <summary>
/// 上传结果(内部使用)
/// </summary>
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* 上传的文件如果文件大小超过10MBTHE 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绑定

View File

@ -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数据

View File

@ -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 组件已创建基础版本,需要根据后端接口进行优化
- 业务页面改造时需保持原有表单验证逻辑不变

View File

@ -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;
/// <summary>
/// 文件上传控制器
/// </summary>
[Route("api/admin/upload")]
public class UploadController : BusinessControllerBase
{
private readonly IUploadService _uploadService;
public UploadController(IUploadService uploadService)
{
_uploadService = uploadService;
}
/// <summary>
/// 上传单个图片
/// </summary>
/// <param name="file">图片文件</param>
/// <returns>上传结果</returns>
[HttpPost("image")]
[BusinessPermission("upload:image")]
public async Task<IActionResult> UploadImage(IFormFile file)
{
try
{
var result = await _uploadService.UploadImageAsync(file);
return Ok(result, "上传成功");
}
catch (BusinessException ex)
{
return Error(ex.Code, ex.Message);
}
}
/// <summary>
/// 批量上传图片
/// </summary>
/// <param name="files">图片文件列表</param>
/// <returns>上传结果列表</returns>
[HttpPost("images")]
[BusinessPermission("upload:image")]
public async Task<IActionResult> UploadImages(List<IFormFile> files)
{
try
{
var results = await _uploadService.UploadImagesAsync(files);
return Ok(results, "上传成功");
}
catch (BusinessException ex)
{
return Error(ex.Code, ex.Message);
}
}
}

View File

@ -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;
}
/// <summary>
/// 注册存储提供者
/// </summary>
private static void RegisterStorageProviders(IServiceCollection services)
{
// 注册本地存储提供者
services.AddScoped<IStorageProvider, LocalStorageProvider>();
// 注册腾讯云COS存储提供者
services.AddScoped<IStorageProvider>(sp =>
{
var logger = sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<TencentCosProvider>>();
var configService = sp.GetRequiredService<IAdminConfigService>();
// 创建获取配置的委托
Func<UploadSetting?> getUploadSetting = () =>
{
return configService.GetConfigAsync<UploadSetting>(ConfigKeys.Uploads).GetAwaiter().GetResult();
};
return new TencentCosProvider(logger, getUploadSetting);
});
}
/// <summary>
/// 自动注册业务服务
/// 扫描程序集中所有实现了 I*Service 接口的服务类并注册

View File

@ -12,6 +12,9 @@
<!-- ASP.NET Core MVC -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" />
<!-- ASP.NET Core Hosting Abstractions for IWebHostEnvironment -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<!-- Object Mapping -->
<PackageReference Include="Mapster" Version="7.4.0" />
@ -20,6 +23,9 @@
<!-- Redis Cache -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<!-- Tencent Cloud COS SDK -->
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.44" />
</ItemGroup>
<ItemGroup>

View File

@ -71,4 +71,14 @@ public static class BusinessErrorCodes
/// 服务器内部错误
/// </summary>
public const int InternalError = 50001;
/// <summary>
/// 操作失败
/// </summary>
public const int OperationFailed = 50002;
/// <summary>
/// 配置错误
/// </summary>
public const int ConfigurationError = 50003;
}

View File

@ -0,0 +1,32 @@
using HoneyBox.Admin.Business.Models.Upload;
namespace HoneyBox.Admin.Business.Services.Interfaces;
/// <summary>
/// 存储提供者接口
/// </summary>
public interface IStorageProvider
{
/// <summary>
/// 存储类型标识
/// "1" = 本地存储
/// "3" = 腾讯云COS
/// </summary>
string StorageType { get; }
/// <summary>
/// 上传文件
/// </summary>
/// <param name="fileStream">文件流</param>
/// <param name="fileName">文件名</param>
/// <param name="contentType">内容类型</param>
/// <returns>上传结果</returns>
Task<UploadResult> UploadAsync(Stream fileStream, string fileName, string contentType);
/// <summary>
/// 删除文件
/// </summary>
/// <param name="fileUrl">文件URL</param>
/// <returns>是否成功</returns>
Task<bool> DeleteAsync(string fileUrl);
}

View File

@ -0,0 +1,24 @@
using HoneyBox.Admin.Business.Models.Upload;
using Microsoft.AspNetCore.Http;
namespace HoneyBox.Admin.Business.Services.Interfaces;
/// <summary>
/// 上传服务接口
/// </summary>
public interface IUploadService
{
/// <summary>
/// 上传图片
/// </summary>
/// <param name="file">上传的文件</param>
/// <returns>上传响应</returns>
Task<UploadResponse> UploadImageAsync(IFormFile file);
/// <summary>
/// 批量上传图片
/// </summary>
/// <param name="files">上传的文件列表</param>
/// <returns>上传响应列表</returns>
Task<List<UploadResponse>> UploadImagesAsync(List<IFormFile> files);
}

View File

@ -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;
/// <summary>
/// 本地存储提供者
/// 将文件保存到 wwwroot/uploads/{yyyy}/{MM}/{dd}/ 目录
/// </summary>
public class LocalStorageProvider : IStorageProvider
{
private readonly IWebHostEnvironment _environment;
private readonly ILogger<LocalStorageProvider> _logger;
private const string UploadBasePath = "uploads";
public LocalStorageProvider(
IWebHostEnvironment environment,
ILogger<LocalStorageProvider> logger)
{
_environment = environment;
_logger = logger;
}
/// <inheritdoc />
public string StorageType => "1";
/// <inheritdoc />
public async Task<UploadResult> 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}");
}
}
/// <inheritdoc />
public Task<bool> 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);
}
}
/// <summary>
/// 生成唯一文件名
/// 格式: {timestamp}_{guid}{extension}
/// </summary>
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}";
}
/// <summary>
/// 确保目录存在,不存在则创建
/// </summary>
private void EnsureDirectoryExists(string path)
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
_logger.LogDebug("创建目录: {Path}", path);
}
}
}

View File

@ -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;
/// <summary>
/// 腾讯云COS存储提供者
/// 将文件上传到腾讯云COS对象存储
/// </summary>
public class TencentCosProvider : IStorageProvider
{
private readonly ILogger<TencentCosProvider> _logger;
private readonly Func<UploadSetting?> _getUploadSetting;
private const string UploadBasePath = "uploads";
public TencentCosProvider(
ILogger<TencentCosProvider> logger,
Func<UploadSetting?> getUploadSetting)
{
_logger = logger;
_getUploadSetting = getUploadSetting;
}
/// <inheritdoc />
public string StorageType => "3";
/// <inheritdoc />
public async Task<UploadResult> 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);
}
}
/// <inheritdoc />
public Task<bool> 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);
}
}
/// <summary>
/// 验证配置参数
/// </summary>
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;
}
/// <summary>
/// 创建COS客户端
/// </summary>
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);
}
/// <summary>
/// 生成唯一文件名
/// 格式: {timestamp}_{guid}{extension}
/// </summary>
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}";
}
/// <summary>
/// 生成访问URL
/// </summary>
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}";
}
/// <summary>
/// 从URL中提取对象路径
/// </summary>
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;
}
}
}

View File

@ -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;
/// <summary>
/// 上传服务实现
/// 负责文件验证、存储提供者选择和文件上传
/// </summary>
public class UploadService : IUploadService
{
private readonly IAdminConfigService _configService;
private readonly IEnumerable<IStorageProvider> _storageProviders;
private readonly ILogger<UploadService> _logger;
/// <summary>
/// 允许的图片格式
/// </summary>
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".webp"
};
/// <summary>
/// 允许的MIME类型
/// </summary>
private static readonly HashSet<string> AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg", "image/png", "image/gif", "image/webp"
};
/// <summary>
/// 最大文件大小 (10MB)
/// </summary>
private const long MaxFileSize = 10 * 1024 * 1024;
/// <summary>
/// 默认存储类型 (本地存储)
/// </summary>
private const string DefaultStorageType = "1";
public UploadService(
IAdminConfigService configService,
IEnumerable<IStorageProvider> storageProviders,
ILogger<UploadService> logger)
{
_configService = configService;
_storageProviders = storageProviders;
_logger = logger;
}
/// <inheritdoc />
public async Task<UploadResponse> 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
};
}
/// <inheritdoc />
public async Task<List<UploadResponse>> UploadImagesAsync(List<IFormFile> files)
{
if (files == null || files.Count == 0)
{
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "请选择要上传的文件");
}
var results = new List<UploadResponse>();
foreach (var file in files)
{
var response = await UploadImageAsync(file);
results.Add(response);
}
return results;
}
/// <summary>
/// 验证文件
/// </summary>
/// <param name="file">上传的文件</param>
/// <returns>错误信息null表示验证通过</returns>
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;
}
/// <summary>
/// 检查文件扩展名是否有效
/// </summary>
/// <param name="extension">文件扩展名(包含点号)</param>
/// <returns>是否有效</returns>
public static bool IsValidExtension(string? extension)
{
if (string.IsNullOrEmpty(extension))
{
return false;
}
return AllowedExtensions.Contains(extension);
}
/// <summary>
/// 检查文件大小是否有效
/// </summary>
/// <param name="fileSize">文件大小(字节)</param>
/// <returns>是否有效</returns>
public static bool IsValidFileSize(long fileSize)
{
return fileSize > 0 && fileSize <= MaxFileSize;
}
/// <summary>
/// 生成唯一文件名
/// 格式: {timestamp}_{guid}{extension}
/// </summary>
/// <param name="originalFileName">原始文件名</param>
/// <returns>唯一文件名</returns>
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}";
}
/// <summary>
/// 获取存储提供者
/// 根据配置选择存储提供者,如果配置无效则使用本地存储
/// </summary>
private async Task<IStorageProvider> GetStorageProviderAsync()
{
// 获取上传配置
var uploadSetting = await _configService.GetConfigAsync<UploadSetting>(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;
}
}

View File

@ -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<ApiResponse<UploadResponse>> {
const formData = new FormData()
formData.append('file', file)
return request<UploadResponse>({
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<ApiResponse<UploadResponse[]>> {
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
return request<UploadResponse[]>({
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)
}
}
})
}

View File

@ -0,0 +1,142 @@
# ImageUpload 图片上传组件
通用图片上传组件支持点击上传、拖拽上传、URL输入三种方式。
## 功能特性
- ✅ 支持点击上传和拖拽上传
- ✅ 支持手动输入图片URL
- ✅ 显示上传进度
- ✅ 支持图片预览和删除
- ✅ 通过 v-model 双向绑定图片URL
- ✅ 上传成功自动更新绑定的URL值
- ✅ 上传失败显示详细错误提示
- ✅ 前端文件类型校验jpg、jpeg、png、gif、webp
- ✅ 前端文件大小校验默认10MB
## 基本用法
```vue
<template>
<el-form-item label="图片" prop="image">
<ImageUpload v-model="formData.image" />
</el-form-item>
</template>
<script setup>
import { reactive } from 'vue'
const formData = reactive({
image: ''
})
</script>
```
## 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
<ImageUpload v-model="formData.image" />
```
### 隐藏URL输入框
```vue
<ImageUpload v-model="formData.image" :show-url-input="false" />
```
### 自定义提示
```vue
<ImageUpload
v-model="formData.image"
placeholder="上传封面图"
tip="建议尺寸: 750x400支持 jpg/png 格式"
/>
```
### 限制文件大小
```vue
<ImageUpload v-model="formData.image" :max-size="2" />
```
### 禁用状态
```vue
<ImageUpload v-model="formData.image" disabled />
```
## 改造指南
将现有的图片输入改为使用 ImageUpload 组件:
### 改造前
```vue
<el-form-item label="图片" prop="image">
<div class="image-upload-container">
<el-input v-model="formData.image" placeholder="请输入图片URL" />
<el-image v-if="formData.image" :src="formData.image" fit="cover" />
</div>
</el-form-item>
```
### 改造后
```vue
<el-form-item label="图片" prop="image">
<ImageUpload v-model="formData.image" />
</el-form-item>
```
## 需要改造的页面清单
| 模块 | 文件 | 图片字段 |
|------|------|----------|
| 商品管理 | 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 |

View File

@ -0,0 +1,454 @@
<template>
<div class="image-upload">
<!-- 上传区域 -->
<div class="upload-area">
<!-- 已有图片时显示预览 -->
<div v-if="modelValue" class="image-preview-wrapper">
<el-image
:src="modelValue"
fit="cover"
class="preview-image"
:preview-src-list="[modelValue]"
preview-teleported
>
<template #error>
<div class="image-error">
<el-icon><Picture /></el-icon>
<span>加载失败</span>
</div>
</template>
</el-image>
<div class="image-actions">
<el-icon class="action-icon" @click="handlePreview"><ZoomIn /></el-icon>
<el-icon class="action-icon" @click="handleRemove"><Delete /></el-icon>
</div>
</div>
<!-- 无图片时显示上传框 -->
<el-upload
v-else
ref="uploadRef"
class="uploader"
:action="uploadAction"
:show-file-list="false"
:before-upload="handleBeforeUpload"
:http-request="handleUpload"
:accept="acceptTypes"
:disabled="disabled || uploading"
drag
>
<div class="upload-content">
<el-icon v-if="!uploading" class="upload-icon"><Plus /></el-icon>
<el-progress
v-else
type="circle"
:percentage="uploadProgress"
:width="50"
/>
<div class="upload-text">
{{ uploading ? '上传中...' : placeholder }}
</div>
</div>
</el-upload>
</div>
<!-- 错误提示 -->
<div v-if="errorMessage" class="upload-error-message">
<el-icon><WarningFilled /></el-icon>
<span>{{ errorMessage }}</span>
</div>
<!-- URL输入框可选显示 -->
<div v-if="showUrlInput" class="url-input-wrapper">
<el-input
v-model="urlInputValue"
:placeholder="urlPlaceholder"
:disabled="disabled"
clearable
@blur="handleUrlBlur"
@keyup.enter="handleUrlConfirm"
>
<template #append>
<el-button :disabled="!urlInputValue" @click="handleUrlConfirm">
确定
</el-button>
</template>
</el-input>
</div>
<!-- 提示信息 -->
<div v-if="tip" class="upload-tip">{{ tip }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage, type UploadRequestOptions } from 'element-plus'
import { Plus, Delete, ZoomIn, Picture, WarningFilled } from '@element-plus/icons-vue'
import { uploadFile } from '@/api/upload'
//
const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
interface Props {
/** 图片URL (v-model) */
modelValue?: string
/** 是否禁用 */
disabled?: boolean
/** 占位文字 */
placeholder?: string
/** URL输入框占位文字 */
urlPlaceholder?: string
/** 是否显示URL输入框 */
showUrlInput?: boolean
/** 接受的文件类型 */
accept?: string
/** 最大文件大小(MB) */
maxSize?: number
/** 提示文字 */
tip?: string
/** 上传接口地址(如果不使用默认接口) */
uploadAction?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
disabled: false,
placeholder: '点击或拖拽上传',
urlPlaceholder: '或输入图片URL',
showUrlInput: true,
accept: 'image/jpeg,image/png,image/gif,image/webp',
maxSize: 10, // 10MB
tip: '',
uploadAction: '#'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'change', value: string): void
(e: 'upload-success', response: any): void
(e: 'upload-error', error: any): void
}>()
//
const uploading = ref(false)
const uploadProgress = ref(0)
const urlInputValue = ref('')
const errorMessage = ref('')
//
const acceptTypes = computed(() => props.accept)
// URL
watch(() => props.modelValue, (val) => {
if (val !== urlInputValue.value) {
urlInputValue.value = val || ''
}
//
if (val) {
errorMessage.value = ''
}
}, { immediate: true })
/**
* 获取文件扩展名
*/
const getFileExtension = (filename: string): string => {
const lastDot = filename.lastIndexOf('.')
if (lastDot === -1) return ''
return filename.substring(lastDot + 1).toLowerCase()
}
/**
* 验证文件类型
*/
const validateFileType = (file: File): { valid: boolean; message: string } => {
const extension = getFileExtension(file.name)
const mimeType = file.type.toLowerCase()
//
const isValidExtension = ALLOWED_EXTENSIONS.includes(extension)
// MIME
const isValidMimeType = ALLOWED_MIME_TYPES.includes(mimeType)
if (!isValidExtension && !isValidMimeType) {
return {
valid: false,
message: `只支持 ${ALLOWED_EXTENSIONS.join('、')} 格式的图片`
}
}
return { valid: true, message: '' }
}
/**
* 验证文件大小
*/
const validateFileSize = (file: File): { valid: boolean; message: string } => {
const fileSizeMB = file.size / 1024 / 1024
if (fileSizeMB > props.maxSize) {
return {
valid: false,
message: `文件大小不能超过 ${props.maxSize}MB当前文件大小为 ${fileSizeMB.toFixed(2)}MB`
}
}
return { valid: true, message: '' }
}
/**
* 清除错误信息
*/
const clearError = () => {
errorMessage.value = ''
}
//
const handleBeforeUpload = (file: File): boolean => {
clearError()
//
const typeValidation = validateFileType(file)
if (!typeValidation.valid) {
errorMessage.value = typeValidation.message
ElMessage.error(typeValidation.message)
return false
}
//
const sizeValidation = validateFileSize(file)
if (!sizeValidation.valid) {
errorMessage.value = sizeValidation.message
ElMessage.error(sizeValidation.message)
return false
}
return true
}
//
const handleUpload = async (options: UploadRequestOptions) => {
const file = options.file as File
uploading.value = true
uploadProgress.value = 0
clearError()
try {
const res = await uploadFile(file, (percent) => {
uploadProgress.value = percent
})
if (res.code === 0 && res.data?.url) {
const url = res.data.url
emit('update:modelValue', url)
emit('change', url)
emit('upload-success', res.data)
urlInputValue.value = url
ElMessage.success('上传成功')
} else {
//
const errMsg = res.message || '上传失败,请重试'
throw new Error(errMsg)
}
} catch (error: any) {
console.error('上传失败:', error)
//
let errMsg = '上传失败,请重试'
if (error.response?.data?.message) {
//
errMsg = error.response.data.message
} else if (error.message) {
errMsg = error.message
}
//
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
errMsg = '上传超时,请检查网络后重试'
} else if (error.message?.includes('Network Error')) {
errMsg = '网络错误,请检查网络连接'
}
errorMessage.value = errMsg
emit('upload-error', error)
ElMessage.error(errMsg)
} finally {
uploading.value = false
uploadProgress.value = 0
}
}
//
const handlePreview = () => {
// el-image
}
//
const handleRemove = () => {
emit('update:modelValue', '')
emit('change', '')
urlInputValue.value = ''
clearError()
}
// URL
const handleUrlBlur = () => {
// URL
}
// URL
const handleUrlConfirm = () => {
const url = urlInputValue.value.trim()
if (url) {
clearError()
emit('update:modelValue', url)
emit('change', url)
}
}
</script>
<style scoped>
.image-upload {
width: 100%;
}
.upload-area {
width: 120px;
height: 120px;
}
.image-preview-wrapper {
position: relative;
width: 100%;
height: 100%;
border: 1px solid var(--el-border-color);
border-radius: 6px;
overflow: hidden;
}
.preview-image {
width: 100%;
height: 100%;
}
.image-actions {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s;
}
.image-preview-wrapper:hover .image-actions {
opacity: 1;
}
.action-icon {
font-size: 20px;
color: #fff;
cursor: pointer;
transition: transform 0.2s;
}
.action-icon:hover {
transform: scale(1.2);
}
.image-error {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
}
.image-error .el-icon {
font-size: 28px;
margin-bottom: 4px;
}
.image-error span {
font-size: 12px;
}
.uploader {
width: 100%;
height: 100%;
}
:deep(.uploader .el-upload) {
width: 100%;
height: 100%;
}
:deep(.uploader .el-upload-dragger) {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
border-radius: 6px;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-icon {
font-size: 28px;
color: var(--el-text-color-placeholder);
margin-bottom: 8px;
}
.upload-text {
font-size: 12px;
color: var(--el-text-color-placeholder);
text-align: center;
line-height: 1.4;
}
.upload-error-message {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
padding: 4px 8px;
font-size: 12px;
color: var(--el-color-danger);
background-color: var(--el-color-danger-light-9);
border-radius: 4px;
max-width: 300px;
}
.upload-error-message .el-icon {
flex-shrink: 0;
}
.url-input-wrapper {
margin-top: 8px;
width: 100%;
max-width: 300px;
}
.upload-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
</style>

View File

@ -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
}

View File

@ -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 })

View File

@ -78,28 +78,12 @@
</el-form-item>
<el-form-item label="广告图片" prop="imageUrl">
<div class="image-upload-container">
<el-input
v-model="formData.imageUrl"
placeholder="请输入图片URL或上传图片"
style="flex: 1"
/>
<!-- 图片预览 -->
<el-image
v-if="formData.imageUrl"
:src="formData.imageUrl"
fit="cover"
class="preview-image"
:preview-src-list="[formData.imageUrl]"
preview-teleported
>
<template #error>
<div class="image-error">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
</div>
<ImageUpload
v-model="formData.imageUrl"
placeholder="点击上传广告图片"
url-placeholder="或输入图片URL"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
<el-form-item label="排序值" prop="sort">
@ -127,7 +111,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import ImageUpload from '@/components/ImageUpload/index.vue'
import {
createAdvert,
updateAdvert,
@ -308,30 +292,6 @@ const handleSubmit = async () => {
</script>
<style scoped>
.image-upload-container {
display: flex;
gap: 12px;
align-items: flex-start;
}
.preview-image {
width: 80px;
height: 60px;
border-radius: 4px;
flex-shrink: 0;
}
.image-error {
display: flex;
justify-content: center;
align-items: center;
width: 80px;
height: 60px;
background: #f5f7fa;
color: #909399;
font-size: 20px;
}
.form-tip {
font-size: 12px;
color: #909399;

View File

@ -204,32 +204,22 @@
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="福利进群二维码" prop="erweima">
<div class="image-upload-wrapper">
<el-input v-model="formData.erweima" placeholder="请输入二维码图片URL" />
<div class="image-preview" v-if="formData.erweima">
<el-image
:src="formData.erweima"
fit="cover"
:preview-src-list="[formData.erweima]"
preview-teleported
/>
</div>
</div>
<ImageUpload
v-model="formData.erweima"
placeholder="点击上传二维码"
:show-url-input="true"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分享图片" prop="share_image">
<div class="image-upload-wrapper">
<el-input v-model="formData.share_image" placeholder="请输入分享图片URL" />
<div class="image-preview" v-if="formData.share_image">
<el-image
:src="formData.share_image"
fit="cover"
:preview-src-list="[formData.share_image]"
preview-teleported
/>
</div>
</div>
<ImageUpload
v-model="formData.share_image"
placeholder="点击上传分享图"
:show-url-input="true"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
</el-col>
</el-row>
@ -285,6 +275,7 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Check } from '@element-plus/icons-vue'
import ImageUpload from '@/components/ImageUpload/index.vue'
import { getBaseSetting, updateBaseSetting, type BaseSetting } from '@/api/business/config'
//
@ -460,24 +451,6 @@ onMounted(() => {
max-width: 1200px;
}
.image-upload-wrapper {
width: 100%;
}
.image-preview {
margin-top: 8px;
width: 100px;
height: 100px;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.image-preview :deep(.el-image) {
width: 100%;
height: 100%;
}
.form-tip {
font-size: 12px;
color: #909399;

View File

@ -61,22 +61,20 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="展示图" prop="normalImage">
<div class="image-upload-wrapper">
<el-input v-model="formData.normalImage" placeholder="请输入图片URL" />
<div class="image-preview" v-if="formData.normalImage">
<el-image :src="formData.normalImage" fit="cover" :preview-src-list="[formData.normalImage]" preview-teleported />
</div>
</div>
<ImageUpload
v-model="formData.normalImage"
placeholder="点击上传展示图"
tip="支持 jpg、png、gif、webp 格式最大10MB"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="选中图" prop="normalSelectImage">
<div class="image-upload-wrapper">
<el-input v-model="formData.normalSelectImage" placeholder="请输入图片URL" />
<div class="image-preview" v-if="formData.normalSelectImage">
<el-image :src="formData.normalSelectImage" fit="cover" :preview-src-list="[formData.normalSelectImage]" preview-teleported />
</div>
</div>
<ImageUpload
v-model="formData.normalSelectImage"
placeholder="点击上传选中图"
tip="支持 jpg、png、gif、webp 格式最大10MB"
/>
</el-form-item>
</el-col>
</el-row>
@ -100,22 +98,20 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="首充展示图" prop="firstChargeImage">
<div class="image-upload-wrapper">
<el-input v-model="formData.firstChargeImage" placeholder="请输入图片URL" />
<div class="image-preview" v-if="formData.firstChargeImage">
<el-image :src="formData.firstChargeImage" fit="cover" :preview-src-list="[formData.firstChargeImage]" preview-teleported />
</div>
</div>
<ImageUpload
v-model="formData.firstChargeImage"
placeholder="点击上传首充展示图"
tip="支持 jpg、png、gif、webp 格式最大10MB"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="首充选中图" prop="firstSelectChargeImage">
<div class="image-upload-wrapper">
<el-input v-model="formData.firstSelectChargeImage" placeholder="请输入图片URL" />
<div class="image-preview" v-if="formData.firstSelectChargeImage">
<el-image :src="formData.firstSelectChargeImage" fit="cover" :preview-src-list="[formData.firstSelectChargeImage]" preview-teleported />
</div>
</div>
<ImageUpload
v-model="formData.firstSelectChargeImage"
placeholder="点击上传首充选中图"
tip="支持 jpg、png、gif、webp 格式最大10MB"
/>
</el-form-item>
</el-col>
</el-row>
@ -160,6 +156,7 @@
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import RewardConfigInput from './RewardConfigInput.vue'
import ImageUpload from '@/components/ImageUpload/index.vue'
import {
createDiamondProduct,
updateDiamondProduct,
@ -339,21 +336,5 @@ const handleSubmit = async () => {
</script>
<style scoped>
.image-upload-wrapper {
width: 100%;
}
.image-preview {
margin-top: 8px;
width: 80px;
height: 80px;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.image-preview :deep(.el-image) {
width: 100%;
height: 100%;
}
/* 样式已移至 ImageUpload 组件 */
</style>

View File

@ -28,75 +28,28 @@
</el-form-item>
<el-form-item label="悬浮球图片" prop="image">
<div class="image-upload-container">
<el-input
v-model="formData.image"
placeholder="请输入图片URL"
style="flex: 1"
/>
<el-image
v-if="formData.image"
:src="formData.image"
fit="cover"
class="preview-image"
:preview-src-list="[formData.image]"
preview-teleported
>
<template #error>
<div class="image-error">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
</div>
<ImageUpload
v-model="formData.image"
placeholder="点击上传悬浮球图片"
url-placeholder="或输入图片URL"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
<el-form-item label="背景图片" prop="imageBj">
<div class="image-upload-container">
<el-input
v-model="formData.imageBj"
placeholder="请输入背景图片URL可选"
style="flex: 1"
/>
<el-image
v-if="formData.imageBj"
:src="formData.imageBj"
fit="cover"
class="preview-image"
:preview-src-list="[formData.imageBj]"
preview-teleported
>
<template #error>
<div class="image-error">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
</div>
<ImageUpload
v-model="formData.imageBj"
placeholder="点击上传背景图片"
url-placeholder="或输入背景图片URL可选"
/>
</el-form-item>
<el-form-item label="详情图片" prop="imageDetails">
<div class="image-upload-container">
<el-input
v-model="formData.imageDetails"
placeholder="请输入详情图片URL可选"
style="flex: 1"
/>
<el-image
v-if="formData.imageDetails"
:src="formData.imageDetails"
fit="cover"
class="preview-image"
:preview-src-list="[formData.imageDetails]"
preview-teleported
>
<template #error>
<div class="image-error">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
</div>
<ImageUpload
v-model="formData.imageDetails"
placeholder="点击上传详情图片"
url-placeholder="或输入详情图片URL可选"
/>
</el-form-item>
<!-- 条件显示跳转链接类型为跳转页面时显示 -->
@ -231,7 +184,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import ImageUpload from '@/components/ImageUpload/index.vue'
import {
createFloatBall,
updateFloatBall,
@ -447,30 +400,6 @@ const handleSubmit = async () => {
</script>
<style scoped>
.image-upload-container {
display: flex;
gap: 12px;
align-items: flex-start;
}
.preview-image {
width: 60px;
height: 60px;
border-radius: 4px;
flex-shrink: 0;
}
.image-error {
display: flex;
justify-content: center;
align-items: center;
width: 60px;
height: 60px;
background: #f5f7fa;
color: #909399;
font-size: 18px;
}
:deep(.el-divider__text) {
font-size: 13px;
color: #606266;

View File

@ -322,22 +322,20 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="盒子封面图" prop="imgUrl">
<div class="image-upload-wrapper">
<el-input v-model="formData.imgUrl" placeholder="请输入图片URL或上传图片" />
<div class="image-preview" v-if="formData.imgUrl">
<el-image :src="formData.imgUrl" fit="cover" :preview-src-list="[formData.imgUrl]" />
</div>
</div>
<ImageUpload
v-model="formData.imgUrl"
placeholder="点击上传封面图"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="盒子详情图" prop="imgUrlDetail">
<div class="image-upload-wrapper">
<el-input v-model="formData.imgUrlDetail" placeholder="请输入图片URL或上传图片" />
<div class="image-preview" v-if="formData.imgUrlDetail">
<el-image :src="formData.imgUrlDetail" fit="cover" :preview-src-list="[formData.imgUrlDetail]" />
</div>
</div>
<ImageUpload
v-model="formData.imgUrlDetail"
placeholder="点击上传详情图"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
</el-col>
</el-row>
@ -355,6 +353,7 @@ import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { createGoods, type GoodsTypeItem, type GoodsCreateRequest } from '@/api/business/goods'
import { getFieldConfig } from '../config/typeFieldConfig'
import ImageUpload from '@/components/ImageUpload/index.vue'
interface Props {
modelValue: boolean
@ -435,8 +434,8 @@ const formRules = reactive<FormRules>({
type: [{ required: true, message: '请选择盒子类型', trigger: 'change' }],
title: [{ required: true, message: '请输入盒子名称', trigger: 'blur' }],
price: [{ required: true, message: '请输入盒子价格', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传盒子封面图', trigger: 'blur' }],
imgUrlDetail: [{ required: true, message: '请上传盒子详情图', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传盒子封面图', trigger: 'change' }],
imgUrlDetail: [{ required: true, message: '请上传盒子详情图', trigger: 'change' }],
stock: [{ required: true, message: '请输入套数', trigger: 'blur' }],
flwStartTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
flwEndTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
@ -628,24 +627,6 @@ watch(() => availableGoodsTypes.value, (types) => {
margin-left: 10px;
}
.image-upload-wrapper {
width: 100%;
}
.image-preview {
margin-top: 10px;
width: 100px;
height: 100px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.image-preview .el-image {
width: 100%;
height: 100%;
}
:deep(.el-divider__text) {
font-weight: 500;
color: #303133;

View File

@ -332,22 +332,20 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="盒子封面图" prop="imgUrl">
<div class="image-upload-wrapper">
<el-input v-model="formData.imgUrl" placeholder="请输入图片URL或上传图片" />
<div class="image-preview" v-if="formData.imgUrl">
<el-image :src="formData.imgUrl" fit="cover" :preview-src-list="[formData.imgUrl]" />
</div>
</div>
<ImageUpload
v-model="formData.imgUrl"
placeholder="点击上传封面图"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="盒子详情图" prop="imgUrlDetail">
<div class="image-upload-wrapper">
<el-input v-model="formData.imgUrlDetail" placeholder="请输入图片URL或上传图片" />
<div class="image-preview" v-if="formData.imgUrlDetail">
<el-image :src="formData.imgUrlDetail" fit="cover" :preview-src-list="[formData.imgUrlDetail]" />
</div>
</div>
<ImageUpload
v-model="formData.imgUrlDetail"
placeholder="点击上传详情图"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
</el-col>
</el-row>
@ -370,6 +368,7 @@ import {
type GoodsUpdateRequest
} from '@/api/business/goods'
import { getFieldConfig, type GoodsTypeFieldConfig } from '../config/typeFieldConfig'
import ImageUpload from '@/components/ImageUpload/index.vue'
interface Props {
modelValue: boolean
@ -452,8 +451,8 @@ const formData = reactive({
const formRules = reactive<FormRules>({
title: [{ required: true, message: '请输入盒子名称', trigger: 'blur' }],
price: [{ required: true, message: '请输入盒子价格', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传盒子封面图', trigger: 'blur' }],
imgUrlDetail: [{ required: true, message: '请上传盒子详情图', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传盒子封面图', trigger: 'change' }],
imgUrlDetail: [{ required: true, message: '请上传盒子详情图', trigger: 'change' }],
stock: [{ required: true, message: '请输入套数', trigger: 'blur' }],
flwStartTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
flwEndTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
@ -640,24 +639,6 @@ watch(() => props.goodsId, (newId) => {
margin-left: 10px;
}
.image-upload-wrapper {
width: 100%;
}
.image-preview {
margin-top: 10px;
width: 100px;
height: 100px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.image-preview .el-image {
width: 100%;
height: 100%;
}
:deep(.el-divider__text) {
font-weight: 500;
color: #303133;

View File

@ -221,22 +221,20 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="奖品图片" prop="imgUrl">
<div class="image-upload-wrapper">
<el-input v-model="formData.imgUrl" placeholder="请输入图片URL" />
<div class="image-preview" v-if="formData.imgUrl">
<el-image :src="formData.imgUrl" fit="cover" :preview-src-list="[formData.imgUrl]" />
</div>
</div>
<ImageUpload
v-model="formData.imgUrl"
placeholder="点击上传奖品图"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="详情图片">
<div class="image-upload-wrapper">
<el-input v-model="formData.imgUrlDetail" placeholder="请输入图片URL" />
<div class="image-preview" v-if="formData.imgUrlDetail">
<el-image :src="formData.imgUrlDetail" fit="cover" :preview-src-list="[formData.imgUrlDetail]" />
</div>
</div>
<ImageUpload
v-model="formData.imgUrlDetail"
placeholder="点击上传详情图"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
</el-col>
</el-row>
@ -259,6 +257,7 @@ import {
PrizeCategoryLabels,
type PrizeCreateRequest
} from '@/api/business/goods'
import ImageUpload from '@/components/ImageUpload/index.vue'
interface Props {
modelValue: boolean
@ -348,7 +347,7 @@ const formRules = reactive<FormRules>({
type: [{ required: true, message: '请选择奖品分类', trigger: 'change' }],
rank: [{ required: true, message: '请选择奖品等级', trigger: 'change' }],
price: [{ required: true, message: '请输入售价', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传奖品图片', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传奖品图片', trigger: 'change' }],
stock: [{ required: true, message: '请输入奖品数量', trigger: 'blur' }],
realPro: [{ required: true, message: '请输入概率', trigger: 'blur' }],
})
@ -441,24 +440,6 @@ const handleSubmit = async () => {
padding-right: 10px;
}
.image-upload-wrapper {
width: 100%;
}
.image-preview {
margin-top: 10px;
width: 80px;
height: 80px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.image-preview .el-image {
width: 100%;
height: 100%;
}
:deep(.el-divider__text) {
font-weight: 500;
color: #303133;

View File

@ -222,22 +222,20 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="奖品图片" prop="imgUrl">
<div class="image-upload-wrapper">
<el-input v-model="formData.imgUrl" placeholder="请输入图片URL" />
<div class="image-preview" v-if="formData.imgUrl">
<el-image :src="formData.imgUrl" fit="cover" :preview-src-list="[formData.imgUrl]" />
</div>
</div>
<ImageUpload
v-model="formData.imgUrl"
placeholder="点击上传奖品图"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="详情图片">
<div class="image-upload-wrapper">
<el-input v-model="formData.imgUrlDetail" placeholder="请输入图片URL" />
<div class="image-preview" v-if="formData.imgUrlDetail">
<el-image :src="formData.imgUrlDetail" fit="cover" :preview-src-list="[formData.imgUrlDetail]" />
</div>
</div>
<ImageUpload
v-model="formData.imgUrlDetail"
placeholder="点击上传详情图"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
</el-col>
</el-row>
@ -267,6 +265,7 @@ import {
type PrizeUpdateRequest,
type PrizeItem
} from '@/api/business/goods'
import ImageUpload from '@/components/ImageUpload/index.vue'
interface Props {
modelValue: boolean
@ -359,7 +358,7 @@ const formRules = reactive<FormRules>({
type: [{ required: true, message: '请选择奖品分类', trigger: 'change' }],
rank: [{ required: true, message: '请选择奖品等级', trigger: 'change' }],
price: [{ required: true, message: '请输入售价', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传奖品图片', trigger: 'blur' }],
imgUrl: [{ required: true, message: '请上传奖品图片', trigger: 'change' }],
stock: [{ required: true, message: '请输入奖品数量', trigger: 'blur' }],
realPro: [{ required: true, message: '请输入概率', trigger: 'blur' }],
})
@ -508,24 +507,6 @@ watch(() => props.prizeData, (data) => {
padding-right: 10px;
}
.image-upload-wrapper {
width: 100%;
}
.image-preview {
margin-top: 10px;
width: 80px;
height: 80px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.image-preview .el-image {
width: 100%;
height: 100%;
}
.dialog-footer {
display: flex;
justify-content: space-between;

View File

@ -33,21 +33,12 @@
</el-form-item>
<el-form-item label="图标" prop="icon">
<div class="image-upload-container">
<el-input
v-model="formData.icon"
placeholder="请输入图标URL"
clearable
/>
<div class="image-preview" v-if="formData.icon">
<el-image
:src="formData.icon"
fit="cover"
:preview-src-list="[formData.icon]"
preview-teleported
/>
</div>
</div>
<ImageUpload
v-model="formData.icon"
placeholder="点击上传图标"
:show-url-input="true"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
@ -178,6 +169,7 @@
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus, Delete } from '@element-plus/icons-vue'
import ImageUpload from '@/components/ImageUpload/index.vue'
import {
createSignConfig,
updateSignConfig,
@ -409,30 +401,6 @@ const handleSubmit = async () => {
</script>
<style scoped>
.image-upload-container {
display: flex;
gap: 12px;
align-items: flex-start;
}
.image-upload-container .el-input {
flex: 1;
}
.image-preview {
width: 60px;
height: 60px;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
}
.image-preview .el-image {
width: 100%;
height: 100%;
}
.reward-config-section {
padding: 12px;
background: #f5f7fa;

View File

@ -21,27 +21,12 @@
</el-form-item>
<el-form-item label="图片" prop="image">
<div class="image-upload-container">
<el-input
v-model="formData.image"
placeholder="请输入图片URL"
style="flex: 1"
/>
<el-image
v-if="formData.image"
:src="formData.image"
fit="cover"
class="preview-image"
:preview-src-list="[formData.image]"
preview-teleported
>
<template #error>
<div class="image-error">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
</div>
<ImageUpload
v-model="formData.image"
placeholder="点击上传图片"
url-placeholder="或输入图片URL"
tip="支持 jpg、png、gif、webp 格式,最大 10MB"
/>
</el-form-item>
<el-form-item label="跳转链接" prop="url">
@ -86,7 +71,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import ImageUpload from '@/components/ImageUpload/index.vue'
import {
createWelfareHouse,
updateWelfareHouse,
@ -225,27 +210,5 @@ const handleSubmit = async () => {
</script>
<style scoped>
.image-upload-container {
display: flex;
gap: 12px;
align-items: flex-start;
}
.preview-image {
width: 60px;
height: 60px;
border-radius: 4px;
flex-shrink: 0;
}
.image-error {
display: flex;
justify-content: center;
align-items: center;
width: 60px;
height: 60px;
background: #f5f7fa;
color: #909399;
font-size: 18px;
}
/* No custom styles needed - ImageUpload component handles styling */
</style>

View File

@ -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;
/// <summary>
/// LocalStorageProvider 单元测试
/// </summary>
public class LocalStorageProviderTests : IDisposable
{
private readonly Mock<IWebHostEnvironment> _mockEnvironment;
private readonly Mock<ILogger<LocalStorageProvider>> _mockLogger;
private readonly string _testWebRootPath;
private readonly LocalStorageProvider _provider;
public LocalStorageProviderTests()
{
_mockEnvironment = new Mock<IWebHostEnvironment>();
_mockLogger = new Mock<ILogger<LocalStorageProvider>>();
// 创建临时测试目录
_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
}
/// <summary>
/// LocalStorageProvider 属性测试
/// **Property 4: 存储策略一致性 - 本地存储URL格式**
/// **Validates: Requirements 2.3**
/// </summary>
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
{
// 忽略清理错误
}
}
}
/// <summary>
/// **Feature: image-upload-feature, Property 4: 存储策略一致性 - 本地存储URL格式**
/// *For any* 上传操作返回的URL格式 SHALL 与本地存储类型一致URL以 `/uploads/` 开头
/// **Validates: Requirements 2.3**
/// </summary>
[Property(MaxTest = 100)]
public bool LocalStorage_UrlFormat_ShouldStartWithUploads(PositiveInt seed)
{
var mockEnvironment = new Mock<IWebHostEnvironment>();
var mockLogger = new Mock<ILogger<LocalStorageProvider>>();
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/");
}
/// <summary>
/// **Feature: image-upload-feature, Property 4: 存储策略一致性 - 本地存储URL格式**
/// *For any* 上传操作返回的URL SHALL 包含日期路径格式 yyyy/MM/dd
/// **Validates: Requirements 2.2, 2.3**
/// </summary>
[Property(MaxTest = 100)]
public bool LocalStorage_UrlFormat_ShouldContainDatePath(PositiveInt seed)
{
var mockEnvironment = new Mock<IWebHostEnvironment>();
var mockLogger = new Mock<ILogger<LocalStorageProvider>>();
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);
}
/// <summary>
/// **Feature: image-upload-feature, Property 3: 文件名唯一性**
/// *For any* 两次上传操作,即使上传相同的文件,生成的文件名 SHALL 不同
/// **Validates: Requirements 1.5**
/// </summary>
[Property(MaxTest = 50)]
public bool LocalStorage_FileNames_ShouldBeUnique(PositiveInt seed)
{
var mockEnvironment = new Mock<IWebHostEnvironment>();
var mockLogger = new Mock<ILogger<LocalStorageProvider>>();
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;
}
/// <summary>
/// **Feature: image-upload-feature, Property 4: 存储策略一致性**
/// *For any* 成功上传的文件URL对应的物理文件 SHALL 存在
/// **Validates: Requirements 2.3**
/// </summary>
[Property(MaxTest = 50)]
public bool LocalStorage_UploadedFile_ShouldExistOnDisk(PositiveInt seed)
{
var mockEnvironment = new Mock<IWebHostEnvironment>();
var mockLogger = new Mock<ILogger<LocalStorageProvider>>();
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);
}
}

View File

@ -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;
/// <summary>
/// TencentCosProvider 单元测试
/// 注意: 由于COS SDK依赖UploadAsync和DeleteAsync方法的测试需要在集成测试中进行
/// 这里只测试静态辅助方法和URL生成逻辑
/// </summary>
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
}
/// <summary>
/// TencentCosProvider 属性测试
/// **Property 4: 存储策略一致性 - COS URL格式**
/// **Validates: Requirements 3.3, 3.4**
/// </summary>
public class TencentCosProviderPropertyTests
{
/// <summary>
/// **Feature: image-upload-feature, Property 4: 存储策略一致性 - COS URL格式**
/// *For any* 有效的Domain和objectKey生成的URL SHALL 以配置的Domain开头
/// **Validates: Requirements 3.3**
/// </summary>
[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);
}
/// <summary>
/// **Feature: image-upload-feature, Property 4: 存储策略一致性 - COS URL格式**
/// *For any* 有效的Domain和objectKey生成的URL SHALL 包含完整的对象路径
/// **Validates: Requirements 3.3**
/// </summary>
[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);
}
/// <summary>
/// **Feature: image-upload-feature, Property 3: 文件名唯一性**
/// *For any* 两次调用GenerateUniqueFileName即使使用相同的原始文件名生成的文件名 SHALL 不同
/// **Validates: Requirements 1.5**
/// </summary>
[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;
}
/// <summary>
/// **Feature: image-upload-feature, Property 4: 存储策略一致性 - COS URL格式**
/// *For any* 生成的文件名SHALL 保留原始文件的扩展名(小写)
/// **Validates: Requirements 3.3**
/// </summary>
[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);
}
/// <summary>
/// **Feature: image-upload-feature, Property 4: 存储策略一致性 - COS URL格式**
/// *For any* 生成的URLSHALL 不包含双斜杠(除了协议部分)
/// **Validates: Requirements 3.3**
/// </summary>
[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("//");
}
}

View File

@ -0,0 +1,208 @@
using FsCheck;
using FsCheck.Xunit;
using HoneyBox.Admin.Business.Services;
using Xunit;
namespace HoneyBox.Tests.Services;
/// <summary>
/// UploadService 属性测试
/// **Validates: Requirements 1.2, 1.3, 1.4, 1.5**
/// </summary>
public class UploadServicePropertyTests
{
/// <summary>
/// 允许的图片扩展名
/// </summary>
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
/// <summary>
/// 不允许的扩展名示例
/// </summary>
private static readonly string[] DisallowedExtensions = { ".txt", ".pdf", ".exe", ".doc", ".html", ".js", ".css", ".zip", ".rar", ".mp4" };
#region Property 1:
/// <summary>
/// **Feature: image-upload-feature, Property 1: 文件格式验证**
/// *For any* 上传的文件,如果文件扩展名不在允许列表(jpg, jpeg, png, gif, webp)中,
/// THE Upload_Service SHALL 返回格式错误。
/// **Validates: Requirements 1.2, 1.4**
/// </summary>
[Property(MaxTest = 100)]
public bool InvalidExtension_ShouldBeRejected(PositiveInt seed)
{
// 从不允许的扩展名中选择一个
var extension = DisallowedExtensions[seed.Get % DisallowedExtensions.Length];
// 验证该扩展名被拒绝
return !UploadService.IsValidExtension(extension);
}
/// <summary>
/// **Feature: image-upload-feature, Property 1: 文件格式验证**
/// *For any* 允许的图片扩展名THE Upload_Service SHALL 接受该格式。
/// **Validates: Requirements 1.4**
/// </summary>
[Property(MaxTest = 100)]
public bool ValidExtension_ShouldBeAccepted(PositiveInt seed)
{
// 从允许的扩展名中选择一个
var extension = AllowedExtensions[seed.Get % AllowedExtensions.Length];
// 验证该扩展名被接受
return UploadService.IsValidExtension(extension);
}
/// <summary>
/// **Feature: image-upload-feature, Property 1: 文件格式验证**
/// *For any* 允许的图片扩展名大写形式THE Upload_Service SHALL 接受该格式(大小写不敏感)。
/// **Validates: Requirements 1.4**
/// </summary>
[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:
/// <summary>
/// **Feature: image-upload-feature, Property 2: 文件大小验证**
/// *For any* 上传的文件如果文件大小超过10MBTHE Upload_Service SHALL 返回大小超限错误。
/// **Validates: Requirements 1.3**
/// </summary>
[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);
}
/// <summary>
/// **Feature: image-upload-feature, Property 2: 文件大小验证**
/// *For any* 文件大小在1字节到10MB之间THE Upload_Service SHALL 接受该文件。
/// **Validates: Requirements 1.3**
/// </summary>
[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);
}
/// <summary>
/// **Feature: image-upload-feature, Property 2: 文件大小验证**
/// *For any* 文件大小为0或负数THE Upload_Service SHALL 拒绝该文件。
/// **Validates: Requirements 1.3**
/// </summary>
[Property(MaxTest = 100)]
public bool ZeroOrNegativeFileSize_ShouldBeRejected(NegativeInt negativeSize)
{
// 验证0和负数被拒绝
return !UploadService.IsValidFileSize(0) && !UploadService.IsValidFileSize(negativeSize.Get);
}
#endregion
#region Property 3:
/// <summary>
/// **Feature: image-upload-feature, Property 3: 文件名唯一性**
/// *For any* 两次上传操作,即使上传相同的文件,生成的文件名 SHALL 不同。
/// **Validates: Requirements 1.5**
/// </summary>
[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;
}
/// <summary>
/// **Feature: image-upload-feature, Property 3: 文件名唯一性**
/// *For any* 原始文件名,生成的唯一文件名 SHALL 保留原始扩展名。
/// **Validates: Requirements 1.5**
/// </summary>
[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());
}
/// <summary>
/// **Feature: image-upload-feature, Property 3: 文件名唯一性**
/// *For any* 大写扩展名的文件,生成的唯一文件名 SHALL 将扩展名转为小写。
/// **Validates: Requirements 1.5**
/// </summary>
[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());
}
/// <summary>
/// **Feature: image-upload-feature, Property 3: 文件名唯一性**
/// *For any* 多次生成的文件名,所有文件名 SHALL 互不相同。
/// **Validates: Requirements 1.5**
/// </summary>
[Property(MaxTest = 50)]
public bool MultipleGeneratedFileNames_ShouldAllBeUnique(PositiveInt seed)
{
var originalFileName = "test.jpg";
var generatedNames = new HashSet<string>();
// 生成多个文件名
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
}

View File

@ -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;
/// <summary>
/// UploadService 单元测试
/// </summary>
public class UploadServiceTests
{
private readonly Mock<IAdminConfigService> _mockConfigService;
private readonly Mock<IStorageProvider> _mockLocalProvider;
private readonly Mock<IStorageProvider> _mockCosProvider;
private readonly Mock<ILogger<UploadService>> _mockLogger;
private readonly UploadService _service;
public UploadServiceTests()
{
_mockConfigService = new Mock<IAdminConfigService>();
_mockLocalProvider = new Mock<IStorageProvider>();
_mockCosProvider = new Mock<IStorageProvider>();
_mockLogger = new Mock<ILogger<UploadService>>();
// 设置存储提供者类型
_mockLocalProvider.Setup(p => p.StorageType).Returns("1");
_mockCosProvider.Setup(p => p.StorageType).Returns("3");
var providers = new List<IStorageProvider> { _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<IFormFile>();
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<IFormFile>();
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<IFormFile>();
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<IFormFile>();
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<IFormFile>();
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<UploadSetting>(ConfigKeys.Uploads))
.ReturnsAsync((UploadSetting?)null);
_mockLocalProvider.Setup(p => p.UploadAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string>()))
.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<Stream>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
_mockCosProvider.Verify(p => p.UploadAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task UploadImageAsync_UsesCosStorageWhenConfigured()
{
// Arrange
var uploadSetting = new UploadSetting { Type = "3" };
_mockConfigService.Setup(c => c.GetConfigAsync<UploadSetting>(ConfigKeys.Uploads))
.ReturnsAsync(uploadSetting);
_mockCosProvider.Setup(p => p.UploadAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string>()))
.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<Stream>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
_mockLocalProvider.Verify(p => p.UploadAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task UploadImageAsync_FallsBackToLocalStorageForInvalidType()
{
// Arrange
var uploadSetting = new UploadSetting { Type = "999" }; // 无效类型
_mockConfigService.Setup(c => c.GetConfigAsync<UploadSetting>(ConfigKeys.Uploads))
.ReturnsAsync(uploadSetting);
_mockLocalProvider.Setup(p => p.UploadAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string>()))
.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<Stream>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
}
#endregion
#region Upload Response Tests
[Fact]
public async Task UploadImageAsync_ReturnsCorrectResponse()
{
// Arrange
_mockConfigService.Setup(c => c.GetConfigAsync<UploadSetting>(ConfigKeys.Uploads))
.ReturnsAsync((UploadSetting?)null);
_mockLocalProvider.Setup(p => p.UploadAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string>()))
.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<UploadSetting>(ConfigKeys.Uploads))
.ReturnsAsync((UploadSetting?)null);
_mockLocalProvider.Setup(p => p.UploadAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(UploadResult.Fail("磁盘空间不足"));
var mockFile = CreateMockFile("test.jpg", "image/jpeg", 1024);
// Act & Assert
var ex = await Assert.ThrowsAsync<BusinessException>(() => _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<UploadSetting>(ConfigKeys.Uploads))
.ReturnsAsync((UploadSetting?)null);
var uploadCount = 0;
_mockLocalProvider.Setup(p => p.UploadAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(() => UploadResult.Ok($"/uploads/2026/01/19/file{++uploadCount}.jpg"));
var files = new List<IFormFile>
{
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<Stream>(), It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(3));
}
[Fact]
public async Task UploadImagesAsync_ThrowsOnEmptyList()
{
// Arrange
var files = new List<IFormFile>();
// Act & Assert
var ex = await Assert.ThrowsAsync<BusinessException>(() => _service.UploadImagesAsync(files));
Assert.Equal(BusinessErrorCodes.ValidationFailed, ex.Code);
}
[Fact]
public async Task UploadImagesAsync_ThrowsOnNullList()
{
// Act & Assert
var ex = await Assert.ThrowsAsync<BusinessException>(() => _service.UploadImagesAsync(null!));
Assert.Equal(BusinessErrorCodes.ValidationFailed, ex.Code);
}
#endregion
#region Helper Methods
private static Mock<IFormFile> CreateMockFile(string fileName, string contentType, long length)
{
var mockFile = new Mock<IFormFile>();
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
}