From dd1318bc00398c616fee6007bc4f6f0e0a1698ec Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Tue, 22 Sep 2026 16:59:33 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=AF=B9=E6=8E=A5fake=20oss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 49 +- Directory.Build.props | 2 +- .../Labeling/LabelPdfGenerationService.cs | 5 +- src/Oms.Application/Oms.Application.csproj | 3 +- src/Oms.Application/OmsApplicationModule.cs | 3 +- .../Services/Orders/OrderFileManager.cs | 9 +- .../Services/Orders/OrderService.Helpers.cs | 8 +- .../Services/Orders/OrderService.StockBind.cs | 1 + .../Services/Orders/OrderService.cs | 5 +- .../Services/Rbac/OmsUserService.cs | 9 +- .../Services/Recharge/RechargeService.cs | 3 +- .../Services/Storage/FileStorageService.cs | 161 ------ .../Warehouse/WarehouseService.Parcel.cs | 2 +- .../Warehouse/WarehouseService.Sku.cs | 7 +- .../Services/Xhs/XhsMediaService.cs | 5 +- .../Shared/OmsFileUploadHelper.cs | 29 +- .../Storage/DirectUploadTickets.cs | 50 -- .../Storage/FileStorageOptions.cs | 66 --- .../Storage/FileStorageServiceExtensions.cs | 93 ---- .../Storage/FileStorageUploadPaths.cs | 59 --- src/Oms.Application/Storage/IFileStorage.cs | 54 -- .../Storage/LocalFileStorage.cs | 171 ------ .../Storage/OmsObjectStorageExtensions.cs | 86 ++++ .../Storage/TencentCosFileStorage.cs | 487 ------------------ src/SimpleAdmin.Api/Auth/AuthService.cs | 5 +- src/SimpleAdmin.Api/SimpleAdmin.Api.csproj | 3 + src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 23 +- .../Storage/ObjectStorageStartupLogger.cs | 36 ++ .../appsettings.Development.json | 42 +- 29 files changed, 263 insertions(+), 1213 deletions(-) delete mode 100644 src/Oms.Application/Services/Storage/FileStorageService.cs delete mode 100644 src/Oms.Application/Storage/DirectUploadTickets.cs delete mode 100644 src/Oms.Application/Storage/FileStorageOptions.cs delete mode 100644 src/Oms.Application/Storage/FileStorageServiceExtensions.cs delete mode 100644 src/Oms.Application/Storage/FileStorageUploadPaths.cs delete mode 100644 src/Oms.Application/Storage/IFileStorage.cs delete mode 100644 src/Oms.Application/Storage/LocalFileStorage.cs create mode 100644 src/Oms.Application/Storage/OmsObjectStorageExtensions.cs delete mode 100644 src/Oms.Application/Storage/TencentCosFileStorage.cs create mode 100644 src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 9d38b61..e2f0a9a 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,10 +1,10 @@ -# 复制到 admin .gitea/workflows/deploy.yml +# 复制到 admin .gitea/workflows/deploy.yml +# push master → prod;push test → test name: Build and Deploy on: push: - branches: [master, main] - workflow_dispatch: + branches: [master, test] env: CSPROJ: src/SimpleAdmin.Api/SimpleAdmin.Api.csproj @@ -21,14 +21,47 @@ jobs: - name: Build and deploy run: | set -euxo pipefail - export KUBECONFIG=/etc/rancher/k3s/k3s.yaml - TAG="${{ gitea.sha }}" - IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}" + REF="${{ gitea.ref_name }}" + case "$REF" in + test) TARGET=test ;; + master) TARGET=prod ;; + *) echo "unsupported branch: $REF"; exit 1 ;; + esac + + SHA="${{ gitea.sha }}" REPO="${{ gitea.repository }}" test -n "$REPO" && [[ "$REPO" == */* ]] - echo "REPO=$REPO TAG=$TAG host=$(hostname)" + + case "$TARGET" in + test) TAG="test-${SHA}" ;; + *) TAG="${SHA}" ;; + esac + + IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}" + echo "TARGET=$TARGET REF=$REF REPO=$REPO TAG=$TAG host=$(hostname)" + + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + unset NAMESPACE cd /data/k3s ./scripts/ci-publish-container.sh "$REPO" "${{ env.CSPROJ }}" \ - "${{ env.IMAGE_NAME }}" "$TAG" "$TAG" + "${{ env.IMAGE_NAME }}" "$TAG" "$SHA" + + case "$TARGET" in + test) + export KUBECONFIG=/etc/rancher/k3s/k3s-test.yaml + export NAMESPACE=test + export MANIFEST_ROOT=/data/k3s-test + ;; + *) + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + export NAMESPACE=prod + export MANIFEST_ROOT=/data/k3s + ;; + esac + if [[ ! -f "$KUBECONFIG" ]]; then + echo "FAIL: missing KUBECONFIG=$KUBECONFIG host=$(hostname) uid=$(id -u)" + ls -la /etc/rancher/k3s/ /root/.kube/ 2>/dev/null || true + exit 1 + fi ./scripts/ci-deploy-api.sh "${{ env.DEPLOY_NAME }}" \ "${{ env.CONTAINER_NAME }}" "$IMG" diff --git a/Directory.Build.props b/Directory.Build.props index e7da47d..7d6f04d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 8.0.5.16 + 8.0.5.17 diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs index 8b3516b..a6da67b 100644 --- a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs +++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs @@ -1,4 +1,5 @@ using Fake.Application; +using Fake.ObjectStorage; using Microsoft.Extensions.Logging; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; @@ -22,7 +23,7 @@ public sealed class LabelPdfGenerationService( ISystemOtherConfigRepository systemOtherConfigRepository, IOrderRepository orderRepository, OrderFileManager orderFileManager, - IFileStorage fileStorage, + IObjectStorage fileStorage, ILogger logger) { public async Task GenerateAndPersistAsync( @@ -167,7 +168,7 @@ public sealed class LabelPdfGenerationService( var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf"; var relativeKey = $"uploads/prints/orders/{fileName}"; await using var stream = new MemoryStream(pdfBytes); - return await fileStorage.SaveAsync(stream, relativeKey, "application/pdf", cancellationToken); + return await fileStorage.SaveObjectAsync(stream, relativeKey, "application/pdf", cancellationToken); } } diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index 8783a7d..73214d1 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -20,6 +20,7 @@ + @@ -29,8 +30,6 @@ - - diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs index 7603018..acd9b8a 100644 --- a/src/Oms.Application/OmsApplicationModule.cs +++ b/src/Oms.Application/OmsApplicationModule.cs @@ -13,7 +13,6 @@ using Oms.Application.Integrations.PlatformShop; using Oms.Application.Labeling; using Oms.Application.Labeling.Hiprint; using Oms.Application.Shared; -using Oms.Application.Storage; using Oms.Application.Workers; using Oms.Domain; @@ -21,6 +20,7 @@ namespace Oms.Application; /// /// OMS 应用层模块注册。 +/// 对象存储由 Api 宿主注册 Fake.ObjectStorage(Local / TencentCos);文件元数据走 Fake.FileManagement。 /// [DependsOn( typeof(OmsDomainModule), @@ -52,7 +52,6 @@ public class OmsApplicationModule : FakeModule context.Services.AddTransient(); context.Services.AddTransient(); - context.Services.AddOmsFileStorage(configuration); context.Services.AddOmsSmallBagTrack(configuration); context.Services.AddOmsItfxIntegration(configuration); context.Services.AddOmsPlatformShopIntegration(configuration); diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs index b4ae163..93c8fc5 100644 --- a/src/Oms.Application/Services/Orders/OrderFileManager.cs +++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs @@ -1,5 +1,6 @@ using Fake.Application; using Fake.DependencyInjection; +using Fake.ObjectStorage; using Microsoft.AspNetCore.Http; using Oms.Application.Dtos.Orders; using Oms.Application.Storage; @@ -14,7 +15,7 @@ namespace Oms.Application.Services.Orders; /// public sealed class OrderFileManager( IOrderFileRepository orderFileRepository, - IFileStorage fileStorage) : IScopedDependency + IObjectStorage fileStorage) : IScopedDependency { private static readonly HashSet ImageExt = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" }; @@ -94,7 +95,7 @@ public sealed class OrderFileManager( OrderId = orderId, FileType = OrderFileTypeCodes.SystemLabel, FileName = fileName, - FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(), + FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(), ContentType = contentType, FileSize = fileSize, Sort = 0, @@ -143,7 +144,7 @@ public sealed class OrderFileManager( OrderId = orderId, FileType = OrderFileTypeCodes.DownstreamLabel, FileName = TryFileNameFromUrl(fileUrl), - FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(), + FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(), ContentType = null, Sort = existing.Count, Source = OrderFileSourceCodes.Downstream, @@ -305,7 +306,7 @@ public sealed class OrderFileManager( var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}"; await using var stream = file.OpenReadStream(); - var key = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType); + var key = await fileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType); return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length); } diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index fc27a3d..63a6b9c 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -488,12 +488,12 @@ public partial class OrderService throw new BusinessException("自定义模板样例文件不存在,请重新上传"); } - if (!await FileStorage.ExistsAsync(template.SourceFilePath)) + if (!await FileStorage.ExistsAsync(FileStoragePath.NormalizeKey(template.SourceFilePath))) { throw new BusinessException("自定义模板样例文件不存在,请重新上传"); } - await using var stream = await FileStorage.OpenReadAsync(template.SourceFilePath); + await using var stream = await FileStorage.GetAsync(FileStoragePath.NormalizeKey(template.SourceFilePath)); using var ms = new MemoryStream(); await stream.CopyToAsync(ms); var bytes = ms.ToArray(); @@ -628,7 +628,7 @@ public partial class OrderService """; var relativeKey = $"uploads/prints/orders/{fileName}"; await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(html)); - var key = await FileStorage.SaveAsync(stream, relativeKey, "text/html; charset=utf-8"); + var key = await FileStorage.SaveObjectAsync(stream, relativeKey, "text/html; charset=utf-8"); return FileStorage.GetPublicUrl(key); } @@ -647,7 +647,7 @@ public partial class OrderService private async Task CleanupExpiredLocalExportFilesAsync() { // COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。 - if (FileStorage is not LocalFileStorage) + if (!FileStorage.IsLocalProvider()) { return; } diff --git a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs index 09ec87b..04b0668 100644 --- a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs +++ b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; +using Oms.Application.Storage; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 91bff8f..a7c7043 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -1,5 +1,6 @@ using Fake.Application; using Fake.Domain.Exceptions; +using Fake.ObjectStorage; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using MiniExcelLibs; @@ -35,7 +36,7 @@ public partial class OrderService : ApplicationService private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService(); private IPickupOrderRepository PickupOrderRepository => LazyServiceProvider.GetRequiredService(); private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService(); - private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); + private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService(); private LabelPdfGenerationService LabelPdfGenerationService => LazyServiceProvider.GetRequiredService(); private OmsRbacUserDisplayNameResolver UserDisplayNameResolver => @@ -345,7 +346,7 @@ public partial class OrderService : ApplicationService await using var stream = new MemoryStream(); await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders)); stream.Position = 0; - var storageKey = await FileStorage.SaveAsync( + var storageKey = await FileStorage.SaveObjectAsync( stream, relativeKey, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs index 45ae3aa..d4f4a2e 100644 --- a/src/Oms.Application/Services/Rbac/OmsUserService.cs +++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs @@ -6,6 +6,7 @@ using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Fake.ObjectStorage; using Oms.Application.Dtos.Rbac; using Oms.Application.Rbac; using Oms.Application.Storage; @@ -24,7 +25,7 @@ public class OmsUserService( UserService userService, UserProfileRepository profileRepository, IEmployeeRepository employeeRepository, - IFileStorage fileStorage) : ApplicationService + IObjectStorage fileStorage) : ApplicationService { /// /// 获取用户详情(含扩展资料)。 @@ -98,7 +99,7 @@ public class OmsUserService( Account = input.Account, Password = input.Password, Email = input.Email, - Avatar = fileStorage.ToStorageKey(input.Avatar), + Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), OrganizationId = input.OrganizationId, RoleIds = input.RoleIds, }, cancellationToken); @@ -136,7 +137,7 @@ public class OmsUserService( { Name = input.Name, Email = input.Email, - Avatar = fileStorage.ToStorageKey(input.Avatar), + Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), OrganizationId = input.OrganizationId, }, cancellationToken); @@ -174,7 +175,7 @@ public class OmsUserService( [FromQuery] Guid id, [FromQuery] string avatarUrl, CancellationToken cancellationToken = default) => - userService.UpdateAvatarAsync(id, fileStorage.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken); + userService.UpdateAvatarAsync(id, OmsObjectStorageExtensions.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken); /// /// 删除用户。 diff --git a/src/Oms.Application/Services/Recharge/RechargeService.cs b/src/Oms.Application/Services/Recharge/RechargeService.cs index 0479431..200f43a 100644 --- a/src/Oms.Application/Services/Recharge/RechargeService.cs +++ b/src/Oms.Application/Services/Recharge/RechargeService.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using MiniExcelLibs; using Oms.Application.Dtos.Recharge; using Oms.Application.Dtos.Shared; +using Fake.ObjectStorage; using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Domain.CustomersAggregate.Repositories; @@ -22,7 +23,7 @@ public class RechargeService : ApplicationService { private IRechargeRecordRepository RechargeRecordRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); - private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); + private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService(); /// /// 提交充值申请。 diff --git a/src/Oms.Application/Services/Storage/FileStorageService.cs b/src/Oms.Application/Services/Storage/FileStorageService.cs deleted file mode 100644 index f2d91b0..0000000 --- a/src/Oms.Application/Services/Storage/FileStorageService.cs +++ /dev/null @@ -1,161 +0,0 @@ -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/Services/Warehouse/WarehouseService.Parcel.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs index 156cc0e..af27b0f 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs @@ -178,7 +178,7 @@ public partial class WarehouseService throw new BusinessException("商品数量必须大于 0"); } - var imageUrl = FileStorage.ToStorageKey(itemInput.ImageUrl); + var imageUrl = OmsObjectStorageExtensions.ToStorageKey(itemInput.ImageUrl); if (imageUrl != null && imageUrl.Length > 1024) { imageUrl = imageUrl[..1024]; diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs index 4f16e2e..6a87085 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Warehouse; +using Fake.ObjectStorage; using Oms.Application.Shared; using Oms.Application.Storage; @@ -11,8 +12,8 @@ namespace Oms.Application.Services.Warehouse; public partial class WarehouseService { - private IFileStorage FileStorage => - LazyServiceProvider.GetRequiredService(); + private IObjectStorage FileStorage => + LazyServiceProvider.GetRequiredService(); /// /// 查询 SKU 列表。 @@ -117,7 +118,7 @@ public partial class WarehouseService entity.SkuCode = skuCode; entity.SkuName = skuName; entity.SkuNameEn = string.IsNullOrWhiteSpace(input.SkuNameEn) ? null : input.SkuNameEn.Trim(); - entity.SkuImageUrl = FileStorage.ToStorageKey(input.SkuImageUrl); + entity.SkuImageUrl = OmsObjectStorageExtensions.ToStorageKey(input.SkuImageUrl); entity.UnitPrice = input.UnitPrice; entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim(); entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim(); diff --git a/src/Oms.Application/Services/Xhs/XhsMediaService.cs b/src/Oms.Application/Services/Xhs/XhsMediaService.cs index c4feb9c..d22ff8f 100644 --- a/src/Oms.Application/Services/Xhs/XhsMediaService.cs +++ b/src/Oms.Application/Services/Xhs/XhsMediaService.cs @@ -1,4 +1,5 @@ using Fake.Application; +using Fake.ObjectStorage; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Xhs; @@ -15,7 +16,7 @@ public class XhsMediaService : ApplicationService { private IXhsMediaRepository MediaRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); - private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); + private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService(); /// /// 上传媒体文件 @@ -34,7 +35,7 @@ public class XhsMediaService : ApplicationService var relativeKey = $"uploads/xhs/{customerId}/{storedName}"; await using var stream = file.OpenReadStream(); - var storageKey = await FileStorage.SaveAsync(stream, relativeKey, file.ContentType); + var storageKey = await FileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType); var publicUrl = FileStorage.GetPublicUrl(storageKey); var media = new XhsMedia diff --git a/src/Oms.Application/Shared/OmsFileUploadHelper.cs b/src/Oms.Application/Shared/OmsFileUploadHelper.cs index 0c479fc..157cec9 100644 --- a/src/Oms.Application/Shared/OmsFileUploadHelper.cs +++ b/src/Oms.Application/Shared/OmsFileUploadHelper.cs @@ -1,12 +1,14 @@ using Fake.Application; +using Fake.ObjectStorage; using Microsoft.AspNetCore.Http; using Oms.Application.Storage; namespace Oms.Application.Shared; /// -/// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。 -/// 返回值为 object key,对外展示请再调用 。 +/// OMS 上传文件辅助(经 )。 +/// 返回值为 object key,对外展示请再调用 。 +/// 用户直传请走 Fake.FileManagement(Presign/Sts + Confirm → FileId)。 /// public static class OmsFileUploadHelper { @@ -15,27 +17,18 @@ public static class OmsFileUploadHelper private const long MaxImageBytes = 5 * 1024 * 1024; - /// - /// 保存充值截图,返回可访问 URL。 - /// - public static Task SaveRechargeScreenshotAsync(IFormFile? file, IFileStorage storage) => + public static Task SaveRechargeScreenshotAsync(IFormFile? file, IObjectStorage storage) => SaveImageAsync(file, storage, "recharge", "充值截图"); - /// - /// 保存仓储 SKU 图片。 - /// - public static Task SaveWarehouseSkuImageAsync(IFormFile file, IFileStorage storage) => + public static Task SaveWarehouseSkuImageAsync(IFormFile file, IObjectStorage storage) => SaveImageAsync(file, storage, "sku", "SKU 图片"); - /// - /// 保存包裹商品图片。 - /// - public static Task SaveWarehouseParcelImageAsync(IFormFile file, IFileStorage storage) => + public static Task SaveWarehouseParcelImageAsync(IFormFile file, IObjectStorage storage) => SaveImageAsync(file, storage, "parcel", "包裹商品图片"); private static async Task SaveImageAsync( IFormFile? file, - IFileStorage storage, + IObjectStorage storage, string folder, string label) { @@ -70,7 +63,7 @@ public static class OmsFileUploadHelper var relativeKey = $"uploads/{folder}/{monthFolder}/{fileName}"; await using var stream = file.OpenReadStream(); - return await storage.SaveAsync(stream, relativeKey, file.ContentType); + return await storage.SaveObjectAsync(stream, relativeKey, file.ContentType); } private static readonly HashSet ExcelExtensions = @@ -84,7 +77,7 @@ public static class OmsFileUploadHelper public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync( IFormFile file, int customerId, - IFileStorage storage) + IObjectStorage storage) { if (file == null || file.Length <= 0) { @@ -106,7 +99,7 @@ public static class OmsFileUploadHelper var relativeKey = $"uploads/order-templates/{customerId}/{fileName}"; await using var stream = file.OpenReadStream(); - var key = await storage.SaveAsync( + var key = await storage.SaveObjectAsync( stream, relativeKey, file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); diff --git a/src/Oms.Application/Storage/DirectUploadTickets.cs b/src/Oms.Application/Storage/DirectUploadTickets.cs deleted file mode 100644 index 7b455c1..0000000 --- a/src/Oms.Application/Storage/DirectUploadTickets.cs +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 9a4279c..0000000 --- a/src/Oms.Application/Storage/FileStorageOptions.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace Oms.Application.Storage; - -/// -/// 文件存储配置。Provider=Local 落盘 wwwroot;Provider=Cos 走腾讯云 COS。 -/// -public class FileStorageOptions -{ - public const string SectionName = "FileStorage"; - - /// - /// 存储提供方:Local | Cos。 - /// - public string Provider { get; set; } = "Local"; - - /// - /// 腾讯云 COS 配置(Provider=Cos 时必填)。 - /// - public TencentCosOptions Cos { get; set; } = new(); -} - -/// -/// 腾讯云 COS 连接参数。 -/// -public class TencentCosOptions -{ - /// API 密钥 SecretId。 - public string SecretId { get; set; } = string.Empty; - - /// API 密钥 SecretKey。 - public string SecretKey { get; set; } = string.Empty; - - /// 地域,如 ap-guangzhou。 - public string Region { get; set; } = string.Empty; - - /// 存储桶,格式 bucketname-APPID。 - public string Bucket { get; set; } = string.Empty; - - /// - /// 对外访问根地址。可填 CDN / 自定义域名,末尾不要斜杠。 - /// 空则默认 https://{Bucket}.cos.{Region}.myqcloud.com - /// - public string? BaseUrl { get; set; } - - /// - /// 对象键前缀(如 oms/prod/),可空。 - /// - public string KeyPrefix { get; set; } = string.Empty; - - /// - /// 预签名读 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/FileStorageServiceExtensions.cs b/src/Oms.Application/Storage/FileStorageServiceExtensions.cs deleted file mode 100644 index fb38e7f..0000000 --- a/src/Oms.Application/Storage/FileStorageServiceExtensions.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Oms.Application.Storage; - -public static class FileStorageServiceExtensions -{ - public static IServiceCollection AddOmsFileStorage(this IServiceCollection services, IConfiguration configuration) - { - var section = configuration.GetSection(FileStorageOptions.SectionName); - services.Configure(section); - - var options = section.Get() ?? new FileStorageOptions(); - var provider = ResolveProvider(options); - - if (string.Equals(provider, "Cos", StringComparison.OrdinalIgnoreCase)) - { - services.AddSingleton(); - } - else - { - services.AddSingleton(); - } - - services.AddHostedService(); - return services; - } - - /// - /// 显式 Provider 优先;未写 Provider 但 Cos 凭证齐全时自动走 Cos,避免生产误落 Local。 - /// - internal static string ResolveProvider(FileStorageOptions options) - { - var configured = options.Provider?.Trim(); - if (!string.IsNullOrWhiteSpace(configured)) - { - return configured; - } - - return HasCompleteCos(options.Cos) ? "Cos" : "Local"; - } - - internal static bool HasCompleteCos(TencentCosOptions? cos) => - cos != null - && !string.IsNullOrWhiteSpace(cos.SecretId) - && !string.IsNullOrWhiteSpace(cos.SecretKey) - && !string.IsNullOrWhiteSpace(cos.Region) - && !string.IsNullOrWhiteSpace(cos.Bucket) - && !cos.Bucket.Contains("your-bucket", StringComparison.OrdinalIgnoreCase); -} - -/// -/// 启动时打印实际 IFileStorage 实现,便于核对 Consul 是否生效。 -/// -internal sealed class FileStorageStartupLogger( - IFileStorage storage, - IOptions options, - ILogger logger) : IHostedService -{ - public Task StartAsync(CancellationToken cancellationToken) - { - var cfg = options.Value; - var cosReady = FileStorageServiceExtensions.HasCompleteCos(cfg.Cos); - logger.LogInformation( - "IFileStorage 实际类型={Implementation},配置 Provider={ConfiguredProvider},Cos凭证齐全={CosReady},Section={Section}", - storage.GetType().FullName, - string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider, - cosReady, - FileStorageOptions.SectionName); - - if (storage is LocalFileStorage && cosReady) - { - logger.LogWarning( - "已检测到齐全 Cos 凭证,但仍在使用 LocalFileStorage。请确认 Consul 中 {Section}:Provider 是否为 Cos(当前={ConfiguredProvider})", - FileStorageOptions.SectionName, - string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider); - } - - if (storage is LocalFileStorage && !cosReady) - { - logger.LogWarning( - "当前为 Local 存储。生产若要用 Cos,请在 Consul KV(idwms/admin)配置 {Section}:Provider=Cos 及 Cos.SecretId/SecretKey/Region/Bucket", - FileStorageOptions.SectionName); - } - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} diff --git a/src/Oms.Application/Storage/FileStorageUploadPaths.cs b/src/Oms.Application/Storage/FileStorageUploadPaths.cs deleted file mode 100644 index 5410a72..0000000 --- a/src/Oms.Application/Storage/FileStorageUploadPaths.cs +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index cd6b501..0000000 --- a/src/Oms.Application/Storage/IFileStorage.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace Oms.Application.Storage; - -/// -/// 统一文件存储(本地 / 腾讯云 COS)。 -/// 约定:库内只存 object key(如 uploads/avatars/a.jpg)。 -/// 私有桶场景:访问用临时签名 URL;用户上传用预签名 PUT 或 STS,文件不经业务服务器。 -/// -public interface IFileStorage -{ - /// 是否支持前端直传(COS=true,Local=false)。 - bool SupportsClientDirectUpload { get; } - - /// - /// 服务端落盘/上传(仅用于后端生成的文件,如面单 PDF、导出)。用户上传请走直传。 - /// - Task SaveAsync( - Stream content, - string relativeKey, - string? contentType = null, - CancellationToken cancellationToken = default); - - /// - /// 临时访问地址。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); - - 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 deleted file mode 100644 index b9a54c0..0000000 --- a/src/Oms.Application/Storage/LocalFileStorage.cs +++ /dev/null @@ -1,171 +0,0 @@ -using Fake.Application; -using Microsoft.AspNetCore.Hosting; - -namespace Oms.Application.Storage; - -/// -/// 本地 wwwroot 文件存储。库内存 key,对外为 /key。不支持前端直传 COS。 -/// -public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage -{ - public bool SupportsClientDirectUpload => false; - - public async Task SaveAsync( - Stream content, - string relativeKey, - string? contentType = null, - CancellationToken cancellationToken = default) - { - var key = FileStoragePath.NormalizeKey(relativeKey); - var absolutePath = ToAbsolutePath(key); - var directory = Path.GetDirectoryName(absolutePath); - if (!string.IsNullOrWhiteSpace(directory)) - { - try - { - Directory.CreateDirectory(directory); - } - catch (UnauthorizedAccessException ex) - { - throw new UnauthorizedAccessException( - $"本地文件存储无法写入目录 '{directory}'(WebRoot={GetWebRoot()})。" + - "容器请确保 wwwroot/uploads 可写,或将 Oms:FileStorage:Provider 设为 Cos。", - ex); - } - catch (IOException ex) - { - throw new IOException( - $"本地文件存储无法创建目录 '{directory}'(WebRoot={GetWebRoot()})。" + - "容器请确保 wwwroot/uploads 可写,或将 Oms:FileStorage:Provider 设为 Cos。", - ex); - } - } - - await using var file = new FileStream( - absolutePath, - FileMode.Create, - FileAccess.Write, - FileShare.None, - bufferSize: 81920, - useAsync: true); - if (content.CanSeek) - { - content.Position = 0; - } - - await content.CopyToAsync(file, cancellationToken); - return key; - } - - public string GetPublicUrl(string? keyOrUrl) - { - if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0") - { - return keyOrUrl ?? string.Empty; - } - - if (FileStoragePath.IsAbsoluteUrl(keyOrUrl)) - { - return keyOrUrl; - } - - if (keyOrUrl.StartsWith('/')) - { - return keyOrUrl.Replace('\\', '/'); - } - - 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)) - { - return null; - } - - if (keyOrUrl == "0") - { - return "0"; - } - - if (FileStoragePath.IsAbsoluteUrl(keyOrUrl)) - { - return keyOrUrl.Trim(); - } - - return FileStoragePath.NormalizeKey(keyOrUrl); - } - - public Task OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - var absolutePath = ResolveAbsolutePath(pathOrUrl); - if (!File.Exists(absolutePath)) - { - throw new FileNotFoundException("本地文件不存在", absolutePath); - } - - Stream stream = new FileStream( - absolutePath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: 81920, - useAsync: true); - return Task.FromResult(stream); - } - - public Task ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - var absolutePath = ResolveAbsolutePath(pathOrUrl); - return Task.FromResult(File.Exists(absolutePath)); - } - - public Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - var absolutePath = ResolveAbsolutePath(pathOrUrl); - if (File.Exists(absolutePath)) - { - File.Delete(absolutePath); - } - - return Task.CompletedTask; - } - - private string ResolveAbsolutePath(string pathOrUrl) - { - if (string.IsNullOrWhiteSpace(pathOrUrl)) - { - throw new ArgumentException("路径不能为空", nameof(pathOrUrl)); - } - - if (Path.IsPathRooted(pathOrUrl) && File.Exists(pathOrUrl)) - { - return pathOrUrl; - } - - return ToAbsolutePath(FileStoragePath.NormalizeKey(pathOrUrl)); - } - - private string ToAbsolutePath(string relativeKey) => - Path.Combine(GetWebRoot(), relativeKey.Replace('/', Path.DirectorySeparatorChar)); - - private string GetWebRoot() => - string.IsNullOrWhiteSpace(environment.WebRootPath) - ? Path.Combine(environment.ContentRootPath, "wwwroot") - : environment.WebRootPath; -} diff --git a/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs b/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs new file mode 100644 index 0000000..ab34314 --- /dev/null +++ b/src/Oms.Application/Storage/OmsObjectStorageExtensions.cs @@ -0,0 +1,86 @@ +using Fake.ObjectStorage; + +namespace Oms.Application.Storage; + +/// +/// OMS 对 的约定封装:库内存逻辑 object key(或外链原样),展示用临时/公开 URL。 +/// +public static class OmsObjectStorageExtensions +{ + /// + /// 将 key / 相对路径规范为库内 object key;绝对外链原样返回;空返回 null。 + /// + public static string? ToStorageKey(string? keyOrUrl) + { + if (string.IsNullOrWhiteSpace(keyOrUrl)) + { + return null; + } + + var raw = keyOrUrl.Trim(); + if (raw == "0") + { + return "0"; + } + + if (FileStoragePath.IsAbsoluteUrl(raw)) + { + return raw; + } + + return FileStoragePath.NormalizeKey(raw); + } + + /// + /// 临时访问地址。空值返回空串;外链原样返回;逻辑 key 走 。 + /// + public static async Task GetPublicUrlAsync( + this IObjectStorage storage, + string? keyOrUrl, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0") + { + return string.Empty; + } + + var raw = keyOrUrl.Trim(); + if (FileStoragePath.IsAbsoluteUrl(raw)) + { + return raw; + } + + var key = FileStoragePath.NormalizeKey(raw); + return await storage.GetUrlAsync(key, cancellationToken: cancellationToken); + } + + /// + /// 同步取访问 URL(映射 DTO 等场景)。 + /// + public static string GetPublicUrl(this IObjectStorage storage, string? keyOrUrl) => + storage.GetPublicUrlAsync(keyOrUrl).ConfigureAwait(false).GetAwaiter().GetResult(); + + /// + /// 服务端写入对象,返回逻辑 object key。 + /// + public static async Task SaveObjectAsync( + this IObjectStorage storage, + Stream content, + string relativeKey, + string? contentType = null, + CancellationToken cancellationToken = default) + { + var result = await storage.SaveAsync( + new ObjectStorageSaveArgs + { + ObjectKey = FileStoragePath.NormalizeKey(relativeKey), + Content = content, + ContentType = contentType + }, + cancellationToken); + return result.ObjectKey; + } + + public static bool IsLocalProvider(this IObjectStorage storage) => + string.Equals(storage.Name, "Local", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Oms.Application/Storage/TencentCosFileStorage.cs b/src/Oms.Application/Storage/TencentCosFileStorage.cs deleted file mode 100644 index 9f152d7..0000000 --- a/src/Oms.Application/Storage/TencentCosFileStorage.cs +++ /dev/null @@ -1,487 +0,0 @@ -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;读返回预签名 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, - ILogger logger) - { - _logger = logger; - _options = options.Value.Cos ?? new TencentCosOptions(); - ValidateOptions(_options); - - _appId = ExtractAppId(_options.Bucket); - var config = new CosXmlConfig.Builder() - .IsHttps(_options.IsHttps) - .SetRegion(_options.Region) - .SetAppid(_appId) - .Build(); - - var signSeconds = Math.Max(60, Math.Max(_options.DurationSecond, _options.UploadDurationSecond)); - var credential = new DefaultQCloudCredentialProvider( - _options.SecretId, - _options.SecretKey, - 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, - string? contentType = null, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - var logicalKey = FileStoragePath.NormalizeKey(relativeKey); - var objectKey = ToObjectKey(logicalKey); - if (content.CanSeek) - { - content.Position = 0; - } - - Stream uploadStream = content; - MemoryStream? owned = null; - if (!content.CanSeek) - { - owned = new MemoryStream(); - content.CopyTo(owned); - owned.Position = 0; - uploadStream = owned; - } - - try - { - var request = new PutObjectRequest(_options.Bucket, objectKey, uploadStream); - if (!string.IsNullOrWhiteSpace(contentType)) - { - request.SetRequestHeader("Content-Type", contentType); - } - - var result = _cosXml.PutObject(request); - if (result.httpCode is < 200 or >= 300) - { - throw new BusinessException($"上传 COS 失败: HTTP {result.httpCode}"); - } - - _logger.LogDebug("COS 服务端上传成功: {ObjectKey}", objectKey); - return Task.FromResult(logicalKey); - } - finally - { - owned?.Dispose(); - } - } - - public string GetPublicUrl(string? keyOrUrl) - { - if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0") - { - return keyOrUrl ?? string.Empty; - } - - if (FileStoragePath.IsAbsoluteUrl(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); - } - - 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) - { - if (string.IsNullOrWhiteSpace(keyOrUrl)) - { - return null; - } - - if (keyOrUrl == "0") - { - return "0"; - } - - if (FileStoragePath.IsAbsoluteUrl(keyOrUrl)) - { - if (!IsManagedPublicUrl(keyOrUrl)) - { - return keyOrUrl.Trim(); - } - - var pathKey = FileStoragePath.NormalizeKey(keyOrUrl); - return StripKeyPrefix(pathKey); - } - - return FileStoragePath.NormalizeKey(keyOrUrl); - } - - public Task OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var objectKey = ToObjectKey(pathOrUrl); - var request = new GetObjectBytesRequest(_options.Bucket, objectKey); - var result = _cosXml.GetObject(request); - if (result.httpCode is < 200 or >= 300) - { - throw new FileNotFoundException($"COS 对象不存在或读取失败: {objectKey}"); - } - - Stream stream = new MemoryStream(result.content); - stream.Position = 0; - return Task.FromResult(stream); - } - - public Task ExistsAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - var objectKey = ToObjectKey(pathOrUrl); - var request = new HeadObjectRequest(_options.Bucket, objectKey); - var result = _cosXml.HeadObject(request); - return Task.FromResult(result.httpCode is >= 200 and < 300); - } - catch - { - return Task.FromResult(false); - } - } - - public Task DeleteAsync(string pathOrUrl, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - var objectKey = ToObjectKey(pathOrUrl); - var request = new DeleteObjectRequest(_options.Bucket, objectKey); - _cosXml.DeleteObject(request); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "删除 COS 对象失败: {Path}", pathOrUrl); - } - - return Task.CompletedTask; - } - - public void Dispose() - { - } - - 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); - if (!string.IsNullOrEmpty(_keyPrefix) - && !key.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase) - && !string.Equals(key, _keyPrefix, StringComparison.OrdinalIgnoreCase)) - { - key = $"{_keyPrefix}/{key}"; - } - - return key; - } - - private string StripKeyPrefix(string objectOrLogicalKey) - { - if (string.IsNullOrEmpty(_keyPrefix)) - { - return objectOrLogicalKey; - } - - if (objectOrLogicalKey.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase)) - { - return objectOrLogicalKey[(_keyPrefix.Length + 1)..]; - } - - if (string.Equals(objectOrLogicalKey, _keyPrefix, StringComparison.OrdinalIgnoreCase)) - { - return string.Empty; - } - - return objectOrLogicalKey; - } - - private bool IsManagedPublicUrl(string url) - { - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) - { - return false; - } - - if (Uri.TryCreate(_publicBaseUrl, UriKind.Absolute, out var baseUri) - && string.Equals(uri.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - 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)) - { - return options.BaseUrl.Trim().TrimEnd('/'); - } - - var scheme = options.IsHttps ? "https" : "http"; - return $"{scheme}://{options.Bucket}.cos.{options.Region}.myqcloud.com"; - } - - private static string ExtractAppId(string bucket) - { - var idx = bucket.LastIndexOf('-'); - if (idx <= 0 || idx >= bucket.Length - 1) - { - throw new BusinessException("COS Bucket 格式应为 bucketname-APPID"); - } - - return bucket[(idx + 1)..]; - } - - private static void ValidateOptions(TencentCosOptions options) - { - if (string.IsNullOrWhiteSpace(options.SecretId) - || string.IsNullOrWhiteSpace(options.SecretKey) - || string.IsNullOrWhiteSpace(options.Region) - || string.IsNullOrWhiteSpace(options.Bucket)) - { - throw new BusinessException( - "Oms:FileStorage:Cos 配置不完整,请填写 SecretId / SecretKey / Region / Bucket"); - } - } -} diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 6cc1886..632f352 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Fake.ObjectStorage; using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Domain.CustomersAggregate.Entities; @@ -43,7 +44,7 @@ public partial class AuthService( IWeChatLoginSessionStore weChatLoginSessionStore, IMemoryCache memoryCache, IRbacAuthCache rbacAuthCache, - IFileStorage fileStorage) + IObjectStorage fileStorage) : ApplicationService { private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3); @@ -418,7 +419,7 @@ public partial class AuthService( } output.Position = 0; - var avatarKey = await fileStorage.SaveAsync(output, relativeKey, "image/jpeg", cancellationToken); + var avatarKey = await fileStorage.SaveObjectAsync(output, relativeKey, "image/jpeg", cancellationToken); var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken); user.UpdateAvatar(avatarKey); await userRepository.UpdateAsync(user, cancellationToken: cancellationToken); diff --git a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj index 2648d19..0143f14 100644 --- a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj +++ b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj @@ -15,6 +15,9 @@ + + + diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index bf93dc0..0d0e2a1 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -8,15 +8,20 @@ using Fake.AspNetCore.ExceptionHandling; using Fake.AspNetCore.Newtonsoft; using Fake.AspNetCore.Swagger; using Fake.FeiShu; +using Fake.FileManagement.Application; +using Fake.FileManagement.Infrastructure; +using Fake.ObjectStorage; using Fake.Security.Claims; using Fake.Serilog; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Oms.Application; using Oms.SqlSugarCore; using SimpleAdmin.Api.Auth; +using SimpleAdmin.Api.Storage; using SimpleAdmin.Api.Swagger; using SimpleAdmin.Api.WeChat; using Swashbuckle.AspNetCore.SwaggerGen; @@ -29,6 +34,9 @@ namespace SimpleAdmin.Api; typeof(OmsSqlSugarCoreModule), typeof(FakeRbacApplicationModule), typeof(FakeRbacInfrastructureModule), + typeof(FakeFileManagementApplicationModule), + typeof(FakeFileManagementInfrastructureModule), + typeof(FakeObjectStorageTencentCosModule), typeof(FakeAspNetCoreModule), typeof(FakeSerilogModule) )] @@ -46,7 +54,7 @@ public class SimpleAdminApiModule : FakeModule var services = context.Services; var configuration = context.Services.GetConfiguration(); - // 配置动态 API - 扫描 OMS 与 RBAC 应用服务 + // 配置动态 API - 扫描 OMS / RBAC / FileManagement 应用服务 services.Configure(options => { options.ApplicationServices2Controller(); @@ -63,6 +71,10 @@ public class SimpleAdminApiModule : FakeModule type != typeof(Fake.Rbac.Application.Services.AuthService) && type != typeof(Fake.Rbac.Application.Services.UserService); }); + options.ApplicationServices2Controller(settings => + { + settings.RootPath = "files"; + }); }); services.AddOmsWeChat(configuration); @@ -128,6 +140,8 @@ public class SimpleAdminApiModule : FakeModule }); // 添加授权服务 services.AddAuthorization(); + + services.AddHostedService(); } public override void ConfigureApplication(ApplicationConfigureContext context) @@ -162,6 +176,13 @@ public class SimpleAdminApiModule : FakeModule // 端点映射 app.MapControllers(); + + // FileManagement 与 RBAC 同库并行(EF);应用 Pending migrations 建 stored_file + using (var scope = app.Services.CreateScope()) + { + var fileDb = scope.ServiceProvider.GetRequiredService(); + fileDb.Database.Migrate(); + } app.Services.GetRequiredService().Send("Application started"); } diff --git a/src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs b/src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs new file mode 100644 index 0000000..90a61bc --- /dev/null +++ b/src/SimpleAdmin.Api/Storage/ObjectStorageStartupLogger.cs @@ -0,0 +1,36 @@ +using Fake.ObjectStorage; +using Microsoft.Extensions.Options; + +namespace SimpleAdmin.Api.Storage; + +/// +/// 启动时打印实际 IObjectStorage 实现,便于核对 Consul ObjectStorage 配置是否生效。 +/// +internal sealed class ObjectStorageStartupLogger( + IObjectStorage storage, + IOptions options, + ILogger logger) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + var cfg = options.Value; + logger.LogInformation( + "IObjectStorage 实际类型={Implementation},Name={Name},配置 Provider={ConfiguredProvider},SignUrlsByDefault={SignUrlsByDefault},KeyPrefix={KeyPrefix}", + storage.GetType().FullName, + storage.Name, + string.IsNullOrWhiteSpace(cfg.Provider) ? "(empty)" : cfg.Provider, + cfg.SignUrlsByDefault, + string.IsNullOrWhiteSpace(cfg.KeyPrefix) ? "(empty)" : cfg.KeyPrefix); + + if (string.Equals(storage.Name, "Local", StringComparison.OrdinalIgnoreCase) + && string.Equals(cfg.Provider, "TencentCos", StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning( + "配置 Provider=TencentCos,但仍在使用 Local。请确认已引用 Fake.ObjectStorage.TencentCos 且 Consul 中 ObjectStorage:Provider 已生效"); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/SimpleAdmin.Api/appsettings.Development.json b/src/SimpleAdmin.Api/appsettings.Development.json index 987adaf..1b28af5 100644 --- a/src/SimpleAdmin.Api/appsettings.Development.json +++ b/src/SimpleAdmin.Api/appsettings.Development.json @@ -59,22 +59,34 @@ "FollowUrl": "" } }, - "Oms": { - "FileStorage": { - "Provider": "Local", - "Cos": { - "SecretId": "", - "SecretKey": "", - "Region": "ap-guangzhou", - "Bucket": "your-bucket-1250000000", - "BaseUrl": "", - "KeyPrefix": "", - "DurationSecond": 3600, - "UploadDurationSecond": 600, - "SignUrls": true, - "IsHttps": true - } + "ObjectStorage": { + "Provider": "Local", + "KeyPrefix": "", + "SignUrlsByDefault": true, + "DefaultSignedUrlExpiresSeconds": 3600, + "DefaultUploadExpiresSeconds": 600, + "Local": { + "BasePath": "wwwroot", + "BaseUrl": "" }, + "TencentCos": { + "SecretId": "", + "SecretKey": "", + "Region": "ap-guangzhou", + "Bucket": "your-bucket-1250000000", + "BaseUrl": "", + "SignUrls": true, + "UseHttps": true, + "DurationSecond": 600 + } + }, + "FileManagement": { + "SignUrlsByDefault": true, + "DefaultSignedUrlExpiresSeconds": 3600, + "DefaultMaxSizeBytes": 20971520, + "DefaultStorageSource": "default" + }, + "Oms": { "TrackService": { "Enabled": true, "BaseUrl": "http://api.nanafa.com/track",