From 56c18c034358b93715319e13f15c45a0999b4f3f Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Thu, 10 Sep 2026 18:00:18 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=8A=A0=E5=BC=BAcos=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Labeling/LabelPdfGenerationService.cs | 2 +- .../Services/Orders/OrderFileManager.cs | 14 +- .../Services/Orders/OrderService.Helpers.cs | 6 +- .../Orders/OrderService.ParcelBilling.cs | 26 +++- .../Services/Orders/OrderService.StockBind.cs | 6 +- .../Services/Orders/OrderService.cs | 4 +- .../Services/Rbac/OmsUserService.cs | 25 ++-- .../Services/Recharge/RechargeService.cs | 7 +- .../Warehouse/WarehouseService.Parcel.cs | 11 +- .../Warehouse/WarehouseService.Sku.cs | 15 +- .../Services/Xhs/XhsMediaService.cs | 5 +- .../Shared/OmsFileUploadHelper.cs | 7 +- .../Storage/FileStoragePath.cs | 41 ++++++ .../Storage/FileStorageServiceExtensions.cs | 73 +++++++++- src/Oms.Application/Storage/IFileStorage.cs | 16 ++- .../Storage/LocalFileStorage.cs | 98 +++++++++---- .../Storage/TencentCosFileStorage.cs | 130 +++++++++++++----- src/SimpleAdmin.Api/Auth/AuthService.cs | 10 +- src/SimpleAdmin.Api/Dockerfile | 4 + 19 files changed, 378 insertions(+), 122 deletions(-) create mode 100644 src/Oms.Application/Storage/FileStoragePath.cs diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs index 568f6f0..8b3516b 100644 --- a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs +++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs @@ -68,7 +68,7 @@ public sealed class LabelPdfGenerationService( return new LabelPdfGenerateResult { Success = true, - LabelUrl = relativeUrl, + LabelUrl = fileStorage.GetPublicUrl(relativeUrl), LabelTemplateId = templateId, Status = LabelGenerateStatusCodes.Ready }; diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs index f3e7829..b4ae163 100644 --- a/src/Oms.Application/Services/Orders/OrderFileManager.cs +++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs @@ -31,7 +31,7 @@ public sealed class OrderFileManager( orderId, OrderFileTypeCodes.SystemLabel, cancellationToken); - return string.IsNullOrWhiteSpace(file?.FileUrl) ? null : file.FileUrl.Trim(); + return string.IsNullOrWhiteSpace(file?.FileUrl) ? null : fileStorage.GetPublicUrl(file.FileUrl.Trim()); } public async Task GetGroupedAsync( @@ -94,7 +94,7 @@ public sealed class OrderFileManager( OrderId = orderId, FileType = OrderFileTypeCodes.SystemLabel, FileName = fileName, - FileUrl = fileUrl.Trim(), + FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(), ContentType = contentType, FileSize = fileSize, Sort = 0, @@ -143,7 +143,7 @@ public sealed class OrderFileManager( OrderId = orderId, FileType = OrderFileTypeCodes.DownstreamLabel, FileName = TryFileNameFromUrl(fileUrl), - FileUrl = fileUrl.Trim(), + FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(), ContentType = null, Sort = existing.Count, Source = OrderFileSourceCodes.Downstream, @@ -305,18 +305,18 @@ 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 url = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType); - return (url, Path.GetFileName(file.FileName), file.ContentType, file.Length); + var key = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType); + return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length); } - private static OrderFileItemOutput MapItem(OrderFile x) => + private OrderFileItemOutput MapItem(OrderFile x) => new() { Id = x.Id, OrderId = x.OrderId, FileType = x.FileType, FileName = x.FileName, - FileUrl = x.FileUrl, + FileUrl = fileStorage.GetPublicUrl(x.FileUrl), ContentType = x.ContentType, FileSize = x.FileSize, Sort = x.Sort, diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 6637f30..fc27a3d 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -628,7 +628,8 @@ public partial class OrderService """; 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"); + var key = await FileStorage.SaveAsync(stream, relativeKey, "text/html; charset=utf-8"); + return FileStorage.GetPublicUrl(key); } private string GetOrderExportDirectory() => Path.Combine(GetWebRootDirectory(), "uploads", "exports", "orders"); @@ -638,7 +639,8 @@ public partial class OrderService var webRoot = string.IsNullOrWhiteSpace(WebHostEnvironment.WebRootPath) ? Path.Combine(WebHostEnvironment.ContentRootPath, "wwwroot") : WebHostEnvironment.WebRootPath; - Directory.CreateDirectory(webRoot); + // 只确保 uploads 子目录可写,避免在只读 ContentRoot 上强建 wwwroot 根目录。 + Directory.CreateDirectory(Path.Combine(webRoot, "uploads")); return webRoot; } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 5d22cdc..87b4e80 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2541,6 +2541,26 @@ public partial class OrderService { var mapped = (await MapOrderOutputsAsync([order])).First(); var systemLabelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id); + var labelUrl = systemLabelUrl ?? string.Empty; + if (string.IsNullOrWhiteSpace(labelUrl) && order.OrderStatus == OrderStatusCodes.Predeclared) + { + // 回落 HTML 面单仅用于返回 LabelUrl;写盘失败不得回滚已预报成功的订单。 + try + { + labelUrl = await CreateOrderPrintFileAsync(mapped); + } + catch (Exception ex) + { + var logger = LazyServiceProvider.GetRequiredService() + .CreateLogger("Oms.OrderParcelCreate"); + logger.LogWarning( + ex, + "预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}", + order.Id, + order.ServiceNo); + } + } + return new OrderParcelCreateOutput { OrderId = order.Id, @@ -2549,11 +2569,7 @@ public partial class OrderService TrackNo = order.TrackNo, ConsigneeCountry = order.ConsigneeCountry, ConsigneeCountryName = order.ConsigneeCountryName, - LabelUrl = !string.IsNullOrWhiteSpace(systemLabelUrl) - ? systemLabelUrl! - : order.OrderStatus == OrderStatusCodes.Predeclared - ? await CreateOrderPrintFileAsync(mapped) - : string.Empty + LabelUrl = labelUrl }; } diff --git a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs index d8f5150..09ec87b 100644 --- a/src/Oms.Application/Services/Orders/OrderService.StockBind.cs +++ b/src/Oms.Application/Services/Orders/OrderService.StockBind.cs @@ -75,7 +75,7 @@ public partial class OrderService SkuId = g.Key, SkuCode = sku?.SkuCode ?? string.Empty, SkuName = sku?.SkuName ?? string.Empty, - SkuImageUrl = sku?.SkuImageUrl, + SkuImageUrl = FileStorage.GetPublicUrl(sku?.SkuImageUrl), WarehouseId = warehouseId, TotalQty = g.Sum(x => x.Qty), FrozenQty = g.Sum(x => x.FrozenQty), @@ -150,7 +150,7 @@ public partial class OrderService ParcelItemId = x.Id, ProductName = x.ProductName, AttributeText = x.AttributeText, - ImageUrl = x.ImageUrl, + ImageUrl = FileStorage.GetPublicUrl(x.ImageUrl), SkuId = x.SkuId, SkuCode = x.SkuCode, Quantity = x.Quantity, @@ -794,7 +794,7 @@ public partial class OrderService TrackingNo = bind.TrackingNo, ProductName = bind.ProductName, AttributeText = bind.AttributeText, - ImageUrl = bind.ImageUrl, + ImageUrl = FileStorage.GetPublicUrl(bind.ImageUrl), BindQty = bind.BindQty, AvailableQty = availableQty, Status = bind.Status diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index fbeb271..91bff8f 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -345,13 +345,13 @@ public partial class OrderService : ApplicationService await using var stream = new MemoryStream(); await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders)); stream.Position = 0; - var downloadUrl = await FileStorage.SaveAsync( + var storageKey = await FileStorage.SaveAsync( stream, relativeKey, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); return new OrderExportOutput { - DownloadUrl = downloadUrl, + DownloadUrl = FileStorage.GetPublicUrl(storageKey), ExpireAt = DateTime.Now.AddHours(24) }; } diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs index 27f8eb6..45ae3aa 100644 --- a/src/Oms.Application/Services/Rbac/OmsUserService.cs +++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Rbac; using Oms.Application.Rbac; +using Oms.Application.Storage; using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Enums; using Oms.Domain.SystemAggregate.Repositories; @@ -22,7 +23,8 @@ namespace Oms.Application.Services.Rbac; public class OmsUserService( UserService userService, UserProfileRepository profileRepository, - IEmployeeRepository employeeRepository) : ApplicationService + IEmployeeRepository employeeRepository, + IFileStorage fileStorage) : ApplicationService { /// /// 获取用户详情(含扩展资料)。 @@ -32,7 +34,7 @@ public class OmsUserService( { var user = await userService.GetAsync(id, cancellationToken); var profile = await profileRepository.GetAsync(id, cancellationToken); - return OmsUserProfileMapper.ToDetailDto(user, profile); + return MapUserDetail(user, profile); } /// @@ -66,7 +68,7 @@ public class OmsUserService( .Select(user => { profileMap.TryGetValue(user.Id, out var profile); - return OmsUserProfileMapper.ToDetailDto(user, profile); + return MapUserDetail(user, profile); }) .ToList(), }; @@ -96,7 +98,7 @@ public class OmsUserService( Account = input.Account, Password = input.Password, Email = input.Email, - Avatar = input.Avatar, + Avatar = fileStorage.ToStorageKey(input.Avatar), OrganizationId = input.OrganizationId, RoleIds = input.RoleIds, }, cancellationToken); @@ -116,7 +118,7 @@ public class OmsUserService( await EnsureEmployeeProfileAsync(user.Id, input, cancellationToken); var profile = await profileRepository.GetAsync(user.Id, cancellationToken); - return OmsUserProfileMapper.ToDetailDto(user, profile); + return MapUserDetail(user, profile); } /// @@ -134,7 +136,7 @@ public class OmsUserService( { Name = input.Name, Email = input.Email, - Avatar = input.Avatar, + Avatar = fileStorage.ToStorageKey(input.Avatar), OrganizationId = input.OrganizationId, }, cancellationToken); @@ -151,7 +153,7 @@ public class OmsUserService( cancellationToken); var profile = await profileRepository.GetAsync(id, cancellationToken); - return OmsUserProfileMapper.ToDetailDto(user, profile); + return MapUserDetail(user, profile); } /// @@ -172,7 +174,7 @@ public class OmsUserService( [FromQuery] Guid id, [FromQuery] string avatarUrl, CancellationToken cancellationToken = default) => - userService.UpdateAvatarAsync(id, avatarUrl, cancellationToken); + userService.UpdateAvatarAsync(id, fileStorage.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken); /// /// 删除用户。 @@ -259,4 +261,11 @@ public class OmsUserService( }, cancellationToken); } + + private OmsUserDetailDto MapUserDetail(UserDto user, UserProfileData? profile) + { + var detail = OmsUserProfileMapper.ToDetailDto(user, profile); + detail.Avatar = fileStorage.GetPublicUrl(detail.Avatar); + return detail; + } } diff --git a/src/Oms.Application/Services/Recharge/RechargeService.cs b/src/Oms.Application/Services/Recharge/RechargeService.cs index fe659f9..0479431 100644 --- a/src/Oms.Application/Services/Recharge/RechargeService.cs +++ b/src/Oms.Application/Services/Recharge/RechargeService.cs @@ -60,7 +60,9 @@ public class RechargeService : ApplicationService }; var savedEntity = await RechargeRecordRepository.InsertAsync(entity); - return ObjectMapper.Map(savedEntity); + var output = ObjectMapper.Map(savedEntity); + output.RechargeScreenshot = FileStorage.GetPublicUrl(savedEntity.RechargeScreenshot); + return output; } /// @@ -159,6 +161,7 @@ public class RechargeService : ApplicationService var output = ObjectMapper.Map(entity); output.PayTypeName = GetPayTypeName(entity.PayType); output.RechargeStatusName = GetRechargeStatusName(entity.RechargeStatus); + output.RechargeScreenshot = FileStorage.GetPublicUrl(entity.RechargeScreenshot); if (customerMap.TryGetValue(entity.CustomerId, out var customer)) { output.CustomerCode = customer.CustomerCode; @@ -205,7 +208,7 @@ public class RechargeService : ApplicationService record.PayType, PayTypeName = GetPayTypeName(record.PayType), record.RechargeAmount, - record.RechargeScreenshot, + RechargeScreenshot = FileStorage.GetPublicUrl(record.RechargeScreenshot), record.RechargeStatus, RechargeStatusName = GetRechargeStatusName(record.RechargeStatus), record.Remark, diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs index 478d585..156cc0e 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 = string.IsNullOrWhiteSpace(itemInput.ImageUrl) ? null : itemInput.ImageUrl.Trim(); + var imageUrl = FileStorage.ToStorageKey(itemInput.ImageUrl); if (imageUrl != null && imageUrl.Length > 1024) { imageUrl = imageUrl[..1024]; @@ -212,8 +212,9 @@ public partial class WarehouseService public async Task UploadParcelImageAsync(IFormFile file) { await ResolveMutationCustomerIdAsync(null); - var path = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, FileStorage); - return new { url = path, fileUrl = path }; + var key = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, FileStorage); + var url = FileStorage.GetPublicUrl(key); + return new { key, url, fileUrl = key }; } /// @@ -309,7 +310,7 @@ public partial class WarehouseService } } - private static WarehouseParcelOutput MapParcelOutput(CParcel parcel, IEnumerable items) + private WarehouseParcelOutput MapParcelOutput(CParcel parcel, IEnumerable items) { var itemList = items.ToList(); var mappedItems = itemList.Select(item => new WarehouseParcelItemOutput @@ -317,7 +318,7 @@ public partial class WarehouseService Id = item.Id, ProductName = item.ProductName, AttributeText = item.AttributeText, - ImageUrl = item.ImageUrl, + ImageUrl = FileStorage.GetPublicUrl(item.ImageUrl), Quantity = item.Quantity, FrozenQty = item.FrozenQty, AvailableQty = item.AvailableQty > 0 ? item.AvailableQty : Math.Max(0, item.Quantity - item.FrozenQty), diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs index c598c77..4f16e2e 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs @@ -117,7 +117,7 @@ public partial class WarehouseService entity.SkuCode = skuCode; entity.SkuName = skuName; entity.SkuNameEn = string.IsNullOrWhiteSpace(input.SkuNameEn) ? null : input.SkuNameEn.Trim(); - entity.SkuImageUrl = string.IsNullOrWhiteSpace(input.SkuImageUrl) ? null : input.SkuImageUrl.Trim(); + entity.SkuImageUrl = FileStorage.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(); @@ -166,8 +166,9 @@ public partial class WarehouseService public async Task UploadSkuImageAsync(IFormFile file) { await ResolveMutationCustomerIdAsync(null); - var path = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, FileStorage); - return new { url = path, fileUrl = path }; + var key = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, FileStorage); + var url = FileStorage.GetPublicUrl(key); + return new { key, url, fileUrl = key }; } /// @@ -217,7 +218,7 @@ public partial class WarehouseService }; } - private static WarehouseSkuOutput MapSkuListOutput(Domain.WarehouseAggregate.Entities.CSku entity) => + private WarehouseSkuOutput MapSkuListOutput(Domain.WarehouseAggregate.Entities.CSku entity) => new() { Id = entity.Id, @@ -225,7 +226,7 @@ public partial class WarehouseService SkuCode = entity.SkuCode, SkuName = entity.SkuName, SkuNameEn = entity.SkuNameEn, - SkuImageUrl = entity.SkuImageUrl, + SkuImageUrl = FileStorage.GetPublicUrl(entity.SkuImageUrl), UnitPrice = entity.UnitPrice, ExHsCode = entity.ExHsCode, ImHsCode = entity.ImHsCode, @@ -238,7 +239,7 @@ public partial class WarehouseService UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) }; - private static WarehouseSkuOutput MapSkuOutput(Domain.WarehouseAggregate.Entities.CSku entity, int? totalQty) => + private WarehouseSkuOutput MapSkuOutput(Domain.WarehouseAggregate.Entities.CSku entity, int? totalQty) => new() { Id = entity.Id, @@ -246,7 +247,7 @@ public partial class WarehouseService SkuCode = entity.SkuCode, SkuName = entity.SkuName, SkuNameEn = entity.SkuNameEn, - SkuImageUrl = entity.SkuImageUrl, + SkuImageUrl = FileStorage.GetPublicUrl(entity.SkuImageUrl), UnitPrice = entity.UnitPrice, ExHsCode = entity.ExHsCode, ImHsCode = entity.ImHsCode, diff --git a/src/Oms.Application/Services/Xhs/XhsMediaService.cs b/src/Oms.Application/Services/Xhs/XhsMediaService.cs index 5b88b50..c4feb9c 100644 --- a/src/Oms.Application/Services/Xhs/XhsMediaService.cs +++ b/src/Oms.Application/Services/Xhs/XhsMediaService.cs @@ -34,13 +34,14 @@ public class XhsMediaService : ApplicationService var relativeKey = $"uploads/xhs/{customerId}/{storedName}"; await using var stream = file.OpenReadStream(); - var publicUrl = await FileStorage.SaveAsync(stream, relativeKey, file.ContentType); + var storageKey = await FileStorage.SaveAsync(stream, relativeKey, file.ContentType); + var publicUrl = FileStorage.GetPublicUrl(storageKey); var media = new XhsMedia { CustomerId = customerId, FileName = file.FileName, - StoragePath = relativeKey, + StoragePath = storageKey, 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 55c6bc5..0c479fc 100644 --- a/src/Oms.Application/Shared/OmsFileUploadHelper.cs +++ b/src/Oms.Application/Shared/OmsFileUploadHelper.cs @@ -6,6 +6,7 @@ namespace Oms.Application.Shared; /// /// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。 +/// 返回值为 object key,对外展示请再调用 。 /// public static class OmsFileUploadHelper { @@ -78,7 +79,7 @@ public static class OmsFileUploadHelper private const long MaxOrderTemplateBytes = 10 * 1024 * 1024; /// - /// 保存下单自定义模板样例文件。 + /// 保存下单自定义模板样例文件,返回 (objectKey, 原始文件名)。 /// public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync( IFormFile file, @@ -105,11 +106,11 @@ public static class OmsFileUploadHelper var relativeKey = $"uploads/order-templates/{customerId}/{fileName}"; await using var stream = file.OpenReadStream(); - var url = await storage.SaveAsync( + var key = await storage.SaveAsync( stream, relativeKey, file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); - return (url, Path.GetFileName(file.FileName)); + return (key, Path.GetFileName(file.FileName)); } private static string? GuessExtensionFromContentType(string? contentType) => contentType?.ToLowerInvariant() switch diff --git a/src/Oms.Application/Storage/FileStoragePath.cs b/src/Oms.Application/Storage/FileStoragePath.cs new file mode 100644 index 0000000..d0322a8 --- /dev/null +++ b/src/Oms.Application/Storage/FileStoragePath.cs @@ -0,0 +1,41 @@ +namespace Oms.Application.Storage; + +/// +/// 文件存储 key / URL 规范化。 +/// +public static class FileStoragePath +{ + /// + /// 规范化为库内 object key:uploads/a.jpg(无前导 /,完整 URL 取 AbsolutePath)。 + /// + public static string NormalizeKey(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)) + { + key = new Uri(key).AbsolutePath; + } + + key = key.TrimStart('/'); + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("object key 不能为空", nameof(pathOrUrl)); + } + + return key; + } + + /// + /// 是否已是绝对 URL(含微信头像等外链)。 + /// + public static bool IsAbsoluteUrl(string? value) => + !string.IsNullOrWhiteSpace(value) + && (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/Oms.Application/Storage/FileStorageServiceExtensions.cs b/src/Oms.Application/Storage/FileStorageServiceExtensions.cs index 9d367b8..fb38e7f 100644 --- a/src/Oms.Application/Storage/FileStorageServiceExtensions.cs +++ b/src/Oms.Application/Storage/FileStorageServiceExtensions.cs @@ -1,5 +1,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Oms.Application.Storage; @@ -7,10 +10,11 @@ public static class FileStorageServiceExtensions { public static IServiceCollection AddOmsFileStorage(this IServiceCollection services, IConfiguration configuration) { - services.Configure(configuration.GetSection(FileStorageOptions.SectionName)); + var section = configuration.GetSection(FileStorageOptions.SectionName); + services.Configure(section); - var provider = configuration.GetSection(FileStorageOptions.SectionName)["Provider"] - ?? "Local"; + var options = section.Get() ?? new FileStorageOptions(); + var provider = ResolveProvider(options); if (string.Equals(provider, "Cos", StringComparison.OrdinalIgnoreCase)) { @@ -21,6 +25,69 @@ public static class FileStorageServiceExtensions 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/IFileStorage.cs b/src/Oms.Application/Storage/IFileStorage.cs index 3f0c91b..a496d58 100644 --- a/src/Oms.Application/Storage/IFileStorage.cs +++ b/src/Oms.Application/Storage/IFileStorage.cs @@ -2,6 +2,7 @@ namespace Oms.Application.Storage; /// /// 统一文件存储(本地 / 腾讯云 COS)。 +/// 约定:库内只存 object key(如 uploads/avatars/a.jpg),对外访问地址用 拼接。 /// public interface IFileStorage { @@ -12,7 +13,7 @@ public interface IFileStorage /// 相对键,如 uploads/avatars/a.jpg(不要前导 /)。 /// MIME,可选。 /// 取消令牌。 - /// 可对外访问的 URL(本地为 /uploads/...,COS 为完整 URL)。 + /// 存储 object key(不含域名;不含 KeyPrefix)。 Task SaveAsync( Stream content, string relativeKey, @@ -20,7 +21,18 @@ public interface IFileStorage CancellationToken cancellationToken = default); /// - /// 打开已保存文件的只读流。pathOrUrl 可为相对路径或完整 URL。 + /// 将库内 key(或历史完整 URL)转为可对外访问的地址。 + /// + string GetPublicUrl(string? keyOrUrl); + + /// + /// 将 key / 相对路径 / 完整 URL 规范为库内应保存的 object key。 + /// 外链(非本存储域名)原样返回,便于兼容微信头像等。 + /// + string? ToStorageKey(string? keyOrUrl); + + /// + /// 打开已保存文件的只读流。pathOrUrl 可为 key、相对路径或完整 URL。 /// Task OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default); diff --git a/src/Oms.Application/Storage/LocalFileStorage.cs b/src/Oms.Application/Storage/LocalFileStorage.cs index 77e9588..96237b2 100644 --- a/src/Oms.Application/Storage/LocalFileStorage.cs +++ b/src/Oms.Application/Storage/LocalFileStorage.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Hosting; namespace Oms.Application.Storage; /// -/// 本地 wwwroot 文件存储。 +/// 本地 wwwroot 文件存储。库内存 key,对外为 /key。 /// public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage { @@ -13,12 +13,29 @@ public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileSto string? contentType = null, CancellationToken cancellationToken = default) { - var key = NormalizeRelativeKey(relativeKey); + var key = FileStoragePath.NormalizeKey(relativeKey); var absolutePath = ToAbsolutePath(key); var directory = Path.GetDirectoryName(absolutePath); if (!string.IsNullOrWhiteSpace(directory)) { - Directory.CreateDirectory(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( @@ -34,7 +51,49 @@ public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileSto } await content.CopyToAsync(file, cancellationToken); - return "/" + key.Replace('\\', '/'); + 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 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) @@ -84,33 +143,14 @@ public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileSto return pathOrUrl; } - var key = NormalizeRelativeKey(pathOrUrl); - return ToAbsolutePath(key); + return ToAbsolutePath(FileStoragePath.NormalizeKey(pathOrUrl)); } - private string ToAbsolutePath(string relativeKey) - { - var webRoot = string.IsNullOrWhiteSpace(environment.WebRootPath) + 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; - 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 index 9a544d5..d5d6bec 100644 --- a/src/Oms.Application/Storage/TencentCosFileStorage.cs +++ b/src/Oms.Application/Storage/TencentCosFileStorage.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Options; namespace Oms.Application.Storage; /// -/// 腾讯云 COS 文件存储。 +/// 腾讯云 COS 文件存储。库内存逻辑 key(不含 KeyPrefix),读写时再拼前缀。 /// public sealed class TencentCosFileStorage : IFileStorage, IDisposable { @@ -16,6 +16,7 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable private readonly CosXmlServer _cosXml; private readonly ILogger _logger; private readonly string _publicBaseUrl; + private readonly string _keyPrefix; public TencentCosFileStorage( IOptions options, @@ -38,6 +39,7 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable _cosXml = new CosXmlServer(config, credential); _publicBaseUrl = ResolvePublicBaseUrl(_options); + _keyPrefix = (_options.KeyPrefix ?? string.Empty).Replace('\\', '/').Trim('/'); } public Task SaveAsync( @@ -48,13 +50,13 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable { cancellationToken.ThrowIfCancellationRequested(); - var objectKey = ToObjectKey(relativeKey); + var logicalKey = FileStoragePath.NormalizeKey(relativeKey); + var objectKey = ToObjectKey(logicalKey); if (content.CanSeek) { content.Position = 0; } - // SDK PutObject(Stream) 需要可定位流;不可定位时先拷到内存。 Stream uploadStream = content; MemoryStream? owned = null; if (!content.CanSeek) @@ -79,9 +81,8 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable throw new BusinessException($"上传 COS 失败: HTTP {result.httpCode}"); } - var url = $"{_publicBaseUrl}/{objectKey}"; - _logger.LogDebug("COS 上传成功: {ObjectKey}", objectKey); - return Task.FromResult(url); + _logger.LogDebug("COS 上传成功: {ObjectKey} (logical={LogicalKey})", objectKey, logicalKey); + return Task.FromResult(logicalKey); } finally { @@ -89,6 +90,49 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable } } + public string GetPublicUrl(string? keyOrUrl) + { + if (string.IsNullOrWhiteSpace(keyOrUrl) || keyOrUrl == "0") + { + return keyOrUrl ?? string.Empty; + } + + if (FileStoragePath.IsAbsoluteUrl(keyOrUrl)) + { + return keyOrUrl; + } + + var objectKey = ToObjectKey(keyOrUrl); + return $"{_publicBaseUrl}/{objectKey}"; + } + + public string? ToStorageKey(string? keyOrUrl) + { + if (string.IsNullOrWhiteSpace(keyOrUrl)) + { + return null; + } + + if (keyOrUrl == "0") + { + return "0"; + } + + if (FileStoragePath.IsAbsoluteUrl(keyOrUrl)) + { + // 本桶 / CDN 域名:落到逻辑 key;其它外链原样保留 + 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(); @@ -140,47 +184,61 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable public void Dispose() { - // CosXmlServer 无显式 Dispose;保留以便未来扩展。 } private string ToObjectKey(string pathOrUrl) { - if (string.IsNullOrWhiteSpace(pathOrUrl)) - { - throw new ArgumentException("路径不能为空", nameof(pathOrUrl)); - } + var key = FileStoragePath.NormalizeKey(pathOrUrl); - var key = pathOrUrl.Trim().Replace('\\', '/'); - if (key.StartsWith("http://", StringComparison.OrdinalIgnoreCase) - || key.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + // 历史完整 URL 已含前缀时不再重复追加 + if (!string.IsNullOrEmpty(_keyPrefix) + && !key.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase) + && !string.Equals(key, _keyPrefix, 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)); + 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; + } + + // 默认 COS 域名形态 + var defaultHost = $"{_options.Bucket}.cos.{_options.Region}.myqcloud.com"; + return string.Equals(uri.Host, defaultHost, StringComparison.OrdinalIgnoreCase); + } + private static string ResolvePublicBaseUrl(TencentCosOptions options) { if (!string.IsNullOrWhiteSpace(options.BaseUrl)) diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 3d25dcc..6cc1886 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -418,13 +418,13 @@ public partial class AuthService( } output.Position = 0; - var avatarUrl = await fileStorage.SaveAsync(output, relativeKey, "image/jpeg", cancellationToken); + var avatarKey = await fileStorage.SaveAsync(output, relativeKey, "image/jpeg", cancellationToken); var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken); - user.UpdateAvatar(avatarUrl); + user.UpdateAvatar(avatarKey); await userRepository.UpdateAsync(user, cancellationToken: cancellationToken); await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken); InvalidateUserInfoCache(userId); - return avatarUrl; + return fileStorage.GetPublicUrl(avatarKey); } private async Task LoginAdminAsync(string account, string password, CancellationToken cancellationToken) @@ -564,7 +564,7 @@ public partial class AuthService( Name = user.Name, Account = user.Account, Email = user.Email, - Avatar = user.Avatar, + Avatar = fileStorage.GetPublicUrl(user.Avatar), OrganizationId = user.OrganizationId, OrganizationName = organizationName, Roles = roles @@ -750,7 +750,7 @@ public partial class AuthService( DisplayName = customer.WechatNickname ?? customer.CustomerName, AccountCode = customer.CustomerCode, Email = customer.Email, - Avatar = customer.WechatAvatar, + Avatar = fileStorage.GetPublicUrl(customer.WechatAvatar), CreateTime = customer.CreateTime, UpdateTime = customer.UpdateTime }; diff --git a/src/SimpleAdmin.Api/Dockerfile b/src/SimpleAdmin.Api/Dockerfile index ae072d7..0d22ae1 100644 --- a/src/SimpleAdmin.Api/Dockerfile +++ b/src/SimpleAdmin.Api/Dockerfile @@ -25,4 +25,8 @@ RUN dotnet publish "SimpleAdmin.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publ FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/publish . +# Local 存储落盘 wwwroot;非 root 用户需可写 uploads(Provider=Cos 时可不依赖此目录) +RUN mkdir -p /app/wwwroot/uploads \ + && chown -R $APP_UID:$APP_UID /app/wwwroot +USER $APP_UID ENTRYPOINT ["dotnet", "SimpleAdmin.Api.dll"]