This commit is contained in:
parent
56c18c0343
commit
d1e3672808
@ -30,6 +30,7 @@
|
||||
<PackageReference Include="QuestPDF" Version="2025.7.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.49" />
|
||||
<PackageReference Include="Tencent.QCloud.Cos.Sts.Sdk" Version="3.0.5" />
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.10" />
|
||||
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" />
|
||||
</ItemGroup>
|
||||
|
||||
161
src/Oms.Application/Services/Storage/FileStorageService.cs
Normal file
161
src/Oms.Application/Services/Storage/FileStorageService.cs
Normal file
@ -0,0 +1,161 @@
|
||||
using Fake.Application;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Oms.Application.Storage;
|
||||
|
||||
namespace Oms.Application.Services.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 文件存储直传 / 临时访问。私有桶:前端拿凭证直传 COS,后端只存 key。
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ApiExplorerSettings(GroupName = "OMS-Storage")]
|
||||
public class FileStorageService : ApplicationService
|
||||
{
|
||||
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
|
||||
|
||||
/// <summary>
|
||||
/// 是否支持前端直传(Cos=true)。
|
||||
/// </summary>
|
||||
[HttpGet("/file-storage/capabilities")]
|
||||
public object GetCapabilities() => new
|
||||
{
|
||||
supportsClientDirectUpload = FileStorage.SupportsClientDirectUpload,
|
||||
provider = FileStorage.GetType().Name
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 签发预签名 PUT 上传地址。前端对该 URL 直接 PUT 文件体,成功后把返回的 key 交给业务接口落库。
|
||||
/// </summary>
|
||||
[HttpPost("/file-storage/presign-upload")]
|
||||
public async Task<PresignUploadOutput> PresignUploadAsync([FromBody] PresignUploadInput input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.FileName))
|
||||
{
|
||||
throw new BusinessException("fileName 不能为空");
|
||||
}
|
||||
|
||||
var ticket = await FileStorage.CreatePresignedUploadAsync(
|
||||
input.Folder ?? "misc",
|
||||
input.FileName.Trim(),
|
||||
input.ContentType);
|
||||
|
||||
return new PresignUploadOutput
|
||||
{
|
||||
Key = ticket.Key,
|
||||
UploadUrl = ticket.UploadUrl,
|
||||
Method = ticket.Method,
|
||||
ContentType = ticket.ContentType,
|
||||
Headers = ticket.Headers,
|
||||
ExpireAt = ticket.ExpireAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 签发 STS 临时密钥。前端用 cos-js-sdk-v5 等 SDK 直传;落库只存 key。
|
||||
/// </summary>
|
||||
[HttpPost("/file-storage/sts-upload")]
|
||||
public async Task<StsUploadOutput> StsUploadAsync([FromBody] PresignUploadInput input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.FileName))
|
||||
{
|
||||
throw new BusinessException("fileName 不能为空");
|
||||
}
|
||||
|
||||
var ticket = await FileStorage.CreateStsUploadAsync(
|
||||
input.Folder ?? "misc",
|
||||
input.FileName.Trim());
|
||||
|
||||
return new StsUploadOutput
|
||||
{
|
||||
Key = ticket.Key,
|
||||
CosKey = ticket.CosKey,
|
||||
Bucket = ticket.Bucket,
|
||||
Region = ticket.Region,
|
||||
TmpSecretId = ticket.TmpSecretId,
|
||||
TmpSecretKey = ticket.TmpSecretKey,
|
||||
SessionToken = ticket.SessionToken,
|
||||
StartTime = ticket.StartTime,
|
||||
ExpiredTime = ticket.ExpiredTime
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按库内 key 生成临时访问地址(私有桶预签名 GET)。
|
||||
/// </summary>
|
||||
[HttpGet("/file-storage/access-url")]
|
||||
public AccessUrlOutput GetAccessUrl([FromQuery] string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
throw new BusinessException("key 不能为空");
|
||||
}
|
||||
|
||||
var storageKey = FileStorage.ToStorageKey(key) ?? key.Trim();
|
||||
return new AccessUrlOutput
|
||||
{
|
||||
Key = storageKey,
|
||||
Url = FileStorage.GetPublicUrl(storageKey)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将完整 URL / 相对路径规范为库内 key(业务保存前可调用)。
|
||||
/// </summary>
|
||||
[HttpPost("/file-storage/normalize-key")]
|
||||
public NormalizeKeyOutput NormalizeKey([FromBody] NormalizeKeyInput input)
|
||||
{
|
||||
var key = FileStorage.ToStorageKey(input.PathOrUrl);
|
||||
return new NormalizeKeyOutput { Key = key };
|
||||
}
|
||||
}
|
||||
|
||||
public class PresignUploadInput
|
||||
{
|
||||
/// <summary>业务目录:avatars / sku / parcel / recharge / order-files / xhs 等。</summary>
|
||||
public string? Folder { get; set; }
|
||||
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
public string? ContentType { get; set; }
|
||||
}
|
||||
|
||||
public class PresignUploadOutput
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string UploadUrl { get; set; } = string.Empty;
|
||||
public string Method { get; set; } = "PUT";
|
||||
public string? ContentType { get; set; }
|
||||
public IReadOnlyDictionary<string, string> Headers { get; set; } =
|
||||
new Dictionary<string, string>();
|
||||
public DateTime ExpireAt { get; set; }
|
||||
}
|
||||
|
||||
public class StsUploadOutput
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string CosKey { get; set; } = string.Empty;
|
||||
public string Bucket { get; set; } = string.Empty;
|
||||
public string Region { get; set; } = string.Empty;
|
||||
public string TmpSecretId { get; set; } = string.Empty;
|
||||
public string TmpSecretKey { get; set; } = string.Empty;
|
||||
public string SessionToken { get; set; } = string.Empty;
|
||||
public long StartTime { get; set; }
|
||||
public long ExpiredTime { get; set; }
|
||||
}
|
||||
|
||||
public class AccessUrlOutput
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Url { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class NormalizeKeyInput
|
||||
{
|
||||
public string? PathOrUrl { get; set; }
|
||||
}
|
||||
|
||||
public class NormalizeKeyOutput
|
||||
{
|
||||
public string? Key { get; set; }
|
||||
}
|
||||
50
src/Oms.Application/Storage/DirectUploadTickets.cs
Normal file
50
src/Oms.Application/Storage/DirectUploadTickets.cs
Normal file
@ -0,0 +1,50 @@
|
||||
namespace Oms.Application.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 预签名 PUT 直传凭证(前端直接 PUT 到 COS,文件不经后端)。
|
||||
/// </summary>
|
||||
public sealed class PresignedUploadTicket
|
||||
{
|
||||
/// <summary>库内应保存的逻辑 key(不含 KeyPrefix)。</summary>
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>一次性 PUT 地址(含签名)。</summary>
|
||||
public string UploadUrl { get; init; } = string.Empty;
|
||||
|
||||
public string Method { get; init; } = "PUT";
|
||||
|
||||
/// <summary>PUT 时必须带上的 Content-Type(若签发时绑定了则必填)。</summary>
|
||||
public string? ContentType { get; init; }
|
||||
|
||||
/// <summary>PUT 时必须原样携带的请求头。</summary>
|
||||
public IReadOnlyDictionary<string, string> Headers { get; init; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public DateTime ExpireAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// STS 临时密钥直传凭证(前端用 cos-js-sdk 等上传)。
|
||||
/// </summary>
|
||||
public sealed class StsUploadTicket
|
||||
{
|
||||
/// <summary>库内应保存的逻辑 key。</summary>
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>COS 对象完整键(含 KeyPrefix),SDK putObject 用。</summary>
|
||||
public string CosKey { get; init; } = string.Empty;
|
||||
|
||||
public string Bucket { get; init; } = string.Empty;
|
||||
|
||||
public string Region { get; init; } = string.Empty;
|
||||
|
||||
public string TmpSecretId { get; init; } = string.Empty;
|
||||
|
||||
public string TmpSecretKey { get; init; } = string.Empty;
|
||||
|
||||
public string SessionToken { get; init; } = string.Empty;
|
||||
|
||||
public long StartTime { get; init; }
|
||||
|
||||
public long ExpiredTime { get; init; }
|
||||
}
|
||||
@ -5,7 +5,7 @@ namespace Oms.Application.Storage;
|
||||
/// </summary>
|
||||
public class FileStorageOptions
|
||||
{
|
||||
public const string SectionName = "Oms:FileStorage";
|
||||
public const string SectionName = "FileStorage";
|
||||
|
||||
/// <summary>
|
||||
/// 存储提供方:Local | Cos。
|
||||
@ -46,8 +46,20 @@ public class TencentCosOptions
|
||||
/// </summary>
|
||||
public string KeyPrefix { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>签名有效期(秒)。</summary>
|
||||
public long DurationSecond { get; set; } = 600;
|
||||
/// <summary>
|
||||
/// 预签名读 URL 有效期(秒)。私有桶临时访问用。
|
||||
/// </summary>
|
||||
public long DurationSecond { get; set; } = 3600;
|
||||
|
||||
/// <summary>
|
||||
/// 预签名上传 / STS 有效期(秒)。建议短于读有效期。
|
||||
/// </summary>
|
||||
public long UploadDurationSecond { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// 是否对 GetPublicUrl 返回预签名链接。私有桶必须为 true。
|
||||
/// </summary>
|
||||
public bool SignUrls { get; set; } = true;
|
||||
|
||||
/// <summary>是否 HTTPS。</summary>
|
||||
public bool IsHttps { get; set; } = true;
|
||||
|
||||
59
src/Oms.Application/Storage/FileStorageUploadPaths.cs
Normal file
59
src/Oms.Application/Storage/FileStorageUploadPaths.cs
Normal file
@ -0,0 +1,59 @@
|
||||
using Fake.Application;
|
||||
|
||||
namespace Oms.Application.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 直传目录与 object key 生成。
|
||||
/// </summary>
|
||||
public static class FileStorageUploadPaths
|
||||
{
|
||||
private static readonly HashSet<string> AllowedFolders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"avatars",
|
||||
"sku",
|
||||
"parcel",
|
||||
"recharge",
|
||||
"order-files",
|
||||
"order-templates",
|
||||
"xhs",
|
||||
"prints",
|
||||
"exports",
|
||||
"misc"
|
||||
};
|
||||
|
||||
public static string NormalizeFolder(string? folder)
|
||||
{
|
||||
var value = (folder ?? string.Empty).Trim().Trim('/').Replace('\\', '/');
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new BusinessException("上传目录 folder 不能为空");
|
||||
}
|
||||
|
||||
// 允许 uploads/sku 或 sku
|
||||
if (value.StartsWith("uploads/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = value["uploads/".Length..];
|
||||
}
|
||||
|
||||
var root = value.Split('/', StringSplitOptions.RemoveEmptyEntries)[0];
|
||||
if (!AllowedFolders.Contains(root))
|
||||
{
|
||||
throw new BusinessException($"不支持的上传目录: {root}");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static string BuildLogicalKey(string folder, string fileName)
|
||||
{
|
||||
var normalizedFolder = NormalizeFolder(folder);
|
||||
var ext = Path.GetExtension(fileName);
|
||||
if (string.IsNullOrWhiteSpace(ext) || ext.Length > 16)
|
||||
{
|
||||
ext = ".bin";
|
||||
}
|
||||
|
||||
var month = DateTime.Now.ToString("yyyyMM");
|
||||
return $"uploads/{normalizedFolder}/{month}/{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@ -2,18 +2,17 @@ namespace Oms.Application.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 统一文件存储(本地 / 腾讯云 COS)。
|
||||
/// 约定:库内只存 object key(如 uploads/avatars/a.jpg),对外访问地址用 <see cref="GetPublicUrl"/> 拼接。
|
||||
/// 约定:库内只存 object key(如 uploads/avatars/a.jpg)。
|
||||
/// 私有桶场景:访问用临时签名 URL;用户上传用预签名 PUT 或 STS,文件不经业务服务器。
|
||||
/// </summary>
|
||||
public interface IFileStorage
|
||||
{
|
||||
/// <summary>是否支持前端直传(COS=true,Local=false)。</summary>
|
||||
bool SupportsClientDirectUpload { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 保存文件。
|
||||
/// 服务端落盘/上传(仅用于后端生成的文件,如面单 PDF、导出)。用户上传请走直传。
|
||||
/// </summary>
|
||||
/// <param name="content">文件流。</param>
|
||||
/// <param name="relativeKey">相对键,如 uploads/avatars/a.jpg(不要前导 /)。</param>
|
||||
/// <param name="contentType">MIME,可选。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>存储 object key(不含域名;不含 KeyPrefix)。</returns>
|
||||
Task<string> SaveAsync(
|
||||
Stream content,
|
||||
string relativeKey,
|
||||
@ -21,28 +20,35 @@ public interface IFileStorage
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 将库内 key(或历史完整 URL)转为可对外访问的地址。
|
||||
/// 临时访问地址。Cos 私有桶返回预签名 GET;Local 返回 /uploads/...;外链原样返回。
|
||||
/// </summary>
|
||||
string GetPublicUrl(string? keyOrUrl);
|
||||
|
||||
/// <summary>
|
||||
/// 签发前端直传用的预签名 PUT URL。
|
||||
/// </summary>
|
||||
Task<PresignedUploadTicket> CreatePresignedUploadAsync(
|
||||
string folder,
|
||||
string fileName,
|
||||
string? contentType = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 签发 STS 临时密钥(前端 COS SDK 直传)。不支持时抛业务异常。
|
||||
/// </summary>
|
||||
Task<StsUploadTicket> CreateStsUploadAsync(
|
||||
string folder,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 将 key / 相对路径 / 完整 URL 规范为库内应保存的 object key。
|
||||
/// 外链(非本存储域名)原样返回,便于兼容微信头像等。
|
||||
/// </summary>
|
||||
string? ToStorageKey(string? keyOrUrl);
|
||||
|
||||
/// <summary>
|
||||
/// 打开已保存文件的只读流。pathOrUrl 可为 key、相对路径或完整 URL。
|
||||
/// </summary>
|
||||
Task<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 判断文件是否存在。
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除文件(不存在时忽略)。
|
||||
/// </summary>
|
||||
Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
using Fake.Application;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
|
||||
namespace Oms.Application.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 本地 wwwroot 文件存储。库内存 key,对外为 /key。
|
||||
/// 本地 wwwroot 文件存储。库内存 key,对外为 /key。不支持前端直传 COS。
|
||||
/// </summary>
|
||||
public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage
|
||||
{
|
||||
public bool SupportsClientDirectUpload => false;
|
||||
|
||||
public async Task<string> SaveAsync(
|
||||
Stream content,
|
||||
string relativeKey,
|
||||
@ -66,7 +69,6 @@ public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileSto
|
||||
return keyOrUrl;
|
||||
}
|
||||
|
||||
// 兼容历史数据里已带前导 / 的相对路径
|
||||
if (keyOrUrl.StartsWith('/'))
|
||||
{
|
||||
return keyOrUrl.Replace('\\', '/');
|
||||
@ -75,6 +77,20 @@ public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileSto
|
||||
return "/" + FileStoragePath.NormalizeKey(keyOrUrl);
|
||||
}
|
||||
|
||||
public Task<PresignedUploadTicket> CreatePresignedUploadAsync(
|
||||
string folder,
|
||||
string fileName,
|
||||
string? contentType = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new BusinessException(
|
||||
"当前为 Local 存储,不支持 COS 直传。请将 Oms:FileStorage:Provider 设为 Cos,或继续使用服务端 multipart 上传。");
|
||||
|
||||
public Task<StsUploadTicket> CreateStsUploadAsync(
|
||||
string folder,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new BusinessException("当前为 Local 存储,不支持 STS 直传。请将 Oms:FileStorage:Provider 设为 Cos。");
|
||||
|
||||
public string? ToStorageKey(string? keyOrUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyOrUrl))
|
||||
@ -87,7 +103,6 @@ public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileSto
|
||||
return "0";
|
||||
}
|
||||
|
||||
// 外链(非本站相对路径)保留,如微信头像
|
||||
if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
|
||||
{
|
||||
return keyOrUrl.Trim();
|
||||
|
||||
@ -1,22 +1,40 @@
|
||||
using COSSTS;
|
||||
using COSXML;
|
||||
using COSXML.Auth;
|
||||
using COSXML.Model.Object;
|
||||
using COSXML.Model.Tag;
|
||||
using Fake.Application;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Oms.Application.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云 COS 文件存储。库内存逻辑 key(不含 KeyPrefix),读写时再拼前缀。
|
||||
/// 腾讯云 COS:库内存逻辑 key;读返回预签名 GET;写支持预签名 PUT / STS 前端直传。
|
||||
/// </summary>
|
||||
public sealed class TencentCosFileStorage : IFileStorage, IDisposable
|
||||
{
|
||||
private static readonly string[] UploadActions =
|
||||
[
|
||||
"name/cos:PutObject",
|
||||
"name/cos:PostObject",
|
||||
"name/cos:InitiateMultipartUpload",
|
||||
"name/cos:ListMultipartUploads",
|
||||
"name/cos:ListParts",
|
||||
"name/cos:UploadPart",
|
||||
"name/cos:CompleteMultipartUpload",
|
||||
"name/cos:AbortMultipartUpload"
|
||||
];
|
||||
|
||||
private readonly TencentCosOptions _options;
|
||||
private readonly CosXmlServer _cosXml;
|
||||
private readonly ILogger<TencentCosFileStorage> _logger;
|
||||
private readonly string _publicBaseUrl;
|
||||
private readonly string _keyPrefix;
|
||||
private readonly string _appId;
|
||||
private readonly string? _customHost;
|
||||
|
||||
public TencentCosFileStorage(
|
||||
IOptions<FileStorageOptions> options,
|
||||
@ -26,22 +44,29 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
|
||||
_options = options.Value.Cos ?? new TencentCosOptions();
|
||||
ValidateOptions(_options);
|
||||
|
||||
_appId = ExtractAppId(_options.Bucket);
|
||||
var config = new CosXmlConfig.Builder()
|
||||
.IsHttps(_options.IsHttps)
|
||||
.SetRegion(_options.Region)
|
||||
.SetAppid(ExtractAppId(_options.Bucket))
|
||||
.SetAppid(_appId)
|
||||
.Build();
|
||||
|
||||
var signSeconds = Math.Max(60, Math.Max(_options.DurationSecond, _options.UploadDurationSecond));
|
||||
var credential = new DefaultQCloudCredentialProvider(
|
||||
_options.SecretId,
|
||||
_options.SecretKey,
|
||||
_options.DurationSecond);
|
||||
signSeconds);
|
||||
|
||||
_cosXml = new CosXmlServer(config, credential);
|
||||
_publicBaseUrl = ResolvePublicBaseUrl(_options);
|
||||
_keyPrefix = (_options.KeyPrefix ?? string.Empty).Replace('\\', '/').Trim('/');
|
||||
_customHost = Uri.TryCreate(_publicBaseUrl, UriKind.Absolute, out var baseUri)
|
||||
? baseUri.Host
|
||||
: null;
|
||||
}
|
||||
|
||||
public bool SupportsClientDirectUpload => true;
|
||||
|
||||
public Task<string> SaveAsync(
|
||||
Stream content,
|
||||
string relativeKey,
|
||||
@ -81,7 +106,7 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
|
||||
throw new BusinessException($"上传 COS 失败: HTTP {result.httpCode}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("COS 上传成功: {ObjectKey} (logical={LogicalKey})", objectKey, logicalKey);
|
||||
_logger.LogDebug("COS 服务端上传成功: {ObjectKey}", objectKey);
|
||||
return Task.FromResult(logicalKey);
|
||||
}
|
||||
finally
|
||||
@ -98,12 +123,127 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
|
||||
}
|
||||
|
||||
if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
|
||||
{
|
||||
if (!IsManagedPublicUrl(keyOrUrl))
|
||||
{
|
||||
return keyOrUrl;
|
||||
}
|
||||
|
||||
var objectKey = ToObjectKey(keyOrUrl);
|
||||
return $"{_publicBaseUrl}/{objectKey}";
|
||||
// 已是签名 URL:原样返回,避免重复签名
|
||||
if (keyOrUrl.Contains("q-signature=", StringComparison.OrdinalIgnoreCase)
|
||||
|| keyOrUrl.Contains("sign=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return keyOrUrl;
|
||||
}
|
||||
|
||||
var logical = StripKeyPrefix(FileStoragePath.NormalizeKey(keyOrUrl));
|
||||
return BuildSignedGetUrl(logical);
|
||||
}
|
||||
|
||||
return BuildSignedGetUrl(keyOrUrl);
|
||||
}
|
||||
|
||||
public Task<PresignedUploadTicket> CreatePresignedUploadAsync(
|
||||
string folder,
|
||||
string fileName,
|
||||
string? contentType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var logicalKey = FileStorageUploadPaths.BuildLogicalKey(folder, fileName);
|
||||
var objectKey = ToObjectKey(logicalKey);
|
||||
var duration = Math.Max(60, _options.UploadDurationSecond);
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
headers["Content-Type"] = contentType.Trim();
|
||||
}
|
||||
|
||||
var preSign = new PreSignatureStruct
|
||||
{
|
||||
appid = _appId,
|
||||
bucket = _options.Bucket,
|
||||
region = _options.Region,
|
||||
key = objectKey,
|
||||
httpMethod = "PUT",
|
||||
isHttps = _options.IsHttps,
|
||||
signDurationSecond = duration,
|
||||
headers = headers.Count > 0 ? headers : null,
|
||||
queryParameters = null
|
||||
};
|
||||
ApplyCustomHost(preSign);
|
||||
|
||||
var uploadUrl = _cosXml.GenerateSignURL(preSign);
|
||||
return Task.FromResult(new PresignedUploadTicket
|
||||
{
|
||||
Key = logicalKey,
|
||||
UploadUrl = uploadUrl,
|
||||
Method = "PUT",
|
||||
ContentType = string.IsNullOrWhiteSpace(contentType) ? null : contentType.Trim(),
|
||||
Headers = headers,
|
||||
ExpireAt = DateTime.UtcNow.AddSeconds(duration)
|
||||
});
|
||||
}
|
||||
|
||||
public Task<StsUploadTicket> CreateStsUploadAsync(
|
||||
string folder,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var logicalKey = FileStorageUploadPaths.BuildLogicalKey(folder, fileName);
|
||||
var objectKey = ToObjectKey(logicalKey);
|
||||
var duration = (int)Math.Max(60, _options.UploadDurationSecond);
|
||||
|
||||
var values = new Dictionary<string, object>
|
||||
{
|
||||
["bucket"] = _options.Bucket,
|
||||
["region"] = _options.Region,
|
||||
["allowPrefix"] = objectKey,
|
||||
["allowActions"] = UploadActions,
|
||||
["durationSeconds"] = duration,
|
||||
["secretId"] = _options.SecretId,
|
||||
["secretKey"] = _options.SecretKey
|
||||
};
|
||||
|
||||
Dictionary<string, object> credential;
|
||||
try
|
||||
{
|
||||
credential = STSClient.genCredential(values);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "申请 COS STS 临时密钥失败");
|
||||
throw new BusinessException("申请 COS 临时上传凭证失败,请检查 SecretId/SecretKey 与 CAM 权限");
|
||||
}
|
||||
|
||||
var credentials = ExtractCredentials(credential["Credentials"]);
|
||||
var startTime = ToUnixSeconds(credential.GetValueOrDefault("StartTime"));
|
||||
var expiredTime = ToUnixSeconds(credential.GetValueOrDefault("ExpiredTime"));
|
||||
if (startTime <= 0)
|
||||
{
|
||||
startTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
}
|
||||
|
||||
if (expiredTime <= 0)
|
||||
{
|
||||
expiredTime = startTime + duration;
|
||||
}
|
||||
|
||||
return Task.FromResult(new StsUploadTicket
|
||||
{
|
||||
Key = logicalKey,
|
||||
CosKey = objectKey,
|
||||
Bucket = _options.Bucket,
|
||||
Region = _options.Region,
|
||||
TmpSecretId = credentials.tmpSecretId,
|
||||
TmpSecretKey = credentials.tmpSecretKey,
|
||||
SessionToken = credentials.token,
|
||||
StartTime = startTime,
|
||||
ExpiredTime = expiredTime
|
||||
});
|
||||
}
|
||||
|
||||
public string? ToStorageKey(string? keyOrUrl)
|
||||
@ -120,7 +260,6 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
|
||||
|
||||
if (FileStoragePath.IsAbsoluteUrl(keyOrUrl))
|
||||
{
|
||||
// 本桶 / CDN 域名:落到逻辑 key;其它外链原样保留
|
||||
if (!IsManagedPublicUrl(keyOrUrl))
|
||||
{
|
||||
return keyOrUrl.Trim();
|
||||
@ -186,11 +325,54 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
|
||||
{
|
||||
}
|
||||
|
||||
private string BuildSignedGetUrl(string logicalOrRelativeKey)
|
||||
{
|
||||
var objectKey = ToObjectKey(logicalOrRelativeKey);
|
||||
if (!_options.SignUrls)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Cos.SignUrls=false,私有桶下浏览器将无法直接打开对象。建议保持 SignUrls=true。ObjectKey={ObjectKey}",
|
||||
objectKey);
|
||||
return $"{_publicBaseUrl}/{objectKey}";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var preSign = new PreSignatureStruct
|
||||
{
|
||||
appid = _appId,
|
||||
bucket = _options.Bucket,
|
||||
region = _options.Region,
|
||||
key = objectKey,
|
||||
httpMethod = "GET",
|
||||
isHttps = _options.IsHttps,
|
||||
signDurationSecond = Math.Max(60, _options.DurationSecond),
|
||||
headers = null,
|
||||
queryParameters = null
|
||||
};
|
||||
ApplyCustomHost(preSign);
|
||||
return _cosXml.GenerateSignURL(preSign);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "生成 COS 预签名 GET 失败: {ObjectKey}", objectKey);
|
||||
throw new BusinessException("生成临时访问地址失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyCustomHost(PreSignatureStruct preSign)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_customHost)
|
||||
&& !_customHost.EndsWith(".myqcloud.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
preSign.host = _customHost;
|
||||
preSign.signHost = true;
|
||||
}
|
||||
}
|
||||
|
||||
private string ToObjectKey(string pathOrUrl)
|
||||
{
|
||||
var key = FileStoragePath.NormalizeKey(pathOrUrl);
|
||||
|
||||
// 历史完整 URL 已含前缀时不再重复追加
|
||||
if (!string.IsNullOrEmpty(_keyPrefix)
|
||||
&& !key.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(key, _keyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
@ -234,11 +416,41 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认 COS 域名形态
|
||||
var defaultHost = $"{_options.Bucket}.cos.{_options.Region}.myqcloud.com";
|
||||
return string.Equals(uri.Host, defaultHost, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static (string tmpSecretId, string tmpSecretKey, string token) ExtractCredentials(object? raw)
|
||||
{
|
||||
if (raw == null)
|
||||
{
|
||||
throw new BusinessException("STS 返回 Credentials 为空");
|
||||
}
|
||||
|
||||
var json = JsonConvert.SerializeObject(raw);
|
||||
var obj = JObject.Parse(json);
|
||||
var id = obj.Value<string>("TmpSecretId") ?? obj.Value<string>("tmpSecretId");
|
||||
var key = obj.Value<string>("TmpSecretKey") ?? obj.Value<string>("tmpSecretKey");
|
||||
var token = obj.Value<string>("Token") ?? obj.Value<string>("sessionToken") ?? obj.Value<string>("token");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new BusinessException("STS 凭证字段不完整");
|
||||
}
|
||||
|
||||
return (id, key, token);
|
||||
}
|
||||
|
||||
private static long ToUnixSeconds(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => 0,
|
||||
long l => l,
|
||||
int i => i,
|
||||
string s when long.TryParse(s, out var n) => n,
|
||||
JValue j when j.Type == JTokenType.Integer => j.Value<long>(),
|
||||
_ => 0
|
||||
};
|
||||
|
||||
private static string ResolvePublicBaseUrl(TencentCosOptions options)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||
|
||||
@ -69,7 +69,9 @@
|
||||
"Bucket": "your-bucket-1250000000",
|
||||
"BaseUrl": "",
|
||||
"KeyPrefix": "",
|
||||
"DurationSecond": 600,
|
||||
"DurationSecond": 3600,
|
||||
"UploadDurationSecond": 600,
|
||||
"SignUrls": true,
|
||||
"IsHttps": true
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user