feat:cos
All checks were successful
Build and Deploy / deploy (push) Successful in 1m47s

This commit is contained in:
xiaolipro 2026-09-11 11:34:59 +08:00
parent 56c18c0343
commit d1e3672808
9 changed files with 554 additions and 36 deletions

View File

@ -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>

View 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; }
}

View 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; }
}

View File

@ -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;

View 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()}";
}
}

View File

@ -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);
}

View File

@ -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();

View File

@ -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))

View File

@ -69,7 +69,9 @@
"Bucket": "your-bucket-1250000000",
"BaseUrl": "",
"KeyPrefix": "",
"DurationSecond": 600,
"DurationSecond": 3600,
"UploadDurationSecond": 600,
"SignUrls": true,
"IsHttps": true
}
},