feat:加强cos log
All checks were successful
Build and Deploy / deploy (push) Successful in 1m41s

This commit is contained in:
xiaolipro 2026-09-10 18:00:18 +08:00
parent 943ba539a4
commit 56c18c0343
19 changed files with 378 additions and 122 deletions

View File

@ -68,7 +68,7 @@ public sealed class LabelPdfGenerationService(
return new LabelPdfGenerateResult return new LabelPdfGenerateResult
{ {
Success = true, Success = true,
LabelUrl = relativeUrl, LabelUrl = fileStorage.GetPublicUrl(relativeUrl),
LabelTemplateId = templateId, LabelTemplateId = templateId,
Status = LabelGenerateStatusCodes.Ready Status = LabelGenerateStatusCodes.Ready
}; };

View File

@ -31,7 +31,7 @@ public sealed class OrderFileManager(
orderId, orderId,
OrderFileTypeCodes.SystemLabel, OrderFileTypeCodes.SystemLabel,
cancellationToken); cancellationToken);
return string.IsNullOrWhiteSpace(file?.FileUrl) ? null : file.FileUrl.Trim(); return string.IsNullOrWhiteSpace(file?.FileUrl) ? null : fileStorage.GetPublicUrl(file.FileUrl.Trim());
} }
public async Task<OrderFileGroupedOutput> GetGroupedAsync( public async Task<OrderFileGroupedOutput> GetGroupedAsync(
@ -94,7 +94,7 @@ public sealed class OrderFileManager(
OrderId = orderId, OrderId = orderId,
FileType = OrderFileTypeCodes.SystemLabel, FileType = OrderFileTypeCodes.SystemLabel,
FileName = fileName, FileName = fileName,
FileUrl = fileUrl.Trim(), FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
ContentType = contentType, ContentType = contentType,
FileSize = fileSize, FileSize = fileSize,
Sort = 0, Sort = 0,
@ -143,7 +143,7 @@ public sealed class OrderFileManager(
OrderId = orderId, OrderId = orderId,
FileType = OrderFileTypeCodes.DownstreamLabel, FileType = OrderFileTypeCodes.DownstreamLabel,
FileName = TryFileNameFromUrl(fileUrl), FileName = TryFileNameFromUrl(fileUrl),
FileUrl = fileUrl.Trim(), FileUrl = fileStorage.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
ContentType = null, ContentType = null,
Sort = existing.Count, Sort = existing.Count,
Source = OrderFileSourceCodes.Downstream, Source = OrderFileSourceCodes.Downstream,
@ -305,18 +305,18 @@ public sealed class OrderFileManager(
var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}"; var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
await using var stream = file.OpenReadStream(); await using var stream = file.OpenReadStream();
var url = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType); var key = await fileStorage.SaveAsync(stream, relativeKey, file.ContentType);
return (url, Path.GetFileName(file.FileName), file.ContentType, file.Length); return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length);
} }
private static OrderFileItemOutput MapItem(OrderFile x) => private OrderFileItemOutput MapItem(OrderFile x) =>
new() new()
{ {
Id = x.Id, Id = x.Id,
OrderId = x.OrderId, OrderId = x.OrderId,
FileType = x.FileType, FileType = x.FileType,
FileName = x.FileName, FileName = x.FileName,
FileUrl = x.FileUrl, FileUrl = fileStorage.GetPublicUrl(x.FileUrl),
ContentType = x.ContentType, ContentType = x.ContentType,
FileSize = x.FileSize, FileSize = x.FileSize,
Sort = x.Sort, Sort = x.Sort,

View File

@ -628,7 +628,8 @@ public partial class OrderService
"""; """;
var relativeKey = $"uploads/prints/orders/{fileName}"; var relativeKey = $"uploads/prints/orders/{fileName}";
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(html)); 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"); private string GetOrderExportDirectory() => Path.Combine(GetWebRootDirectory(), "uploads", "exports", "orders");
@ -638,7 +639,8 @@ public partial class OrderService
var webRoot = string.IsNullOrWhiteSpace(WebHostEnvironment.WebRootPath) var webRoot = string.IsNullOrWhiteSpace(WebHostEnvironment.WebRootPath)
? Path.Combine(WebHostEnvironment.ContentRootPath, "wwwroot") ? Path.Combine(WebHostEnvironment.ContentRootPath, "wwwroot")
: WebHostEnvironment.WebRootPath; : WebHostEnvironment.WebRootPath;
Directory.CreateDirectory(webRoot); // 只确保 uploads 子目录可写,避免在只读 ContentRoot 上强建 wwwroot 根目录。
Directory.CreateDirectory(Path.Combine(webRoot, "uploads"));
return webRoot; return webRoot;
} }

View File

@ -2541,6 +2541,26 @@ public partial class OrderService
{ {
var mapped = (await MapOrderOutputsAsync([order])).First(); var mapped = (await MapOrderOutputsAsync([order])).First();
var systemLabelUrl = await OrderFileManager.GetSystemLabelUrlAsync(order.Id); 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<ILoggerFactory>()
.CreateLogger("Oms.OrderParcelCreate");
logger.LogWarning(
ex,
"预报成功但回落面单落盘失败 OrderId={OrderId} ServiceNo={ServiceNo}",
order.Id,
order.ServiceNo);
}
}
return new OrderParcelCreateOutput return new OrderParcelCreateOutput
{ {
OrderId = order.Id, OrderId = order.Id,
@ -2549,11 +2569,7 @@ public partial class OrderService
TrackNo = order.TrackNo, TrackNo = order.TrackNo,
ConsigneeCountry = order.ConsigneeCountry, ConsigneeCountry = order.ConsigneeCountry,
ConsigneeCountryName = order.ConsigneeCountryName, ConsigneeCountryName = order.ConsigneeCountryName,
LabelUrl = !string.IsNullOrWhiteSpace(systemLabelUrl) LabelUrl = labelUrl
? systemLabelUrl!
: order.OrderStatus == OrderStatusCodes.Predeclared
? await CreateOrderPrintFileAsync(mapped)
: string.Empty
}; };
} }

View File

@ -75,7 +75,7 @@ public partial class OrderService
SkuId = g.Key, SkuId = g.Key,
SkuCode = sku?.SkuCode ?? string.Empty, SkuCode = sku?.SkuCode ?? string.Empty,
SkuName = sku?.SkuName ?? string.Empty, SkuName = sku?.SkuName ?? string.Empty,
SkuImageUrl = sku?.SkuImageUrl, SkuImageUrl = FileStorage.GetPublicUrl(sku?.SkuImageUrl),
WarehouseId = warehouseId, WarehouseId = warehouseId,
TotalQty = g.Sum(x => x.Qty), TotalQty = g.Sum(x => x.Qty),
FrozenQty = g.Sum(x => x.FrozenQty), FrozenQty = g.Sum(x => x.FrozenQty),
@ -150,7 +150,7 @@ public partial class OrderService
ParcelItemId = x.Id, ParcelItemId = x.Id,
ProductName = x.ProductName, ProductName = x.ProductName,
AttributeText = x.AttributeText, AttributeText = x.AttributeText,
ImageUrl = x.ImageUrl, ImageUrl = FileStorage.GetPublicUrl(x.ImageUrl),
SkuId = x.SkuId, SkuId = x.SkuId,
SkuCode = x.SkuCode, SkuCode = x.SkuCode,
Quantity = x.Quantity, Quantity = x.Quantity,
@ -794,7 +794,7 @@ public partial class OrderService
TrackingNo = bind.TrackingNo, TrackingNo = bind.TrackingNo,
ProductName = bind.ProductName, ProductName = bind.ProductName,
AttributeText = bind.AttributeText, AttributeText = bind.AttributeText,
ImageUrl = bind.ImageUrl, ImageUrl = FileStorage.GetPublicUrl(bind.ImageUrl),
BindQty = bind.BindQty, BindQty = bind.BindQty,
AvailableQty = availableQty, AvailableQty = availableQty,
Status = bind.Status Status = bind.Status

View File

@ -345,13 +345,13 @@ public partial class OrderService : ApplicationService
await using var stream = new MemoryStream(); await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders)); await MiniExcel.SaveAsAsync(stream, await BuildOrderExportRowsAsync(orders));
stream.Position = 0; stream.Position = 0;
var downloadUrl = await FileStorage.SaveAsync( var storageKey = await FileStorage.SaveAsync(
stream, stream,
relativeKey, relativeKey,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
return new OrderExportOutput return new OrderExportOutput
{ {
DownloadUrl = downloadUrl, DownloadUrl = FileStorage.GetPublicUrl(storageKey),
ExpireAt = DateTime.Now.AddHours(24) ExpireAt = DateTime.Now.AddHours(24)
}; };
} }

View File

@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Rbac; using Oms.Application.Dtos.Rbac;
using Oms.Application.Rbac; using Oms.Application.Rbac;
using Oms.Application.Storage;
using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Enums; using Oms.Domain.SystemAggregate.Enums;
using Oms.Domain.SystemAggregate.Repositories; using Oms.Domain.SystemAggregate.Repositories;
@ -22,7 +23,8 @@ namespace Oms.Application.Services.Rbac;
public class OmsUserService( public class OmsUserService(
UserService userService, UserService userService,
UserProfileRepository profileRepository, UserProfileRepository profileRepository,
IEmployeeRepository employeeRepository) : ApplicationService IEmployeeRepository employeeRepository,
IFileStorage fileStorage) : ApplicationService
{ {
/// <summary> /// <summary>
/// 获取用户详情(含扩展资料)。 /// 获取用户详情(含扩展资料)。
@ -32,7 +34,7 @@ public class OmsUserService(
{ {
var user = await userService.GetAsync(id, cancellationToken); var user = await userService.GetAsync(id, cancellationToken);
var profile = await profileRepository.GetAsync(id, cancellationToken); var profile = await profileRepository.GetAsync(id, cancellationToken);
return OmsUserProfileMapper.ToDetailDto(user, profile); return MapUserDetail(user, profile);
} }
/// <summary> /// <summary>
@ -66,7 +68,7 @@ public class OmsUserService(
.Select(user => .Select(user =>
{ {
profileMap.TryGetValue(user.Id, out var profile); profileMap.TryGetValue(user.Id, out var profile);
return OmsUserProfileMapper.ToDetailDto(user, profile); return MapUserDetail(user, profile);
}) })
.ToList(), .ToList(),
}; };
@ -96,7 +98,7 @@ public class OmsUserService(
Account = input.Account, Account = input.Account,
Password = input.Password, Password = input.Password,
Email = input.Email, Email = input.Email,
Avatar = input.Avatar, Avatar = fileStorage.ToStorageKey(input.Avatar),
OrganizationId = input.OrganizationId, OrganizationId = input.OrganizationId,
RoleIds = input.RoleIds, RoleIds = input.RoleIds,
}, cancellationToken); }, cancellationToken);
@ -116,7 +118,7 @@ public class OmsUserService(
await EnsureEmployeeProfileAsync(user.Id, input, cancellationToken); await EnsureEmployeeProfileAsync(user.Id, input, cancellationToken);
var profile = await profileRepository.GetAsync(user.Id, cancellationToken); var profile = await profileRepository.GetAsync(user.Id, cancellationToken);
return OmsUserProfileMapper.ToDetailDto(user, profile); return MapUserDetail(user, profile);
} }
/// <summary> /// <summary>
@ -134,7 +136,7 @@ public class OmsUserService(
{ {
Name = input.Name, Name = input.Name,
Email = input.Email, Email = input.Email,
Avatar = input.Avatar, Avatar = fileStorage.ToStorageKey(input.Avatar),
OrganizationId = input.OrganizationId, OrganizationId = input.OrganizationId,
}, },
cancellationToken); cancellationToken);
@ -151,7 +153,7 @@ public class OmsUserService(
cancellationToken); cancellationToken);
var profile = await profileRepository.GetAsync(id, cancellationToken); var profile = await profileRepository.GetAsync(id, cancellationToken);
return OmsUserProfileMapper.ToDetailDto(user, profile); return MapUserDetail(user, profile);
} }
/// <summary> /// <summary>
@ -172,7 +174,7 @@ public class OmsUserService(
[FromQuery] Guid id, [FromQuery] Guid id,
[FromQuery] string avatarUrl, [FromQuery] string avatarUrl,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
userService.UpdateAvatarAsync(id, avatarUrl, cancellationToken); userService.UpdateAvatarAsync(id, fileStorage.ToStorageKey(avatarUrl) ?? avatarUrl, cancellationToken);
/// <summary> /// <summary>
/// 删除用户。 /// 删除用户。
@ -259,4 +261,11 @@ public class OmsUserService(
}, },
cancellationToken); cancellationToken);
} }
private OmsUserDetailDto MapUserDetail(UserDto user, UserProfileData? profile)
{
var detail = OmsUserProfileMapper.ToDetailDto(user, profile);
detail.Avatar = fileStorage.GetPublicUrl(detail.Avatar);
return detail;
}
} }

View File

@ -60,7 +60,9 @@ public class RechargeService : ApplicationService
}; };
var savedEntity = await RechargeRecordRepository.InsertAsync(entity); var savedEntity = await RechargeRecordRepository.InsertAsync(entity);
return ObjectMapper.Map<RechargeRecord, RechargeRecordOutput>(savedEntity); var output = ObjectMapper.Map<RechargeRecord, RechargeRecordOutput>(savedEntity);
output.RechargeScreenshot = FileStorage.GetPublicUrl(savedEntity.RechargeScreenshot);
return output;
} }
/// <summary> /// <summary>
@ -159,6 +161,7 @@ public class RechargeService : ApplicationService
var output = ObjectMapper.Map<RechargeRecord, RechargeRecordPaginatedOutput>(entity); var output = ObjectMapper.Map<RechargeRecord, RechargeRecordPaginatedOutput>(entity);
output.PayTypeName = GetPayTypeName(entity.PayType); output.PayTypeName = GetPayTypeName(entity.PayType);
output.RechargeStatusName = GetRechargeStatusName(entity.RechargeStatus); output.RechargeStatusName = GetRechargeStatusName(entity.RechargeStatus);
output.RechargeScreenshot = FileStorage.GetPublicUrl(entity.RechargeScreenshot);
if (customerMap.TryGetValue(entity.CustomerId, out var customer)) if (customerMap.TryGetValue(entity.CustomerId, out var customer))
{ {
output.CustomerCode = customer.CustomerCode; output.CustomerCode = customer.CustomerCode;
@ -205,7 +208,7 @@ public class RechargeService : ApplicationService
record.PayType, record.PayType,
PayTypeName = GetPayTypeName(record.PayType), PayTypeName = GetPayTypeName(record.PayType),
record.RechargeAmount, record.RechargeAmount,
record.RechargeScreenshot, RechargeScreenshot = FileStorage.GetPublicUrl(record.RechargeScreenshot),
record.RechargeStatus, record.RechargeStatus,
RechargeStatusName = GetRechargeStatusName(record.RechargeStatus), RechargeStatusName = GetRechargeStatusName(record.RechargeStatus),
record.Remark, record.Remark,

View File

@ -178,7 +178,7 @@ public partial class WarehouseService
throw new BusinessException("商品数量必须大于 0"); 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) if (imageUrl != null && imageUrl.Length > 1024)
{ {
imageUrl = imageUrl[..1024]; imageUrl = imageUrl[..1024];
@ -212,8 +212,9 @@ public partial class WarehouseService
public async Task<object> UploadParcelImageAsync(IFormFile file) public async Task<object> UploadParcelImageAsync(IFormFile file)
{ {
await ResolveMutationCustomerIdAsync(null); await ResolveMutationCustomerIdAsync(null);
var path = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, FileStorage); var key = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, FileStorage);
return new { url = path, fileUrl = path }; var url = FileStorage.GetPublicUrl(key);
return new { key, url, fileUrl = key };
} }
/// <summary> /// <summary>
@ -309,7 +310,7 @@ public partial class WarehouseService
} }
} }
private static WarehouseParcelOutput MapParcelOutput(CParcel parcel, IEnumerable<CParcelItem> items) private WarehouseParcelOutput MapParcelOutput(CParcel parcel, IEnumerable<CParcelItem> items)
{ {
var itemList = items.ToList(); var itemList = items.ToList();
var mappedItems = itemList.Select(item => new WarehouseParcelItemOutput var mappedItems = itemList.Select(item => new WarehouseParcelItemOutput
@ -317,7 +318,7 @@ public partial class WarehouseService
Id = item.Id, Id = item.Id,
ProductName = item.ProductName, ProductName = item.ProductName,
AttributeText = item.AttributeText, AttributeText = item.AttributeText,
ImageUrl = item.ImageUrl, ImageUrl = FileStorage.GetPublicUrl(item.ImageUrl),
Quantity = item.Quantity, Quantity = item.Quantity,
FrozenQty = item.FrozenQty, FrozenQty = item.FrozenQty,
AvailableQty = item.AvailableQty > 0 ? item.AvailableQty : Math.Max(0, item.Quantity - item.FrozenQty), AvailableQty = item.AvailableQty > 0 ? item.AvailableQty : Math.Max(0, item.Quantity - item.FrozenQty),

View File

@ -117,7 +117,7 @@ public partial class WarehouseService
entity.SkuCode = skuCode; entity.SkuCode = skuCode;
entity.SkuName = skuName; entity.SkuName = skuName;
entity.SkuNameEn = string.IsNullOrWhiteSpace(input.SkuNameEn) ? null : input.SkuNameEn.Trim(); 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.UnitPrice = input.UnitPrice;
entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim(); entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim();
entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim(); entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim();
@ -166,8 +166,9 @@ public partial class WarehouseService
public async Task<object> UploadSkuImageAsync(IFormFile file) public async Task<object> UploadSkuImageAsync(IFormFile file)
{ {
await ResolveMutationCustomerIdAsync(null); await ResolveMutationCustomerIdAsync(null);
var path = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, FileStorage); var key = await OmsFileUploadHelper.SaveWarehouseSkuImageAsync(file, FileStorage);
return new { url = path, fileUrl = path }; var url = FileStorage.GetPublicUrl(key);
return new { key, url, fileUrl = key };
} }
/// <summary> /// <summary>
@ -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() new()
{ {
Id = entity.Id, Id = entity.Id,
@ -225,7 +226,7 @@ public partial class WarehouseService
SkuCode = entity.SkuCode, SkuCode = entity.SkuCode,
SkuName = entity.SkuName, SkuName = entity.SkuName,
SkuNameEn = entity.SkuNameEn, SkuNameEn = entity.SkuNameEn,
SkuImageUrl = entity.SkuImageUrl, SkuImageUrl = FileStorage.GetPublicUrl(entity.SkuImageUrl),
UnitPrice = entity.UnitPrice, UnitPrice = entity.UnitPrice,
ExHsCode = entity.ExHsCode, ExHsCode = entity.ExHsCode,
ImHsCode = entity.ImHsCode, ImHsCode = entity.ImHsCode,
@ -238,7 +239,7 @@ public partial class WarehouseService
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) 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() new()
{ {
Id = entity.Id, Id = entity.Id,
@ -246,7 +247,7 @@ public partial class WarehouseService
SkuCode = entity.SkuCode, SkuCode = entity.SkuCode,
SkuName = entity.SkuName, SkuName = entity.SkuName,
SkuNameEn = entity.SkuNameEn, SkuNameEn = entity.SkuNameEn,
SkuImageUrl = entity.SkuImageUrl, SkuImageUrl = FileStorage.GetPublicUrl(entity.SkuImageUrl),
UnitPrice = entity.UnitPrice, UnitPrice = entity.UnitPrice,
ExHsCode = entity.ExHsCode, ExHsCode = entity.ExHsCode,
ImHsCode = entity.ImHsCode, ImHsCode = entity.ImHsCode,

View File

@ -34,13 +34,14 @@ public class XhsMediaService : ApplicationService
var relativeKey = $"uploads/xhs/{customerId}/{storedName}"; var relativeKey = $"uploads/xhs/{customerId}/{storedName}";
await using var stream = file.OpenReadStream(); 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 var media = new XhsMedia
{ {
CustomerId = customerId, CustomerId = customerId,
FileName = file.FileName, FileName = file.FileName,
StoragePath = relativeKey, StoragePath = storageKey,
PublicUrl = publicUrl, PublicUrl = publicUrl,
MimeType = file.ContentType, MimeType = file.ContentType,
FileSizeBytes = file.Length FileSizeBytes = file.Length

View File

@ -6,6 +6,7 @@ namespace Oms.Application.Shared;
/// <summary> /// <summary>
/// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。 /// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。
/// 返回值为 object key,对外展示请再调用 <see cref="IFileStorage.GetPublicUrl"/>。
/// </summary> /// </summary>
public static class OmsFileUploadHelper public static class OmsFileUploadHelper
{ {
@ -78,7 +79,7 @@ public static class OmsFileUploadHelper
private const long MaxOrderTemplateBytes = 10 * 1024 * 1024; private const long MaxOrderTemplateBytes = 10 * 1024 * 1024;
/// <summary> /// <summary>
/// 保存下单自定义模板样例文件。 /// 保存下单自定义模板样例文件,返回 (objectKey, 原始文件名)。
/// </summary> /// </summary>
public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync( public static async Task<(string RelativePath, string FileName)> SaveOrderTemplateSampleAsync(
IFormFile file, IFormFile file,
@ -105,11 +106,11 @@ public static class OmsFileUploadHelper
var relativeKey = $"uploads/order-templates/{customerId}/{fileName}"; var relativeKey = $"uploads/order-templates/{customerId}/{fileName}";
await using var stream = file.OpenReadStream(); await using var stream = file.OpenReadStream();
var url = await storage.SaveAsync( var key = await storage.SaveAsync(
stream, stream,
relativeKey, relativeKey,
file.ContentType ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); 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 private static string? GuessExtensionFromContentType(string? contentType) => contentType?.ToLowerInvariant() switch

View File

@ -0,0 +1,41 @@
namespace Oms.Application.Storage;
/// <summary>
/// 文件存储 key / URL 规范化。
/// </summary>
public static class FileStoragePath
{
/// <summary>
/// 规范化为库内 object key:uploads/a.jpg(无前导 /,完整 URL 取 AbsolutePath)。
/// </summary>
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;
}
/// <summary>
/// 是否已是绝对 URL(含微信头像等外链)。
/// </summary>
public static bool IsAbsoluteUrl(string? value) =>
!string.IsNullOrWhiteSpace(value)
&& (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| value.StartsWith("https://", StringComparison.OrdinalIgnoreCase));
}

View File

@ -1,5 +1,8 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Oms.Application.Storage; namespace Oms.Application.Storage;
@ -7,10 +10,11 @@ public static class FileStorageServiceExtensions
{ {
public static IServiceCollection AddOmsFileStorage(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddOmsFileStorage(this IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<FileStorageOptions>(configuration.GetSection(FileStorageOptions.SectionName)); var section = configuration.GetSection(FileStorageOptions.SectionName);
services.Configure<FileStorageOptions>(section);
var provider = configuration.GetSection(FileStorageOptions.SectionName)["Provider"] var options = section.Get<FileStorageOptions>() ?? new FileStorageOptions();
?? "Local"; var provider = ResolveProvider(options);
if (string.Equals(provider, "Cos", StringComparison.OrdinalIgnoreCase)) if (string.Equals(provider, "Cos", StringComparison.OrdinalIgnoreCase))
{ {
@ -21,6 +25,69 @@ public static class FileStorageServiceExtensions
services.AddSingleton<IFileStorage, LocalFileStorage>(); services.AddSingleton<IFileStorage, LocalFileStorage>();
} }
services.AddHostedService<FileStorageStartupLogger>();
return services; return services;
} }
/// <summary>
/// 显式 Provider 优先;未写 Provider 但 Cos 凭证齐全时自动走 Cos,避免生产误落 Local。
/// </summary>
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);
}
/// <summary>
/// 启动时打印实际 IFileStorage 实现,便于核对 Consul 是否生效。
/// </summary>
internal sealed class FileStorageStartupLogger(
IFileStorage storage,
IOptions<FileStorageOptions> options,
ILogger<FileStorageStartupLogger> 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;
} }

View File

@ -2,6 +2,7 @@ namespace Oms.Application.Storage;
/// <summary> /// <summary>
/// 统一文件存储(本地 / 腾讯云 COS)。 /// 统一文件存储(本地 / 腾讯云 COS)。
/// 约定:库内只存 object key(如 uploads/avatars/a.jpg),对外访问地址用 <see cref="GetPublicUrl"/> 拼接。
/// </summary> /// </summary>
public interface IFileStorage public interface IFileStorage
{ {
@ -12,7 +13,7 @@ public interface IFileStorage
/// <param name="relativeKey">相对键,如 uploads/avatars/a.jpg(不要前导 /)。</param> /// <param name="relativeKey">相对键,如 uploads/avatars/a.jpg(不要前导 /)。</param>
/// <param name="contentType">MIME,可选。</param> /// <param name="contentType">MIME,可选。</param>
/// <param name="cancellationToken">取消令牌。</param> /// <param name="cancellationToken">取消令牌。</param>
/// <returns>可对外访问的 URL(本地为 /uploads/...,COS 为完整 URL)。</returns> /// <returns>存储 object key(不含域名;不含 KeyPrefix)。</returns>
Task<string> SaveAsync( Task<string> SaveAsync(
Stream content, Stream content,
string relativeKey, string relativeKey,
@ -20,7 +21,18 @@ public interface IFileStorage
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 打开已保存文件的只读流。pathOrUrl 可为相对路径或完整 URL。 /// 将库内 key(或历史完整 URL)转为可对外访问的地址。
/// </summary>
string GetPublicUrl(string? keyOrUrl);
/// <summary>
/// 将 key / 相对路径 / 完整 URL 规范为库内应保存的 object key。
/// 外链(非本存储域名)原样返回,便于兼容微信头像等。
/// </summary>
string? ToStorageKey(string? keyOrUrl);
/// <summary>
/// 打开已保存文件的只读流。pathOrUrl 可为 key、相对路径或完整 URL。
/// </summary> /// </summary>
Task<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default); Task<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default);

View File

@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Hosting;
namespace Oms.Application.Storage; namespace Oms.Application.Storage;
/// <summary> /// <summary>
/// 本地 wwwroot 文件存储。 /// 本地 wwwroot 文件存储。库内存 key,对外为 /key。
/// </summary> /// </summary>
public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileStorage
{ {
@ -13,13 +13,30 @@ public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileSto
string? contentType = null, string? contentType = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var key = NormalizeRelativeKey(relativeKey); var key = FileStoragePath.NormalizeKey(relativeKey);
var absolutePath = ToAbsolutePath(key); var absolutePath = ToAbsolutePath(key);
var directory = Path.GetDirectoryName(absolutePath); var directory = Path.GetDirectoryName(absolutePath);
if (!string.IsNullOrWhiteSpace(directory)) if (!string.IsNullOrWhiteSpace(directory))
{
try
{ {
Directory.CreateDirectory(directory); 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( await using var file = new FileStream(
absolutePath, absolutePath,
@ -34,7 +51,49 @@ public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileSto
} }
await content.CopyToAsync(file, cancellationToken); 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<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default) public Task<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default)
@ -84,33 +143,14 @@ public sealed class LocalFileStorage(IWebHostEnvironment environment) : IFileSto
return pathOrUrl; return pathOrUrl;
} }
var key = NormalizeRelativeKey(pathOrUrl); return ToAbsolutePath(FileStoragePath.NormalizeKey(pathOrUrl));
return ToAbsolutePath(key);
} }
private string ToAbsolutePath(string relativeKey) private string ToAbsolutePath(string relativeKey) =>
{ Path.Combine(GetWebRoot(), relativeKey.Replace('/', Path.DirectorySeparatorChar));
var webRoot = string.IsNullOrWhiteSpace(environment.WebRootPath)
private string GetWebRoot() =>
string.IsNullOrWhiteSpace(environment.WebRootPath)
? Path.Combine(environment.ContentRootPath, "wwwroot") ? Path.Combine(environment.ContentRootPath, "wwwroot")
: environment.WebRootPath; : 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;
}
} }

View File

@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
namespace Oms.Application.Storage; namespace Oms.Application.Storage;
/// <summary> /// <summary>
/// 腾讯云 COS 文件存储。 /// 腾讯云 COS 文件存储。库内存逻辑 key(不含 KeyPrefix),读写时再拼前缀。
/// </summary> /// </summary>
public sealed class TencentCosFileStorage : IFileStorage, IDisposable public sealed class TencentCosFileStorage : IFileStorage, IDisposable
{ {
@ -16,6 +16,7 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
private readonly CosXmlServer _cosXml; private readonly CosXmlServer _cosXml;
private readonly ILogger<TencentCosFileStorage> _logger; private readonly ILogger<TencentCosFileStorage> _logger;
private readonly string _publicBaseUrl; private readonly string _publicBaseUrl;
private readonly string _keyPrefix;
public TencentCosFileStorage( public TencentCosFileStorage(
IOptions<FileStorageOptions> options, IOptions<FileStorageOptions> options,
@ -38,6 +39,7 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
_cosXml = new CosXmlServer(config, credential); _cosXml = new CosXmlServer(config, credential);
_publicBaseUrl = ResolvePublicBaseUrl(_options); _publicBaseUrl = ResolvePublicBaseUrl(_options);
_keyPrefix = (_options.KeyPrefix ?? string.Empty).Replace('\\', '/').Trim('/');
} }
public Task<string> SaveAsync( public Task<string> SaveAsync(
@ -48,13 +50,13 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var objectKey = ToObjectKey(relativeKey); var logicalKey = FileStoragePath.NormalizeKey(relativeKey);
var objectKey = ToObjectKey(logicalKey);
if (content.CanSeek) if (content.CanSeek)
{ {
content.Position = 0; content.Position = 0;
} }
// SDK PutObject(Stream) 需要可定位流;不可定位时先拷到内存。
Stream uploadStream = content; Stream uploadStream = content;
MemoryStream? owned = null; MemoryStream? owned = null;
if (!content.CanSeek) if (!content.CanSeek)
@ -79,9 +81,8 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
throw new BusinessException($"上传 COS 失败: HTTP {result.httpCode}"); throw new BusinessException($"上传 COS 失败: HTTP {result.httpCode}");
} }
var url = $"{_publicBaseUrl}/{objectKey}"; _logger.LogDebug("COS 上传成功: {ObjectKey} (logical={LogicalKey})", objectKey, logicalKey);
_logger.LogDebug("COS 上传成功: {ObjectKey}", objectKey); return Task.FromResult(logicalKey);
return Task.FromResult(url);
} }
finally 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<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default) public Task<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@ -140,47 +184,61 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
public void Dispose() public void Dispose()
{ {
// CosXmlServer 无显式 Dispose;保留以便未来扩展。
} }
private string ToObjectKey(string pathOrUrl) private string ToObjectKey(string pathOrUrl)
{ {
if (string.IsNullOrWhiteSpace(pathOrUrl)) var key = FileStoragePath.NormalizeKey(pathOrUrl);
{
throw new ArgumentException("路径不能为空", nameof(pathOrUrl));
}
var key = pathOrUrl.Trim().Replace('\\', '/'); // 历史完整 URL 已含前缀时不再重复追加
if (key.StartsWith("http://", StringComparison.OrdinalIgnoreCase) if (!string.IsNullOrEmpty(_keyPrefix)
|| key.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) && !key.StartsWith(_keyPrefix + "/", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(key, _keyPrefix, StringComparison.OrdinalIgnoreCase))
{ {
var uri = new Uri(key); key = $"{_keyPrefix}/{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; 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) private static string ResolvePublicBaseUrl(TencentCosOptions options)
{ {
if (!string.IsNullOrWhiteSpace(options.BaseUrl)) if (!string.IsNullOrWhiteSpace(options.BaseUrl))

View File

@ -418,13 +418,13 @@ public partial class AuthService(
} }
output.Position = 0; 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); var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken);
user.UpdateAvatar(avatarUrl); user.UpdateAvatar(avatarKey);
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken); await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken); await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken);
InvalidateUserInfoCache(userId); InvalidateUserInfoCache(userId);
return avatarUrl; return fileStorage.GetPublicUrl(avatarKey);
} }
private async Task<OmsLoginResultDto> LoginAdminAsync(string account, string password, CancellationToken cancellationToken) private async Task<OmsLoginResultDto> LoginAdminAsync(string account, string password, CancellationToken cancellationToken)
@ -564,7 +564,7 @@ public partial class AuthService(
Name = user.Name, Name = user.Name,
Account = user.Account, Account = user.Account,
Email = user.Email, Email = user.Email,
Avatar = user.Avatar, Avatar = fileStorage.GetPublicUrl(user.Avatar),
OrganizationId = user.OrganizationId, OrganizationId = user.OrganizationId,
OrganizationName = organizationName, OrganizationName = organizationName,
Roles = roles Roles = roles
@ -750,7 +750,7 @@ public partial class AuthService(
DisplayName = customer.WechatNickname ?? customer.CustomerName, DisplayName = customer.WechatNickname ?? customer.CustomerName,
AccountCode = customer.CustomerCode, AccountCode = customer.CustomerCode,
Email = customer.Email, Email = customer.Email,
Avatar = customer.WechatAvatar, Avatar = fileStorage.GetPublicUrl(customer.WechatAvatar),
CreateTime = customer.CreateTime, CreateTime = customer.CreateTime,
UpdateTime = customer.UpdateTime UpdateTime = customer.UpdateTime
}; };

View File

@ -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 FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app WORKDIR /app
COPY --from=build /app/publish . 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"] ENTRYPOINT ["dotnet", "SimpleAdmin.Api.dll"]