333
This commit is contained in:
parent
8b17e1b84d
commit
29b231c417
295
.kiro/specs/image-upload-feature/design.md
Normal file
295
.kiro/specs/image-upload-feature/design.md
Normal 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* 上传的文件,如果文件大小超过10MB,THE Upload_Service SHALL 返回大小超限错误。
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 3: 文件名唯一性
|
||||
|
||||
*For any* 两次上传操作,即使上传相同的文件,生成的文件名 SHALL 不同。
|
||||
|
||||
**Validates: Requirements 1.5**
|
||||
|
||||
### Property 4: 存储策略一致性
|
||||
|
||||
*For any* 上传操作,返回的URL格式 SHALL 与当前配置的存储类型一致:
|
||||
- 本地存储: URL以 `/uploads/` 开头
|
||||
- 腾讯云COS: URL以配置的 Domain 开头
|
||||
|
||||
**Validates: Requirements 2.3, 3.3**
|
||||
|
||||
### Property 5: 上传成功返回有效URL
|
||||
|
||||
*For any* 成功的上传操作,返回的URL SHALL 是可访问的有效图片地址。
|
||||
|
||||
**Validates: Requirements 1.1**
|
||||
|
||||
## Error Handling
|
||||
|
||||
| 错误场景 | 错误码 | 错误信息 |
|
||||
|---------|--------|---------|
|
||||
| 文件为空 | 400 | 请选择要上传的文件 |
|
||||
| 文件格式不支持 | 400 | 只支持 jpg、jpeg、png、gif、webp 格式的图片 |
|
||||
| 文件大小超限 | 400 | 文件大小不能超过10MB |
|
||||
| 存储配置无效 | 500 | 存储配置无效,请检查上传配置 |
|
||||
| COS上传失败 | 500 | 上传到云存储失败: {具体错误} |
|
||||
| 本地存储失败 | 500 | 保存文件失败: {具体错误} |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 单元测试
|
||||
|
||||
1. **UploadService 测试**
|
||||
- 测试文件格式验证
|
||||
- 测试文件大小验证
|
||||
- 测试文件名生成唯一性
|
||||
- 测试存储提供者选择逻辑
|
||||
|
||||
2. **LocalStorageProvider 测试**
|
||||
- 测试目录创建
|
||||
- 测试文件保存
|
||||
- 测试URL生成
|
||||
|
||||
3. **TencentCosProvider 测试**
|
||||
- 测试配置读取
|
||||
- 测试上传逻辑(使用Mock)
|
||||
|
||||
### 集成测试
|
||||
|
||||
1. **API 集成测试**
|
||||
- 测试上传接口完整流程
|
||||
- 测试错误响应格式
|
||||
|
||||
### 前端测试
|
||||
|
||||
1. **ImageUpload 组件测试**
|
||||
- 测试文件选择
|
||||
- 测试上传进度显示
|
||||
- 测试v-model绑定
|
||||
93
.kiro/specs/image-upload-feature/requirements.md
Normal file
93
.kiro/specs/image-upload-feature/requirements.md
Normal 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数据
|
||||
175
.kiro/specs/image-upload-feature/tasks.md
Normal file
175
.kiro/specs/image-upload-feature/tasks.md
Normal 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 组件已创建基础版本,需要根据后端接口进行优化
|
||||
- 业务页面改造时需保持原有表单验证逻辑不变
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 接口的服务类并注册
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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* 生成的URL,SHALL 不包含双斜杠(除了协议部分)
|
||||
/// **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("//");
|
||||
}
|
||||
}
|
||||
|
|
@ -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* 上传的文件,如果文件大小超过10MB,THE 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user