This commit is contained in:
parent
943ba539a4
commit
56c18c0343
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user