This commit is contained in:
parent
943ba539a4
commit
56c18c0343
@ -68,7 +68,7 @@ public sealed class LabelPdfGenerationService(
|
||||
return new LabelPdfGenerateResult
|
||||
{
|
||||
Success = true,
|
||||
LabelUrl = relativeUrl,
|
||||
LabelUrl = fileStorage.GetPublicUrl(relativeUrl),
|
||||
LabelTemplateId = templateId,
|
||||
Status = LabelGenerateStatusCodes.Ready
|
||||
};
|
||||
|
||||
@ -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<OrderFileGroupedOutput> 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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<ILoggerFactory>()
|
||||
.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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取用户详情(含扩展资料)。
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 删除用户。
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,7 +60,9 @@ public class RechargeService : ApplicationService
|
||||
};
|
||||
|
||||
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>
|
||||
@ -159,6 +161,7 @@ public class RechargeService : ApplicationService
|
||||
var output = ObjectMapper.Map<RechargeRecord, RechargeRecordPaginatedOutput>(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,
|
||||
|
||||
@ -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<object> 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 };
|
||||
}
|
||||
|
||||
/// <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 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),
|
||||
|
||||
@ -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<object> 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 };
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -6,6 +6,7 @@ namespace Oms.Application.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// OMS 上传文件辅助(经 IFileStorage,支持本地 / 腾讯云 COS)。
|
||||
/// 返回值为 object key,对外展示请再调用 <see cref="IFileStorage.GetPublicUrl"/>。
|
||||
/// </summary>
|
||||
public static class OmsFileUploadHelper
|
||||
{
|
||||
@ -78,7 +79,7 @@ public static class OmsFileUploadHelper
|
||||
private const long MaxOrderTemplateBytes = 10 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// 保存下单自定义模板样例文件。
|
||||
/// 保存下单自定义模板样例文件,返回 (objectKey, 原始文件名)。
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
41
src/Oms.Application/Storage/FileStoragePath.cs
Normal file
41
src/Oms.Application/Storage/FileStoragePath.cs
Normal 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));
|
||||
}
|
||||
@ -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<FileStorageOptions>(configuration.GetSection(FileStorageOptions.SectionName));
|
||||
var section = configuration.GetSection(FileStorageOptions.SectionName);
|
||||
services.Configure<FileStorageOptions>(section);
|
||||
|
||||
var provider = configuration.GetSection(FileStorageOptions.SectionName)["Provider"]
|
||||
?? "Local";
|
||||
var options = section.Get<FileStorageOptions>() ?? new FileStorageOptions();
|
||||
var provider = ResolveProvider(options);
|
||||
|
||||
if (string.Equals(provider, "Cos", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@ -21,6 +25,69 @@ public static class FileStorageServiceExtensions
|
||||
services.AddSingleton<IFileStorage, LocalFileStorage>();
|
||||
}
|
||||
|
||||
services.AddHostedService<FileStorageStartupLogger>();
|
||||
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;
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ namespace Oms.Application.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 统一文件存储(本地 / 腾讯云 COS)。
|
||||
/// 约定:库内只存 object key(如 uploads/avatars/a.jpg),对外访问地址用 <see cref="GetPublicUrl"/> 拼接。
|
||||
/// </summary>
|
||||
public interface IFileStorage
|
||||
{
|
||||
@ -12,7 +13,7 @@ public interface IFileStorage
|
||||
/// <param name="relativeKey">相对键,如 uploads/avatars/a.jpg(不要前导 /)。</param>
|
||||
/// <param name="contentType">MIME,可选。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>可对外访问的 URL(本地为 /uploads/...,COS 为完整 URL)。</returns>
|
||||
/// <returns>存储 object key(不含域名;不含 KeyPrefix)。</returns>
|
||||
Task<string> SaveAsync(
|
||||
Stream content,
|
||||
string relativeKey,
|
||||
@ -20,7 +21,18 @@ public interface IFileStorage
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <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>
|
||||
Task<Stream> OpenReadAsync(string pathOrUrl, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Hosting;
|
||||
namespace Oms.Application.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 本地 wwwroot 文件存储。
|
||||
/// 本地 wwwroot 文件存储。库内存 key,对外为 /key。
|
||||
/// </summary>
|
||||
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<Stream> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
|
||||
namespace Oms.Application.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云 COS 文件存储。
|
||||
/// 腾讯云 COS 文件存储。库内存逻辑 key(不含 KeyPrefix),读写时再拼前缀。
|
||||
/// </summary>
|
||||
public sealed class TencentCosFileStorage : IFileStorage, IDisposable
|
||||
{
|
||||
@ -16,6 +16,7 @@ public sealed class TencentCosFileStorage : IFileStorage, IDisposable
|
||||
private readonly CosXmlServer _cosXml;
|
||||
private readonly ILogger<TencentCosFileStorage> _logger;
|
||||
private readonly string _publicBaseUrl;
|
||||
private readonly string _keyPrefix;
|
||||
|
||||
public TencentCosFileStorage(
|
||||
IOptions<FileStorageOptions> 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<string> 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<Stream> 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))
|
||||
|
||||
@ -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<OmsLoginResultDto> 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
|
||||
};
|
||||
|
||||
@ -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"]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user