This commit is contained in:
gpu 2026-01-19 23:45:03 +08:00
parent 29b231c417
commit f72751f62d
11 changed files with 393 additions and 6 deletions

View File

@ -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
}
/// <summary>
/// 上传单个图片
/// 获取预签名上传URL客户端直传COS
/// </summary>
/// <param name="request">请求参数</param>
/// <returns>预签名URL信息如果不支持直传则返回null</returns>
[HttpPost("presigned-url")]
[BusinessPermission("upload:image")]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// 上传单个图片(服务端上传,用于本地存储或降级场景)
/// </summary>
/// <param name="file">图片文件</param>
/// <returns>上传结果</returns>

View File

@ -735,6 +735,12 @@ public class UploadSetting
[JsonPropertyName("type")]
public string? Type { get; set; }
/// <summary>
/// 腾讯云AppId
/// </summary>
[JsonPropertyName("AppId")]
public string? AppId { get; set; }
/// <summary>
/// 空间名称/Bucket
/// </summary>

View File

@ -15,7 +15,12 @@ public interface IStorageProvider
string StorageType { get; }
/// <summary>
/// 上传文件
/// 是否支持客户端直传
/// </summary>
bool SupportsDirectUpload { get; }
/// <summary>
/// 上传文件(服务端上传)
/// </summary>
/// <param name="fileStream">文件流</param>
/// <param name="fileName">文件名</param>
@ -23,6 +28,15 @@ public interface IStorageProvider
/// <returns>上传结果</returns>
Task<UploadResult> UploadAsync(Stream fileStream, string fileName, string contentType);
/// <summary>
/// 获取预签名上传URL客户端直传
/// </summary>
/// <param name="fileName">文件名</param>
/// <param name="contentType">内容类型</param>
/// <param name="expiresInSeconds">URL有效期默认600秒</param>
/// <returns>预签名URL信息</returns>
Task<PresignedUrlResponse?> GetPresignedUploadUrlAsync(string fileName, string contentType, int expiresInSeconds = 600);
/// <summary>
/// 删除文件
/// </summary>

View File

@ -9,7 +9,7 @@ namespace HoneyBox.Admin.Business.Services.Interfaces;
public interface IUploadService
{
/// <summary>
/// 上传图片
/// 上传图片(服务端上传,用于本地存储或不支持直传的场景)
/// </summary>
/// <param name="file">上传的文件</param>
/// <returns>上传响应</returns>
@ -21,4 +21,11 @@ public interface IUploadService
/// <param name="files">上传的文件列表</param>
/// <returns>上传响应列表</returns>
Task<List<UploadResponse>> UploadImagesAsync(List<IFormFile> files);
/// <summary>
/// 获取预签名上传URL客户端直传
/// </summary>
/// <param name="request">请求参数</param>
/// <returns>预签名URL响应如果不支持直传则返回null</returns>
Task<PresignedUrlResponse?> GetPresignedUploadUrlAsync(GetPresignedUrlRequest request);
}

View File

@ -26,6 +26,9 @@ public class LocalStorageProvider : IStorageProvider
/// <inheritdoc />
public string StorageType => "1";
/// <inheritdoc />
public bool SupportsDirectUpload => false;
/// <inheritdoc />
public async Task<UploadResult> UploadAsync(Stream fileStream, string fileName, string contentType)
{
@ -101,6 +104,14 @@ public class LocalStorageProvider : IStorageProvider
}
}
/// <inheritdoc />
public Task<PresignedUrlResponse?> GetPresignedUploadUrlAsync(string fileName, string contentType, int expiresInSeconds = 600)
{
// 本地存储不支持客户端直传返回null
_logger.LogDebug("本地存储不支持客户端直传");
return Task.FromResult<PresignedUrlResponse?>(null);
}
/// <summary>
/// 生成唯一文件名
/// 格式: {timestamp}_{guid}{extension}

View File

@ -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
/// <inheritdoc />
public string StorageType => "3";
/// <inheritdoc />
public bool SupportsDirectUpload => true;
/// <inheritdoc />
public async Task<UploadResult> UploadAsync(Stream fileStream, string fileName, string contentType)
{
@ -153,6 +158,114 @@ public class TencentCosProvider : IStorageProvider
}
}
/// <inheritdoc />
public Task<PresignedUrlResponse?> GetPresignedUploadUrlAsync(string fileName, string contentType, int expiresInSeconds = 600)
{
try
{
var setting = _getUploadSetting();
if (setting == null)
{
_logger.LogWarning("获取预签名URL失败配置无效");
return Task.FromResult<PresignedUrlResponse?>(null);
}
// 验证必要的配置参数
var validationError = ValidateConfig(setting);
if (validationError != null)
{
_logger.LogWarning("获取预签名URL失败: {Error}", validationError);
return Task.FromResult<PresignedUrlResponse?>(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<PresignedUrlResponse?>(new PresignedUrlResponse
{
UploadUrl = presignedUrl,
FileUrl = fileUrl,
ObjectKey = objectKey,
ExpiresIn = expiresInSeconds,
StorageType = StorageType
});
}
catch (Exception ex)
{
_logger.LogError(ex, "生成预签名URL异常: {FileName}", fileName);
return Task.FromResult<PresignedUrlResponse?>(null);
}
}
/// <summary>
/// 手动生成腾讯云COS预签名URL
/// </summary>
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;
}
/// <summary>
/// HMAC-SHA1 签名
/// </summary>
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();
}
/// <summary>
/// SHA1 哈希
/// </summary>
private static string Sha1Hash(string data)
{
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(data));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
/// <summary>
/// 验证配置参数
/// </summary>

View File

@ -203,4 +203,67 @@ public class UploadService : IUploadService
_logger.LogDebug("使用存储提供者: {StorageType}", provider.StorageType);
return provider;
}
/// <inheritdoc />
public async Task<PresignedUrlResponse?> 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;
}
/// <summary>
/// 根据扩展名获取ContentType
/// </summary>
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"
};
}
}

View File

@ -206,6 +206,8 @@ export const StorageTypeLabels: Record<string, string> = {
export interface UploadSetting {
/** 存储类型 1本地 2阿里云 3腾讯云 */
type?: string
/** 腾讯云AppId */
AppId?: string
/** 空间名称/Bucket */
Bucket?: string
/** 地域 */

View File

@ -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
/** 对象KeyCOS路径 */
objectKey: string
/** URL过期时间 */
expiresIn: number
/** 存储类型 */
storageType: string
}
/** 不支持直传的响应 */
export interface DirectUploadNotSupportedResponse {
supportsDirectUpload: false
}
/**
*
* URL
* @param params
* @returns URL信息
*/
export function getPresignedUrl(
params: GetPresignedUrlRequest
): Promise<ApiResponse<PresignedUrlResponse | DirectUploadNotSupportedResponse>> {
return request<PresignedUrlResponse | DirectUploadNotSupportedResponse>({
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<void> {
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<ApiResponse<UploadResponse>> {
// 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<ApiResponse<UploadResponse>> {

View File

@ -5,6 +5,7 @@
<!-- 已有图片时显示预览 -->
<div v-if="modelValue" class="image-preview-wrapper">
<el-image
ref="imageRef"
:src="modelValue"
fit="cover"
class="preview-image"
@ -136,6 +137,7 @@ const uploading = ref(false)
const uploadProgress = ref(0)
const urlInputValue = ref('')
const errorMessage = ref('')
const imageRef = ref<InstanceType<typeof import('element-plus')['ElImage']>>()
//
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()
}
//

View File

@ -54,6 +54,22 @@
{{ formData.type === StorageType.Aliyun ? '阿里云OSS配置' : '腾讯云COS配置' }}
</el-divider>
<!-- 腾讯云COS需要AppId -->
<el-row v-if="formData.type === StorageType.Tencent" :gutter="24">
<el-col :span="12">
<el-form-item label="AppId" prop="AppId">
<el-input
v-model="formData.AppId"
placeholder="请输入腾讯云AppId"
clearable
/>
<div class="form-tip">
腾讯云账号的AppId可在控制台账号信息中查看
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="空间名称(Bucket)" prop="Bucket">
@ -174,6 +190,7 @@ const formRef = ref<FormInstance>()
//
interface FormDataType {
type: string
AppId: string
Bucket: string
Region: string
AccessKeyId: string
@ -183,6 +200,7 @@ interface FormDataType {
const formData = reactive<FormDataType>({
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