From f72751f62dd1926414e972cb2d3ad1ac2777b73f Mon Sep 17 00:00:00 2001 From: gpu Date: Mon, 19 Jan 2026 23:45:03 +0800 Subject: [PATCH] 333 --- .../Controllers/UploadController.cs | 28 +++- .../Models/Config/ConfigModels.cs | 6 + .../Services/Interfaces/IStorageProvider.cs | 16 ++- .../Services/Interfaces/IUploadService.cs | 9 +- .../Services/Storage/LocalStorageProvider.cs | 11 ++ .../Services/Storage/TencentCosProvider.cs | 113 ++++++++++++++++ .../Services/UploadService.cs | 63 +++++++++ .../admin-web/src/api/business/config.ts | 2 + .../admin-web/src/api/upload.ts | 123 +++++++++++++++++- .../src/components/ImageUpload/index.vue | 5 +- .../src/views/business/config/uploads.vue | 23 ++++ 11 files changed, 393 insertions(+), 6 deletions(-) diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Controllers/UploadController.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Controllers/UploadController.cs index 1d349067..4e741359 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/Controllers/UploadController.cs +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Controllers/UploadController.cs @@ -1,5 +1,6 @@ using HoneyBox.Admin.Business.Attributes; using HoneyBox.Admin.Business.Models; +using HoneyBox.Admin.Business.Models.Upload; using HoneyBox.Admin.Business.Services.Interfaces; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -20,7 +21,32 @@ public class UploadController : BusinessControllerBase } /// - /// 上传单个图片 + /// 获取预签名上传URL(客户端直传COS) + /// + /// 请求参数 + /// 预签名URL信息,如果不支持直传则返回null + [HttpPost("presigned-url")] + [BusinessPermission("upload:image")] + public async Task GetPresignedUrl([FromBody] GetPresignedUrlRequest request) + { + try + { + var result = await _uploadService.GetPresignedUploadUrlAsync(request); + if (result == null) + { + // 不支持直传,返回特定标识让前端走服务端上传 + return Ok(new { supportsDirectUpload = false }, "当前存储配置不支持客户端直传"); + } + return Ok(result, "获取成功"); + } + catch (BusinessException ex) + { + return Error(ex.Code, ex.Message); + } + } + + /// + /// 上传单个图片(服务端上传,用于本地存储或降级场景) /// /// 图片文件 /// 上传结果 diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Models/Config/ConfigModels.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Models/Config/ConfigModels.cs index 6f36db7c..08978646 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/Models/Config/ConfigModels.cs +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Models/Config/ConfigModels.cs @@ -735,6 +735,12 @@ public class UploadSetting [JsonPropertyName("type")] public string? Type { get; set; } + /// + /// 腾讯云AppId + /// + [JsonPropertyName("AppId")] + public string? AppId { get; set; } + /// /// 空间名称/Bucket /// diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IStorageProvider.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IStorageProvider.cs index c8e7dcaf..14fdab40 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IStorageProvider.cs +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IStorageProvider.cs @@ -15,7 +15,12 @@ public interface IStorageProvider string StorageType { get; } /// - /// 上传文件 + /// 是否支持客户端直传 + /// + bool SupportsDirectUpload { get; } + + /// + /// 上传文件(服务端上传) /// /// 文件流 /// 文件名 @@ -23,6 +28,15 @@ public interface IStorageProvider /// 上传结果 Task UploadAsync(Stream fileStream, string fileName, string contentType); + /// + /// 获取预签名上传URL(客户端直传) + /// + /// 文件名 + /// 内容类型 + /// URL有效期(秒),默认600秒 + /// 预签名URL信息 + Task GetPresignedUploadUrlAsync(string fileName, string contentType, int expiresInSeconds = 600); + /// /// 删除文件 /// diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IUploadService.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IUploadService.cs index e5b6cd77..9a774f7e 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IUploadService.cs +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Interfaces/IUploadService.cs @@ -9,7 +9,7 @@ namespace HoneyBox.Admin.Business.Services.Interfaces; public interface IUploadService { /// - /// 上传图片 + /// 上传图片(服务端上传,用于本地存储或不支持直传的场景) /// /// 上传的文件 /// 上传响应 @@ -21,4 +21,11 @@ public interface IUploadService /// 上传的文件列表 /// 上传响应列表 Task> UploadImagesAsync(List files); + + /// + /// 获取预签名上传URL(客户端直传) + /// + /// 请求参数 + /// 预签名URL响应,如果不支持直传则返回null + Task GetPresignedUploadUrlAsync(GetPresignedUrlRequest request); } diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/LocalStorageProvider.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/LocalStorageProvider.cs index e93e1460..5aaf2fe2 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/LocalStorageProvider.cs +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/LocalStorageProvider.cs @@ -26,6 +26,9 @@ public class LocalStorageProvider : IStorageProvider /// public string StorageType => "1"; + /// + public bool SupportsDirectUpload => false; + /// public async Task UploadAsync(Stream fileStream, string fileName, string contentType) { @@ -101,6 +104,14 @@ public class LocalStorageProvider : IStorageProvider } } + /// + public Task GetPresignedUploadUrlAsync(string fileName, string contentType, int expiresInSeconds = 600) + { + // 本地存储不支持客户端直传,返回null + _logger.LogDebug("本地存储不支持客户端直传"); + return Task.FromResult(null); + } + /// /// 生成唯一文件名 /// 格式: {timestamp}_{guid}{extension} diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/TencentCosProvider.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/TencentCosProvider.cs index ae7bc623..b8488668 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/TencentCosProvider.cs +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/Storage/TencentCosProvider.cs @@ -5,6 +5,8 @@ using HoneyBox.Admin.Business.Models.Config; using HoneyBox.Admin.Business.Models.Upload; using HoneyBox.Admin.Business.Services.Interfaces; using Microsoft.Extensions.Logging; +using System.Security.Cryptography; +using System.Text; namespace HoneyBox.Admin.Business.Services.Storage; @@ -29,6 +31,9 @@ public class TencentCosProvider : IStorageProvider /// public string StorageType => "3"; + /// + public bool SupportsDirectUpload => true; + /// public async Task UploadAsync(Stream fileStream, string fileName, string contentType) { @@ -153,6 +158,114 @@ public class TencentCosProvider : IStorageProvider } } + /// + public Task GetPresignedUploadUrlAsync(string fileName, string contentType, int expiresInSeconds = 600) + { + try + { + var setting = _getUploadSetting(); + if (setting == null) + { + _logger.LogWarning("获取预签名URL失败,配置无效"); + return Task.FromResult(null); + } + + // 验证必要的配置参数 + var validationError = ValidateConfig(setting); + if (validationError != null) + { + _logger.LogWarning("获取预签名URL失败: {Error}", validationError); + return Task.FromResult(null); + } + + // 生成日期目录路径: 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}"; + + // 手动生成预签名URL + var presignedUrl = GeneratePresignedUrl(setting, objectKey, "PUT", contentType, expiresInSeconds); + + // 生成访问URL + var fileUrl = GenerateAccessUrl(setting.Domain!, objectKey); + + _logger.LogInformation("生成预签名URL成功: {ObjectKey}, 有效期: {ExpiresIn}秒", objectKey, expiresInSeconds); + + return Task.FromResult(new PresignedUrlResponse + { + UploadUrl = presignedUrl, + FileUrl = fileUrl, + ObjectKey = objectKey, + ExpiresIn = expiresInSeconds, + StorageType = StorageType + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "生成预签名URL异常: {FileName}", fileName); + return Task.FromResult(null); + } + } + + /// + /// 手动生成腾讯云COS预签名URL + /// + private static string GeneratePresignedUrl(UploadSetting setting, string objectKey, string httpMethod, string contentType, int expiresInSeconds) + { + var startTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var endTime = startTime + expiresInSeconds; + var keyTime = $"{startTime};{endTime}"; + + // 构建COS主机名 + var host = $"{setting.Bucket}.cos.{setting.Region}.myqcloud.com"; + var urlPath = $"/{objectKey}"; + + // 1. 生成 SignKey + var signKey = HmacSha1(setting.AccessKeySecret!, keyTime); + + // 2. 生成 HttpString + var httpString = $"{httpMethod.ToLowerInvariant()}\n{urlPath}\n\nhost={host.ToLowerInvariant()}\n"; + + // 3. 生成 StringToSign + var sha1HttpString = Sha1Hash(httpString); + var stringToSign = $"sha1\n{keyTime}\n{sha1HttpString}\n"; + + // 4. 生成 Signature + var signature = HmacSha1(signKey, stringToSign); + + // 5. 构建 Authorization + var authorization = $"q-sign-algorithm=sha1&q-ak={setting.AccessKeyId}&q-sign-time={keyTime}&q-key-time={keyTime}&q-header-list=host&q-url-param-list=&q-signature={signature}"; + + // 6. 构建完整URL + var presignedUrl = $"https://{host}{urlPath}?{authorization}"; + + return presignedUrl; + } + + /// + /// HMAC-SHA1 签名 + /// + private static string HmacSha1(string key, string data) + { + using var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(key)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data)); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + /// + /// SHA1 哈希 + /// + private static string Sha1Hash(string data) + { + var hash = SHA1.HashData(Encoding.UTF8.GetBytes(data)); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + /// /// 验证配置参数 /// diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/UploadService.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/UploadService.cs index b487ce88..d5dd1aca 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/Services/UploadService.cs +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Services/UploadService.cs @@ -203,4 +203,67 @@ public class UploadService : IUploadService _logger.LogDebug("使用存储提供者: {StorageType}", provider.StorageType); return provider; } + + /// + public async Task GetPresignedUploadUrlAsync(GetPresignedUrlRequest request) + { + // 验证请求参数 + if (string.IsNullOrWhiteSpace(request.FileName)) + { + throw new BusinessException(BusinessErrorCodes.ValidationFailed, "文件名不能为空"); + } + + // 验证文件扩展名 + var extension = Path.GetExtension(request.FileName); + if (!IsValidExtension(extension)) + { + throw new BusinessException(BusinessErrorCodes.ValidationFailed, "只支持 jpg、jpeg、png、gif、webp 格式的图片"); + } + + // 验证文件大小 + if (request.FileSize > 0 && !IsValidFileSize(request.FileSize)) + { + throw new BusinessException(BusinessErrorCodes.ValidationFailed, "文件大小不能超过10MB"); + } + + // 获取存储提供者 + var provider = await GetStorageProviderAsync(); + + // 检查是否支持客户端直传 + if (!provider.SupportsDirectUpload) + { + _logger.LogDebug("当前存储提供者不支持客户端直传,将使用服务端上传"); + return null; + } + + // 获取预签名URL + var contentType = request.ContentType; + if (string.IsNullOrWhiteSpace(contentType)) + { + contentType = GetContentTypeByExtension(extension); + } + + var result = await provider.GetPresignedUploadUrlAsync(request.FileName, contentType); + if (result == null) + { + throw new BusinessException(BusinessErrorCodes.OperationFailed, "获取上传URL失败,请检查存储配置"); + } + + return result; + } + + /// + /// 根据扩展名获取ContentType + /// + private static string GetContentTypeByExtension(string extension) + { + return extension.ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + _ => "application/octet-stream" + }; + } } diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/business/config.ts b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/business/config.ts index f28a3662..7f5bc554 100644 --- a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/business/config.ts +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/business/config.ts @@ -206,6 +206,8 @@ export const StorageTypeLabels: Record = { export interface UploadSetting { /** 存储类型 1本地 2阿里云 3腾讯云 */ type?: string + /** 腾讯云AppId */ + AppId?: string /** 空间名称/Bucket */ Bucket?: string /** 地域 */ diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/upload.ts b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/upload.ts index d140bab3..3488e588 100644 --- a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/upload.ts +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/api/upload.ts @@ -1,4 +1,5 @@ import { request, type ApiResponse } from '@/utils/request' +import axios from 'axios' /** 上传响应 */ export interface UploadResponse { @@ -10,13 +11,131 @@ export interface UploadResponse { fileSize: number } +/** 预签名URL请求 */ +export interface GetPresignedUrlRequest { + /** 原始文件名 */ + fileName: string + /** 文件MIME类型 */ + contentType: string + /** 文件大小(字节) */ + fileSize: number +} + +/** 预签名URL响应 */ +export interface PresignedUrlResponse { + /** 预签名上传URL */ + uploadUrl: string + /** 文件最终访问URL */ + fileUrl: string + /** 对象Key(COS路径) */ + objectKey: string + /** URL过期时间(秒) */ + expiresIn: number + /** 存储类型 */ + storageType: string +} + +/** 不支持直传的响应 */ +export interface DirectUploadNotSupportedResponse { + supportsDirectUpload: false +} + /** - * 上传文件 + * 获取预签名上传URL + * @param params 请求参数 + * @returns 预签名URL信息 + */ +export function getPresignedUrl( + params: GetPresignedUrlRequest +): Promise> { + return request({ + url: '/admin/upload/presigned-url', + method: 'POST', + data: params + }) +} + +/** + * 直接上传文件到COS + * @param uploadUrl 预签名上传URL + * @param file 文件对象 + * @param contentType 文件MIME类型 + * @param onProgress 上传进度回调 + */ +export async function uploadToCos( + uploadUrl: string, + file: File, + contentType: string, + onProgress?: (percent: number) => void +): Promise { + await axios.put(uploadUrl, file, { + headers: { + 'Content-Type': contentType + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total && onProgress) { + const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total) + onProgress(percent) + } + } + }) +} + +/** + * 上传文件(智能选择直传或服务端上传) * @param file 文件对象 * @param onProgress 上传进度回调 * @returns 上传结果 */ -export function uploadFile( +export async function uploadFile( + file: File, + onProgress?: (percent: number) => void +): Promise> { + // 1. 先尝试获取预签名URL + const presignedRes = await getPresignedUrl({ + fileName: file.name, + contentType: file.type || 'application/octet-stream', + fileSize: file.size + }) + + // 2. 检查是否支持直传 + if (presignedRes.code === 0 && presignedRes.data) { + const data = presignedRes.data + + // 检查是否支持直传 + if ('supportsDirectUpload' in data && data.supportsDirectUpload === false) { + // 不支持直传,走服务端上传 + return uploadFileToServer(file, onProgress) + } + + // 支持直传,直接上传到COS + const presignedData = data as PresignedUrlResponse + await uploadToCos(presignedData.uploadUrl, file, file.type || 'application/octet-stream', onProgress) + + // 返回上传结果 + return { + code: 0, + message: '上传成功', + data: { + url: presignedData.fileUrl, + fileName: file.name, + fileSize: file.size + } + } + } + + // 获取预签名URL失败,降级到服务端上传 + console.warn('获取预签名URL失败,降级到服务端上传:', presignedRes.message) + return uploadFileToServer(file, onProgress) +} + +/** + * 上传文件到服务端(降级方案) + * @param file 文件对象 + * @param onProgress 上传进度回调 + * @returns 上传结果 + */ +export function uploadFileToServer( file: File, onProgress?: (percent: number) => void ): Promise> { diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/ImageUpload/index.vue b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/ImageUpload/index.vue index 1aa1b1ac..e629ce9b 100644 --- a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/ImageUpload/index.vue +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/components/ImageUpload/index.vue @@ -5,6 +5,7 @@
>() // 计算属性 const acceptTypes = computed(() => props.accept) @@ -282,7 +284,8 @@ const handleUpload = async (options: UploadRequestOptions) => { // 预览图片 const handlePreview = () => { - // el-image 组件自带预览功能,点击图片即可预览 + // 手动触发 el-image 的预览功能 + imageRef.value?.$el?.querySelector('img')?.click() } // 删除图片 diff --git a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/config/uploads.vue b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/config/uploads.vue index 4f99d0ad..de74564d 100644 --- a/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/config/uploads.vue +++ b/server/HoneyBox/src/HoneyBox.Admin/admin-web/src/views/business/config/uploads.vue @@ -54,6 +54,22 @@ {{ formData.type === StorageType.Aliyun ? '阿里云OSS配置' : '腾讯云COS配置' }} + + + + + +
+ 腾讯云账号的AppId,可在控制台账号信息中查看 +
+
+
+
+ @@ -174,6 +190,7 @@ const formRef = ref() // 表单数据 interface FormDataType { type: string + AppId: string Bucket: string Region: string AccessKeyId: string @@ -183,6 +200,7 @@ interface FormDataType { const formData = reactive({ type: StorageType.Local, + AppId: '', Bucket: '', Region: '', AccessKeyId: '', @@ -230,6 +248,7 @@ const formRules: FormRules = { const handleTypeChange = () => { // 切换到本地存储时,清空云存储配置 if (formData.type === StorageType.Local) { + formData.AppId = '' formData.Bucket = '' formData.Region = '' formData.AccessKeyId = '' @@ -249,6 +268,7 @@ const loadData = async () => { const data = res.data.value Object.assign(formData, { type: data.type || StorageType.Local, + AppId: data.AppId || '', Bucket: data.Bucket || '', Region: data.Region || '', AccessKeyId: data.AccessKeyId || '', @@ -283,6 +303,9 @@ const handleSave = async () => { // 仅在云存储模式下提交云存储配置 if (isCloudStorage.value) { + if (formData.type === StorageType.Tencent) { + submitData.AppId = formData.AppId + } submitData.Bucket = formData.Bucket submitData.Region = formData.Region submitData.AccessKeyId = formData.AccessKeyId