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
}
},