diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs index 749dedb..568f6f0 100644 --- a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs +++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs @@ -1,8 +1,8 @@ using Fake.Application; -using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; using Oms.Application.Dtos.Orders; using Oms.Application.Services.Orders; +using Oms.Application.Storage; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; @@ -22,7 +22,7 @@ public sealed class LabelPdfGenerationService( ISystemOtherConfigRepository systemOtherConfigRepository, IOrderRepository orderRepository, OrderFileManager orderFileManager, - IWebHostEnvironment webHostEnvironment, + IFileStorage fileStorage, ILogger logger) { public async Task GenerateAndPersistAsync( @@ -158,11 +158,6 @@ public sealed class LabelPdfGenerationService( private async Task SavePdfAsync(string serviceNo, byte[] pdfBytes, CancellationToken cancellationToken) { - var webRoot = string.IsNullOrWhiteSpace(webHostEnvironment.WebRootPath) - ? Path.Combine(webHostEnvironment.ContentRootPath, "wwwroot") - : webHostEnvironment.WebRootPath; - var directory = Path.Combine(webRoot, "uploads", "prints", "orders"); - Directory.CreateDirectory(directory); var safeNo = string.IsNullOrWhiteSpace(serviceNo) ? "order" : serviceNo.Trim(); foreach (var c in Path.GetInvalidFileNameChars()) { @@ -170,9 +165,9 @@ public sealed class LabelPdfGenerationService( } var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf"; - var absolute = Path.Combine(directory, fileName); - await File.WriteAllBytesAsync(absolute, pdfBytes, cancellationToken); - return $"/uploads/prints/orders/{fileName}"; + var relativeKey = $"uploads/prints/orders/{fileName}"; + await using var stream = new MemoryStream(pdfBytes); + return await fileStorage.SaveAsync(stream, relativeKey, "application/pdf", cancellationToken); } } diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index bea92ee..a63f881 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs index f44ca86..7603018 100644 --- a/src/Oms.Application/OmsApplicationModule.cs +++ b/src/Oms.Application/OmsApplicationModule.cs @@ -13,6 +13,7 @@ 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; @@ -51,6 +52,7 @@ 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 d93f20a..f3e7829 100644 --- a/src/Oms.Application/Services/Orders/OrderFileManager.cs +++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs @@ -1,9 +1,8 @@ using Fake.Application; using Fake.DependencyInjection; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Oms.Application.Dtos.Orders; -using Oms.Application.Shared; +using Oms.Application.Storage; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; @@ -15,7 +14,7 @@ namespace Oms.Application.Services.Orders; /// public sealed class OrderFileManager( IOrderFileRepository orderFileRepository, - IWebHostEnvironment webHostEnvironment) : IScopedDependency + IFileStorage fileStorage) : IScopedDependency { private static readonly HashSet ImageExt = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" }; @@ -303,21 +302,10 @@ public sealed class OrderFileManager( } } - var webRoot = string.IsNullOrWhiteSpace(webHostEnvironment.WebRootPath) - ? Path.Combine(webHostEnvironment.ContentRootPath, "wwwroot") - : webHostEnvironment.WebRootPath; - var relativeDir = Path.Combine("uploads", "order-files", orderId.ToString(), fileType); - var absoluteDir = Path.Combine(webRoot, relativeDir); - Directory.CreateDirectory(absoluteDir); - var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; - var absolutePath = Path.Combine(absoluteDir, storedName); - await using (var stream = File.Create(absolutePath)) - { - await file.CopyToAsync(stream); - } - - var url = $"/uploads/order-files/{orderId}/{fileType}/{storedName}".Replace('\\', '/'); + var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}"; + await using var stream = file.OpenReadStream(); + var url = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType); return (url, 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 8b14ed7..6637f30 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -12,6 +12,7 @@ using Oms.Application.Dtos.Shared; using Oms.Application.Messaging; using Oms.Application.Services.Orders.Templates; using Oms.Application.Shared; +using Oms.Application.Storage; using Oms.Domain.OrdersAggregate; using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Entities; @@ -487,13 +488,15 @@ public partial class OrderService throw new BusinessException("自定义模板样例文件不存在,请重新上传"); } - var absolutePath = OmsFileUploadHelper.ResolveWebRootAbsolutePath(WebHostEnvironment, template.SourceFilePath); - if (!File.Exists(absolutePath)) + if (!await FileStorage.ExistsAsync(template.SourceFilePath)) { throw new BusinessException("自定义模板样例文件不存在,请重新上传"); } - var bytes = await File.ReadAllBytesAsync(absolutePath); + await using var stream = await FileStorage.OpenReadAsync(template.SourceFilePath); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + var bytes = ms.ToArray(); var downloadName = string.IsNullOrWhiteSpace(template.SourceFileName) ? $"{template.Name}.xlsx" : template.SourceFileName; @@ -609,8 +612,6 @@ public partial class OrderService private async Task CreateOrderPrintFileAsync(OrderOutput order) { - var directory = Path.Combine(GetWebRootDirectory(), "uploads", "prints", "orders"); - Directory.CreateDirectory(directory); var fileName = $"{order.ServiceNo}-{DateTime.Now:yyyyMMddHHmmss}.html"; var html = $""" @@ -625,8 +626,9 @@ public partial class OrderService """; - await File.WriteAllTextAsync(Path.Combine(directory, fileName), html, Encoding.UTF8); - return $"/uploads/prints/orders/{fileName}"; + var relativeKey = $"uploads/prints/orders/{fileName}"; + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(html)); + return await FileStorage.SaveAsync(stream, relativeKey, "text/html; charset=utf-8"); } private string GetOrderExportDirectory() => Path.Combine(GetWebRootDirectory(), "uploads", "exports", "orders"); @@ -640,6 +642,17 @@ public partial class OrderService return webRoot; } + private async Task CleanupExpiredLocalExportFilesAsync() + { + // COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。 + if (FileStorage is not LocalFileStorage) + { + return; + } + + await CleanupExpiredFilesAsync(GetOrderExportDirectory()); + } + private async Task CleanupExpiredFilesAsync(string directory) { if (!Directory.Exists(directory)) diff --git a/src/Oms.Application/Services/Orders/OrderService.Template.cs b/src/Oms.Application/Services/Orders/OrderService.Template.cs index e838702..fbfddc9 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Template.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Template.cs @@ -77,7 +77,7 @@ public partial class OrderService var (relativePath, originalName) = await OmsFileUploadHelper.SaveOrderTemplateSampleAsync( input.File, customerId, - WebHostEnvironment); + FileStorage); var suggested = OrderTemplateHeaderMatcher.BuildSuggestedMapping(OrderTemplateKeys.DedicatedSmallParcelStandard, headers); var entity = new OrderTemplate diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 2929197..fbeb271 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -8,6 +8,7 @@ using Oms.Application.Dtos.Shared; using Oms.Application.DataPerm; using Oms.Application.Labeling; using Oms.Application.Shared; +using Oms.Application.Storage; using Newtonsoft.Json; using Oms.Application.Services.Orders.Batch; using Oms.Domain.ChannelsAggregate.Repositories; @@ -34,6 +35,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 LabelPdfGenerationService LabelPdfGenerationService => LazyServiceProvider.GetRequiredService(); private OmsRbacUserDisplayNameResolver UserDisplayNameResolver => @@ -323,7 +325,7 @@ public partial class OrderService : ApplicationService [HttpPost("/order/export")] public async Task ExportAsync([FromBody] OrderExportInput input) { - await CleanupExpiredFilesAsync(GetOrderExportDirectory()); + await CleanupExpiredLocalExportFilesAsync(); var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync( CustomerRepository, CurrentUser, @@ -339,12 +341,17 @@ public partial class OrderService : ApplicationService } var fileName = $"orders-{DateTime.Now:yyyyMMddHHmmss}.xlsx"; - var directory = GetOrderExportDirectory(); - Directory.CreateDirectory(directory); - await MiniExcel.SaveAsAsync(Path.Combine(directory, fileName), await BuildOrderExportRowsAsync(orders)); + var relativeKey = $"uploads/exports/orders/{fileName}"; + await using var stream = new MemoryStream(); + await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders)); + stream.Position = 0; + var downloadUrl = await FileStorage.SaveAsync( + stream, + relativeKey, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); return new OrderExportOutput { - DownloadUrl = $"/uploads/exports/orders/{fileName}", + DownloadUrl = downloadUrl, ExpireAt = DateTime.Now.AddHours(24) }; } diff --git a/src/Oms.Application/Services/Recharge/RechargeService.cs b/src/Oms.Application/Services/Recharge/RechargeService.cs index 1e52a0d..fe659f9 100644 --- a/src/Oms.Application/Services/Recharge/RechargeService.cs +++ b/src/Oms.Application/Services/Recharge/RechargeService.cs @@ -1,12 +1,12 @@ using System.Linq.Expressions; using Fake.Application; using Fake.Domain.Exceptions; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using MiniExcelLibs; using Oms.Application.Dtos.Recharge; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; +using Oms.Application.Storage; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.RechargeAggregate.Entities; using Oms.Domain.RechargeAggregate.Enums; @@ -22,7 +22,7 @@ public class RechargeService : ApplicationService { private IRechargeRecordRepository RechargeRecordRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); - private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService(); + private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); /// /// 提交充值申请。 @@ -45,7 +45,7 @@ public class RechargeService : ApplicationService var screenshotPath = await OmsFileUploadHelper.SaveRechargeScreenshotAsync( input.RechargeScreenshot, - WebHostEnvironment); + FileStorage); var entity = new RechargeRecord { diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs index 3f7cbff..478d585 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Warehouse; using Oms.Application.Shared; +using Oms.Application.Storage; using Oms.Domain.WarehouseAggregate.Consts; using Oms.Domain.WarehouseAggregate.Entities; @@ -211,7 +212,7 @@ public partial class WarehouseService public async Task UploadParcelImageAsync(IFormFile file) { await ResolveMutationCustomerIdAsync(null); - var path = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, WebHostEnvironment); + var path = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, FileStorage); return new { url = path, fileUrl = path }; } diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs index 3bb855f..c598c77 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs @@ -1,18 +1,18 @@ using System.Linq.Expressions; using Fake.Application; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Warehouse; using Oms.Application.Shared; +using Oms.Application.Storage; namespace Oms.Application.Services.Warehouse; public partial class WarehouseService { - private IWebHostEnvironment WebHostEnvironment => - LazyServiceProvider.GetRequiredService(); + private IFileStorage FileStorage => + LazyServiceProvider.GetRequiredService(); /// /// 查询 SKU 列表。 @@ -166,7 +166,7 @@ public partial class WarehouseService public async Task UploadSkuImageAsync(IFormFile file) { await ResolveMutationCustomerIdAsync(null); - var path = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, WebHostEnvironment); + var path = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, FileStorage); return new { url = path, fileUrl = path }; } diff --git a/src/Oms.Application/Services/Xhs/XhsMediaService.cs b/src/Oms.Application/Services/Xhs/XhsMediaService.cs index 389fdaf..5b88b50 100644 --- a/src/Oms.Application/Services/Xhs/XhsMediaService.cs +++ b/src/Oms.Application/Services/Xhs/XhsMediaService.cs @@ -1,9 +1,9 @@ using Fake.Application; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Xhs; using Oms.Application.Shared; +using Oms.Application.Storage; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.XhsAggregate.Entities; using Oms.Domain.XhsAggregate.Repositories; @@ -15,7 +15,7 @@ public class XhsMediaService : ApplicationService { private IXhsMediaRepository MediaRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); - private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService(); + private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); /// /// 上传媒体文件 @@ -29,25 +29,18 @@ public class XhsMediaService : ApplicationService } var customerId = await OmsSessionHelper.ResolveXhsCustomerIdAsync(CustomerRepository, CurrentUser); - var uploadsRoot = Path.Combine(WebHostEnvironment.WebRootPath ?? "wwwroot", "uploads", "xhs", customerId.ToString()); - Directory.CreateDirectory(uploadsRoot); - var ext = Path.GetExtension(file.FileName); var storedName = $"{Guid.NewGuid():N}{ext}"; - var fullPath = Path.Combine(uploadsRoot, storedName); - await using (var stream = File.Create(fullPath)) - { - await file.CopyToAsync(stream); - } + var relativeKey = $"uploads/xhs/{customerId}/{storedName}"; - var relativePath = Path.Combine("uploads", "xhs", customerId.ToString(), storedName).Replace('\\', '/'); - var publicUrl = $"/{relativePath}"; + await using var stream = file.OpenReadStream(); + var publicUrl = await FileStorage.SaveAsync(stream, relativeKey, file.ContentType); var media = new XhsMedia { CustomerId = customerId, FileName = file.FileName, - StoragePath = relativePath, + StoragePath = relativeKey, PublicUrl = publicUrl, MimeType = file.ContentType, FileSizeBytes = file.Length diff --git a/src/Oms.Application/Shared/OmsFileUploadHelper.cs b/src/Oms.Application/Shared/OmsFileUploadHelper.cs index 2116e2e..55c6bc5 100644 --- a/src/Oms.Application/Shared/OmsFileUploadHelper.cs +++ b/src/Oms.Application/Shared/OmsFileUploadHelper.cs @@ -1,11 +1,11 @@ using Fake.Application; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Oms.Application.Storage; namespace Oms.Application.Shared; /// -/// OMS 上传文件落盘(wwwroot/uploads)。 +/// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。 /// public static class OmsFileUploadHelper { @@ -15,26 +15,26 @@ public static class OmsFileUploadHelper private const long MaxImageBytes = 5 * 1024 * 1024; /// - /// 保存充值截图,返回以 /uploads 开头的相对访问路径。 + /// 保存充值截图,返回可访问 URL。 /// - public static Task SaveRechargeScreenshotAsync(IFormFile? file, IWebHostEnvironment env) => - SaveImageAsync(file, env, "recharge", "充值截图"); + public static Task SaveRechargeScreenshotAsync(IFormFile? file, IFileStorage storage) => + SaveImageAsync(file, storage, "recharge", "充值截图"); /// /// 保存仓储 SKU 图片。 /// - public static Task SaveWarehouseSkuImageAsync(IFormFile file, IWebHostEnvironment env) => - SaveImageAsync(file, env, "sku", "SKU 图片"); + public static Task SaveWarehouseSkuImageAsync(IFormFile file, IFileStorage storage) => + SaveImageAsync(file, storage, "sku", "SKU 图片"); /// /// 保存包裹商品图片。 /// - public static Task SaveWarehouseParcelImageAsync(IFormFile file, IWebHostEnvironment env) => - SaveImageAsync(file, env, "parcel", "包裹商品图片"); + public static Task SaveWarehouseParcelImageAsync(IFormFile file, IFileStorage storage) => + SaveImageAsync(file, storage, "parcel", "包裹商品图片"); private static async Task SaveImageAsync( IFormFile? file, - IWebHostEnvironment env, + IFileStorage storage, string folder, string label) { @@ -64,25 +64,12 @@ public static class OmsFileUploadHelper throw new BusinessException($"{label}仅支持 jpg、png、webp、gif、bmp 格式"); } - var webRoot = env.WebRootPath; - if (string.IsNullOrWhiteSpace(webRoot)) - { - webRoot = Path.Combine(env.ContentRootPath, "wwwroot"); - } - var monthFolder = DateTime.Now.ToString("yyyyMM"); - var relativeDir = Path.Combine("uploads", folder, monthFolder); - var absoluteDir = Path.Combine(webRoot, relativeDir); - Directory.CreateDirectory(absoluteDir); - var fileName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; - var absolutePath = Path.Combine(absoluteDir, fileName); - await using (var stream = File.Create(absolutePath)) - { - await file.CopyToAsync(stream); - } + var relativeKey = $"uploads/{folder}/{monthFolder}/{fileName}"; - return $"/uploads/{folder}/{monthFolder}/{fileName}".Replace('\\', '/'); + await using var stream = file.OpenReadStream(); + return await storage.SaveAsync(stream, relativeKey, file.ContentType); } private static readonly HashSet ExcelExtensions = @@ -96,7 +83,7 @@ public static class OmsFileUploadHelper public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync( IFormFile file, int customerId, - IWebHostEnvironment env) + IFileStorage storage) { if (file == null || file.Length <= 0) { @@ -114,37 +101,15 @@ public static class OmsFileUploadHelper throw new BusinessException("仅支持 .xlsx / .xls 文件"); } - var webRoot = env.WebRootPath; - if (string.IsNullOrWhiteSpace(webRoot)) - { - webRoot = Path.Combine(env.ContentRootPath, "wwwroot"); - } - - var relativeDir = Path.Combine("uploads", "order-templates", customerId.ToString()); - var absoluteDir = Path.Combine(webRoot, relativeDir); - Directory.CreateDirectory(absoluteDir); - var fileName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; - var absolutePath = Path.Combine(absoluteDir, fileName); - await using (var stream = File.Create(absolutePath)) - { - await file.CopyToAsync(stream); - } + var relativeKey = $"uploads/order-templates/{customerId}/{fileName}"; - var relativePath = $"/uploads/order-templates/{customerId}/{fileName}".Replace('\\', '/'); - return (relativePath, Path.GetFileName(file.FileName)); - } - - public static string ResolveWebRootAbsolutePath(IWebHostEnvironment env, string relativePath) - { - var webRoot = env.WebRootPath; - if (string.IsNullOrWhiteSpace(webRoot)) - { - webRoot = Path.Combine(env.ContentRootPath, "wwwroot"); - } - - var trimmed = relativePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar); - return Path.Combine(webRoot, trimmed); + await using var stream = file.OpenReadStream(); + var url = await storage.SaveAsync( + stream, + relativeKey, + file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + return (url, Path.GetFileName(file.FileName)); } private static string? GuessExtensionFromContentType(string? contentType) => contentType?.ToLowerInvariant() switch diff --git a/src/Oms.Application/Storage/FileStorageOptions.cs b/src/Oms.Application/Storage/FileStorageOptions.cs new file mode 100644 index 0000000..725eee2 --- /dev/null +++ b/src/Oms.Application/Storage/FileStorageOptions.cs @@ -0,0 +1,54 @@ +namespace Oms.Application.Storage; + +/// +/// 文件存储配置。Provider=Local 落盘 wwwroot;Provider=Cos 走腾讯云 COS。 +/// +public class FileStorageOptions +{ + public const string SectionName = "Oms: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; + + /// 签名有效期(秒)。 + public long DurationSecond { get; set; } = 600; + + /// 是否 HTTPS。 + public bool IsHttps { get; set; } = true; +} diff --git a/src/Oms.Application/Storage/FileStorageServiceExtensions.cs b/src/Oms.Application/Storage/FileStorageServiceExtensions.cs new file mode 100644 index 0000000..9d367b8 --- /dev/null +++ b/src/Oms.Application/Storage/FileStorageServiceExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Oms.Application.Storage; + +public static class FileStorageServiceExtensions +{ + public static IServiceCollection AddOmsFileStorage(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(FileStorageOptions.SectionName)); + + var provider = configuration.GetSection(FileStorageOptions.SectionName)["Provider"] + ?? "Local"; + + if (string.Equals(provider, "Cos", StringComparison.OrdinalIgnoreCase)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + + return services; + } +} diff --git a/src/Oms.Application/Storage/IFileStorage.cs b/src/Oms.Application/Storage/IFileStorage.cs new file mode 100644 index 0000000..3f0c91b --- /dev/null +++ b/src/Oms.Application/Storage/IFileStorage.cs @@ -0,0 +1,36 @@ +namespace Oms.Application.Storage; + +/// +/// 统一文件存储(本地 / 腾讯云 COS)。 +/// +public interface IFileStorage +{ + /// + /// 保存文件。 + /// + /// 文件流。 + /// 相对键,如 uploads/avatars/a.jpg(不要前导 /)。 + /// MIME,可选。 + /// 取消令牌。 + /// 可对外访问的 URL(本地为 /uploads/...,COS 为完整 URL)。 + Task SaveAsync( + Stream content, + string relativeKey, + string? contentType = null, + CancellationToken cancellationToken = default); + + /// + /// 打开已保存文件的只读流。pathOrUrl 可为相对路径或完整 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 new file mode 100644 index 0000000..77e9588 --- /dev/null +++ b/src/Oms.Application/Storage/LocalFileStorage.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Hosting; + +namespace Oms.Application.Storage; + +/// +/// 本地 wwwroot 文件存储。 +/// +public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage +{ + public async Task SaveAsync( + Stream content, + string relativeKey, + string? contentType = null, + CancellationToken cancellationToken = default) + { + var key = NormalizeRelativeKey(relativeKey); + var absolutePath = ToAbsolutePath(key); + var directory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + 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.Replace('\\', '/'); + } + + 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; + } + + var key = NormalizeRelativeKey(pathOrUrl); + return ToAbsolutePath(key); + } + + private string ToAbsolutePath(string relativeKey) + { + var webRoot = string.IsNullOrWhiteSpace(environment.WebRootPath) + ? Path.Combine(environment.ContentRootPath, "wwwroot") + : environment.WebRootPath; + return Path.Combine(webRoot, relativeKey.Replace('/', Path.DirectorySeparatorChar)); + } + + private static string NormalizeRelativeKey(string relativeKey) + { + var key = relativeKey.Trim().Replace('\\', '/').TrimStart('/'); + if (key.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + var uri = new Uri(key); + key = uri.AbsolutePath.TrimStart('/'); + } + + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("relativeKey 不能为空", nameof(relativeKey)); + } + + return key; + } +} diff --git a/src/Oms.Application/Storage/TencentCosFileStorage.cs b/src/Oms.Application/Storage/TencentCosFileStorage.cs new file mode 100644 index 0000000..9a544d5 --- /dev/null +++ b/src/Oms.Application/Storage/TencentCosFileStorage.cs @@ -0,0 +1,217 @@ +using COSXML; +using COSXML.Auth; +using COSXML.Model.Object; +using Fake.Application; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Oms.Application.Storage; + +/// +/// 腾讯云 COS 文件存储。 +/// +public sealed class TencentCosFileStorage : IFileStorage, IDisposable +{ + private readonly TencentCosOptions _options; + private readonly CosXmlServer _cosXml; + private readonly ILogger _logger; + private readonly string _publicBaseUrl; + + public TencentCosFileStorage( + IOptions options, + ILogger logger) + { + _logger = logger; + _options = options.Value.Cos ?? new TencentCosOptions(); + ValidateOptions(_options); + + var config = new CosXmlConfig.Builder() + .IsHttps(_options.IsHttps) + .SetRegion(_options.Region) + .SetAppid(ExtractAppId(_options.Bucket)) + .Build(); + + var credential = new DefaultQCloudCredentialProvider( + _options.SecretId, + _options.SecretKey, + _options.DurationSecond); + + _cosXml = new CosXmlServer(config, credential); + _publicBaseUrl = ResolvePublicBaseUrl(_options); + } + + public Task SaveAsync( + Stream content, + string relativeKey, + string? contentType = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var objectKey = ToObjectKey(relativeKey); + if (content.CanSeek) + { + content.Position = 0; + } + + // SDK PutObject(Stream) 需要可定位流;不可定位时先拷到内存。 + 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}"); + } + + var url = $"{_publicBaseUrl}/{objectKey}"; + _logger.LogDebug("COS 上传成功: {ObjectKey}", objectKey); + return Task.FromResult(url); + } + finally + { + owned?.Dispose(); + } + } + + 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() + { + // CosXmlServer 无显式 Dispose;保留以便未来扩展。 + } + + private string ToObjectKey(string pathOrUrl) + { + if (string.IsNullOrWhiteSpace(pathOrUrl)) + { + throw new ArgumentException("路径不能为空", nameof(pathOrUrl)); + } + + var key = pathOrUrl.Trim().Replace('\\', '/'); + if (key.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + var uri = new Uri(key); + key = uri.AbsolutePath.TrimStart('/'); + } + else + { + key = key.TrimStart('/'); + } + + var prefix = (_options.KeyPrefix ?? string.Empty).Replace('\\', '/').Trim('/'); + if (!string.IsNullOrEmpty(prefix)) + { + // 若已带前缀则不重复追加(兼容完整 URL 回读)。 + if (!key.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase) + && !string.Equals(key, prefix, StringComparison.OrdinalIgnoreCase)) + { + key = $"{prefix}/{key}"; + } + } + + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("对象键不能为空", nameof(pathOrUrl)); + } + + return key; + } + + 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 c380ff1..3d25dcc 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Oms.Application.Shared; +using Oms.Application.Storage; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Enums; using Oms.Domain.CustomersAggregate.Repositories; @@ -36,13 +37,13 @@ public partial class AuthService( AccountManager accountManager, IJwtService jwtService, IUserRepository userRepository, - IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContextAccessor, FakeRbacDbContext rbacDbContext, ICustomerRepository customerRepository, IWeChatLoginSessionStore weChatLoginSessionStore, IMemoryCache memoryCache, - IRbacAuthCache rbacAuthCache) + IRbacAuthCache rbacAuthCache, + IFileStorage fileStorage) : ApplicationService { private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3); @@ -384,12 +385,10 @@ public partial class AuthService( throw new DomainException("文件大小不能超过10MB"); } - var uploadPath = Path.Combine(webHostEnvironment.WebRootPath ?? webHostEnvironment.ContentRootPath, "uploads", "avatars"); - Directory.CreateDirectory(uploadPath); - - var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}{extension}"; - var filePath = Path.Combine(uploadPath, fileName); + var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}.jpg"; + var relativeKey = $"uploads/avatars/{fileName}"; + await using var output = new MemoryStream(); using (var stream = file.OpenReadStream()) using (var image = await Image.LoadAsync(stream, cancellationToken)) { @@ -412,13 +411,14 @@ public partial class AuthService( } image.Mutate(x => x.Resize(width, height)); - await image.SaveAsJpegAsync(filePath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder + await image.SaveAsJpegAsync(output, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder { Quality = 80 }, cancellationToken); } - var avatarUrl = $"/uploads/avatars/{fileName}"; + output.Position = 0; + var avatarUrl = await fileStorage.SaveAsync(output, relativeKey, "image/jpeg", cancellationToken); var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken); user.UpdateAvatar(avatarUrl); await userRepository.UpdateAsync(user, cancellationToken: cancellationToken); diff --git a/src/SimpleAdmin.Api/appsettings.Development.json b/src/SimpleAdmin.Api/appsettings.Development.json index 1439c62..a9e6fcf 100644 --- a/src/SimpleAdmin.Api/appsettings.Development.json +++ b/src/SimpleAdmin.Api/appsettings.Development.json @@ -60,6 +60,19 @@ } }, "Oms": { + "FileStorage": { + "Provider": "Local", + "Cos": { + "SecretId": "", + "SecretKey": "", + "Region": "ap-guangzhou", + "Bucket": "your-bucket-1250000000", + "BaseUrl": "", + "KeyPrefix": "", + "DurationSecond": 600, + "IsHttps": true + } + }, "TrackService": { "Enabled": true, "BaseUrl": "http://api.nanafa.com/track", @@ -120,4 +133,4 @@ "Enabled": false } } -} \ No newline at end of file +} diff --git a/src/SimpleAdmin.Api/appsettings.Production.json b/src/SimpleAdmin.Api/appsettings.Production.json deleted file mode 100644 index 49f2a98..0000000 --- a/src/SimpleAdmin.Api/appsettings.Production.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "UseLocalConfigs": false, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "App": { - "CorsOrigins": [ - "https://ideship.com", - "https://www.ideship.com" - ], - "SendStartupNotification": false - }, - "WeChat": { - "Mp": { - "OAuthRedirectBase": "https://ideship.com", - "FollowUrl": "" - } - }, - "Oms": { - "TrackService": { - "Enabled": true, - "BaseUrl": "https://api.ideship.com/track" - }, - "Itfx": { - "BaseUrl": "https://api.ideship.com/itfx" - }, - "PlatformShop": { - "FrontendBaseUrl": "https://ideship.com" - } - } -}