From d1e3672808face532ce7daa7c4e7b1a71051926e Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Fri, 11 Sep 2026 11:34:59 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9Acos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Oms.Application/Oms.Application.csproj | 1 + .../Services/Storage/FileStorageService.cs | 161 ++++++++++++ .../Storage/DirectUploadTickets.cs | 50 ++++ .../Storage/FileStorageOptions.cs | 18 +- .../Storage/FileStorageUploadPaths.cs | 59 +++++ src/Oms.Application/Storage/IFileStorage.cs | 42 ++-- .../Storage/LocalFileStorage.cs | 21 +- .../Storage/TencentCosFileStorage.cs | 234 +++++++++++++++++- .../appsettings.Development.json | 4 +- 9 files changed, 554 insertions(+), 36 deletions(-) create mode 100644 src/Oms.Application/Services/Storage/FileStorageService.cs create mode 100644 src/Oms.Application/Storage/DirectUploadTickets.cs create mode 100644 src/Oms.Application/Storage/FileStorageUploadPaths.cs diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index a63f881..8783a7d 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -30,6 +30,7 @@ + diff --git a/src/Oms.Application/Services/Storage/FileStorageService.cs b/src/Oms.Application/Services/Storage/FileStorageService.cs new file mode 100644 index 0000000..f2d91b0 --- /dev/null +++ b/src/Oms.Application/Services/Storage/FileStorageService.cs @@ -0,0 +1,161 @@ +using Fake.Application; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Storage; + +namespace Oms.Application.Services.Storage; + +/// +/// 文件存储直传 / 临时访问。私有桶:前端拿凭证直传 COS,后端只存 key。 +/// +[Authorize] +[ApiExplorerSettings(GroupName = "OMS-Storage")] +public class FileStorageService : ApplicationService +{ + private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); + + /// + /// 是否支持前端直传(Cos=true)。 + /// + [HttpGet("/file-storage/capabilities")] + public object GetCapabilities() => new + { + supportsClientDirectUpload = FileStorage.SupportsClientDirectUpload, + provider = FileStorage.GetType().Name + }; + + /// + /// 签发预签名 PUT 上传地址。前端对该 URL 直接 PUT 文件体,成功后把返回的 key 交给业务接口落库。 + /// + [HttpPost("/file-storage/presign-upload")] + public async Task 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 + }; + } + + /// + /// 签发 STS 临时密钥。前端用 cos-js-sdk-v5 等 SDK 直传;落库只存 key。 + /// + [HttpPost("/file-storage/sts-upload")] + public async Task 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 + }; + } + + /// + /// 按库内 key 生成临时访问地址(私有桶预签名 GET)。 + /// + [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) + }; + } + + /// + /// 将完整 URL / 相对路径规范为库内 key(业务保存前可调用)。 + /// + [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 +{ + /// 业务目录:avatars / sku / parcel / recharge / order-files / xhs 等。 + 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 Headers { get; set; } = + new Dictionary(); + 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; } +} diff --git a/src/Oms.Application/Storage/DirectUploadTickets.cs b/src/Oms.Application/Storage/DirectUploadTickets.cs new file mode 100644 index 0000000..7b455c1 --- /dev/null +++ b/src/Oms.Application/Storage/DirectUploadTickets.cs @@ -0,0 +1,50 @@ +namespace Oms.Application.Storage; + +/// +/// 预签名 PUT 直传凭证(前端直接 PUT 到 COS,文件不经后端)。 +/// +public sealed class PresignedUploadTicket +{ + /// 库内应保存的逻辑 key(不含 KeyPrefix)。 + public string Key { get; init; } = string.Empty; + + /// 一次性 PUT 地址(含签名)。 + public string UploadUrl { get; init; } = string.Empty; + + public string Method { get; init; } = "PUT"; + + /// PUT 时必须带上的 Content-Type(若签发时绑定了则必填)。 + public string? ContentType { get; init; } + + /// PUT 时必须原样携带的请求头。 + public IReadOnlyDictionary Headers { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public DateTime ExpireAt { get; init; } +} + +/// +/// STS 临时密钥直传凭证(前端用 cos-js-sdk 等上传)。 +/// +public sealed class StsUploadTicket +{ + /// 库内应保存的逻辑 key。 + public string Key { get; init; } = string.Empty; + + /// COS 对象完整键(含 KeyPrefix),SDK putObject 用。 + 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; } +} diff --git a/src/Oms.Application/Storage/FileStorageOptions.cs b/src/Oms.Application/Storage/FileStorageOptions.cs index 725eee2..9a4279c 100644 --- a/src/Oms.Application/Storage/FileStorageOptions.cs +++ b/src/Oms.Application/Storage/FileStorageOptions.cs @@ -5,7 +5,7 @@ namespace Oms.Application.Storage; /// public class FileStorageOptions { - public const string SectionName = "Oms:FileStorage"; + public const string SectionName = "FileStorage"; /// /// 存储提供方:Local | Cos。 @@ -46,8 +46,20 @@ public class TencentCosOptions /// public string KeyPrefix { get; set; } = string.Empty; - /// 签名有效期(秒)。 - public long DurationSecond { get; set; } = 600; + /// + /// 预签名读 URL 有效期(秒)。私有桶临时访问用。 + /// + public long DurationSecond { get; set; } = 3600; + + /// + /// 预签名上传 / STS 有效期(秒)。建议短于读有效期。 + /// + public long UploadDurationSecond { get; set; } = 600; + + /// + /// 是否对 GetPublicUrl 返回预签名链接。私有桶必须为 true。 + /// + public bool SignUrls { get; set; } = true; /// 是否 HTTPS。 public bool IsHttps { get; set; } = true; diff --git a/src/Oms.Application/Storage/FileStorageUploadPaths.cs b/src/Oms.Application/Storage/FileStorageUploadPaths.cs new file mode 100644 index 0000000..5410a72 --- /dev/null +++ b/src/Oms.Application/Storage/FileStorageUploadPaths.cs @@ -0,0 +1,59 @@ +using Fake.Application; + +namespace Oms.Application.Storage; + +/// +/// 直传目录与 object key 生成。 +/// +public static class FileStorageUploadPaths +{ + private static readonly HashSet 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()}"; + } +} diff --git a/src/Oms.Application/Storage/IFileStorage.cs b/src/Oms.Application/Storage/IFileStorage.cs index a496d58..cd6b501 100644 --- a/src/Oms.Application/Storage/IFileStorage.cs +++ b/src/Oms.Application/Storage/IFileStorage.cs @@ -2,18 +2,17 @@ namespace Oms.Application.Storage; /// /// 统一文件存储(本地 / 腾讯云 COS)。 -/// 约定:库内只存 object key(如 uploads/avatars/a.jpg),对外访问地址用 拼接。 +/// 约定:库内只存 object key(如 uploads/avatars/a.jpg)。 +/// 私有桶场景:访问用临时签名 URL;用户上传用预签名 PUT 或 STS,文件不经业务服务器。 /// public interface IFileStorage { + /// 是否支持前端直传(COS=true,Local=false)。 + bool SupportsClientDirectUpload { get; } + /// - /// 保存文件。 + /// 服务端落盘/上传(仅用于后端生成的文件,如面单 PDF、导出)。用户上传请走直传。 /// - /// 文件流。 - /// 相对键,如 uploads/avatars/a.jpg(不要前导 /)。 - /// MIME,可选。 - /// 取消令牌。 - /// 存储 object key(不含域名;不含 KeyPrefix)。 Task SaveAsync( Stream content, string relativeKey, @@ -21,28 +20,35 @@ public interface IFileStorage CancellationToken cancellationToken = default); /// - /// 将库内 key(或历史完整 URL)转为可对外访问的地址。 + /// 临时访问地址。Cos 私有桶返回预签名 GET;Local 返回 /uploads/...;外链原样返回。 /// string GetPublicUrl(string? keyOrUrl); + /// + /// 签发前端直传用的预签名 PUT URL。 + /// + Task CreatePresignedUploadAsync( + string folder, + string fileName, + string? contentType = null, + CancellationToken cancellationToken = default); + + /// + /// 签发 STS 临时密钥(前端 COS SDK 直传)。不支持时抛业务异常。 + /// + Task CreateStsUploadAsync( + string folder, + string fileName, + CancellationToken cancellationToken = default); + /// /// 将 key / 相对路径 / 完整 URL 规范为库内应保存的 object key。 - /// 外链(非本存储域名)原样返回,便于兼容微信头像等。 /// string? ToStorageKey(string? keyOrUrl); - /// - /// 打开已保存文件的只读流。pathOrUrl 可为 key、相对路径或完整 URL。 - /// Task OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default); - /// - /// 判断文件是否存在。 - /// Task ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default); - /// - /// 删除文件(不存在时忽略)。 - /// Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Application/Storage/LocalFileStorage.cs b/src/Oms.Application/Storage/LocalFileStorage.cs index 96237b2..b9a54c0 100644 --- a/src/Oms.Application/Storage/LocalFileStorage.cs +++ b/src/Oms.Application/Storage/LocalFileStorage.cs @@ -1,12 +1,15 @@ +using Fake.Application; using Microsoft.AspNetCore.Hosting; namespace Oms.Application.Storage; /// -/// 本地 wwwroot 文件存储。库内存 key,对外为 /key。 +/// 本地 wwwroot 文件存储。库内存 key,对外为 /key。不支持前端直传 COS。 /// public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage { + public bool SupportsClientDirectUpload => false; + public async Task 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 CreatePresignedUploadAsync( + string folder, + string fileName, + string? contentType = null, + CancellationToken cancellationToken = default) => + throw new BusinessException( + "当前为 Local 存储,不支持 COS 直传。请将 Oms:FileStorage:Provider 设为 Cos,或继续使用服务端 multipart 上传。"); + + public Task 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(); diff --git a/src/Oms.Application/Storage/TencentCosFileStorage.cs b/src/Oms.Application/Storage/TencentCosFileStorage.cs index d5d6bec..9f152d7 100644 --- a/src/Oms.Application/Storage/TencentCosFileStorage.cs +++ b/src/Oms.Application/Storage/TencentCosFileStorage.cs @@ -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; /// -/// 腾讯云 COS 文件存储。库内存逻辑 key(不含 KeyPrefix),读写时再拼前缀。 +/// 腾讯云 COS:库内存逻辑 key;读返回预签名 GET;写支持预签名 PUT / STS 前端直传。 /// 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 _logger; private readonly string _publicBaseUrl; private readonly string _keyPrefix; + private readonly string _appId; + private readonly string? _customHost; public TencentCosFileStorage( IOptions 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 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 @@ -99,11 +124,126 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable if (FileStoragePath.IsAbsoluteUrl(keyOrUrl)) { - return keyOrUrl; + if (!IsManagedPublicUrl(keyOrUrl)) + { + return keyOrUrl; + } + + // 已是签名 URL:原样返回,避免重复签名 + if (keyOrUrl.Contains("q-signature=", StringComparison.OrdinalIgnoreCase) + || keyOrUrl.Contains("sign=", StringComparison.OrdinalIgnoreCase)) + { + return keyOrUrl; + } + + var logical = StripKeyPrefix(FileStoragePath.NormalizeKey(keyOrUrl)); + return BuildSignedGetUrl(logical); } - var objectKey = ToObjectKey(keyOrUrl); - return $"{_publicBaseUrl}/{objectKey}"; + return BuildSignedGetUrl(keyOrUrl); + } + + public Task 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(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 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 + { + ["bucket"] = _options.Bucket, + ["region"] = _options.Region, + ["allowPrefix"] = objectKey, + ["allowActions"] = UploadActions, + ["durationSeconds"] = duration, + ["secretId"] = _options.SecretId, + ["secretKey"] = _options.SecretKey + }; + + Dictionary 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("TmpSecretId") ?? obj.Value("tmpSecretId"); + var key = obj.Value("TmpSecretKey") ?? obj.Value("tmpSecretKey"); + var token = obj.Value("Token") ?? obj.Value("sessionToken") ?? obj.Value("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(), + _ => 0 + }; + private static string ResolvePublicBaseUrl(TencentCosOptions options) { if (!string.IsNullOrWhiteSpace(options.BaseUrl)) diff --git a/src/SimpleAdmin.Api/appsettings.Development.json b/src/SimpleAdmin.Api/appsettings.Development.json index a9e6fcf..987adaf 100644 --- a/src/SimpleAdmin.Api/appsettings.Development.json +++ b/src/SimpleAdmin.Api/appsettings.Development.json @@ -69,7 +69,9 @@ "Bucket": "your-bucket-1250000000", "BaseUrl": "", "KeyPrefix": "", - "DurationSecond": 600, + "DurationSecond": 3600, + "UploadDurationSecond": 600, + "SignUrls": true, "IsHttps": true } },