using Fake.Application;
using Fake.DependencyInjection;
using Fake.ObjectStorage;
using Microsoft.AspNetCore.Http;
using Oms.Application.Dtos.Orders;
using Oms.Application.Services.Storage;
using Oms.Application.Storage;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Services.Orders;
///
/// 订单附件读写(系统面单仅 1 份,其它类型最多 20)。
///
public sealed class OrderFileManager(
IOrderFileRepository orderFileRepository,
IObjectStorage fileStorage,
IHttpClientFactory httpClientFactory) : IScopedDependency
{
private static readonly HashSet ImageExt =
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
private static readonly HashSet LabelExt =
new(StringComparer.OrdinalIgnoreCase) { ".pdf", ".jpg", ".jpeg", ".png" };
private const long MaxImageBytes = 5 * 1024 * 1024;
private const long MaxPdfBytes = 10 * 1024 * 1024;
public async Task GetSystemLabelUrlAsync(int orderId, CancellationToken cancellationToken = default)
{
var file = await orderFileRepository.GetByOrderAndTypeAsync(
orderId,
OrderFileTypeCodes.SystemLabel,
cancellationToken);
return string.IsNullOrWhiteSpace(file?.FileUrl) ? null : fileStorage.GetPublicUrl(file.FileUrl.Trim());
}
///
/// 批量取系统面单公开地址(已存在才返回)。
///
public async Task> GetSystemLabelUrlsAsync(
IReadOnlyCollection orderIds,
CancellationToken cancellationToken = default)
{
var result = new Dictionary();
var ids = orderIds?.Where(x => x > 0).Distinct().ToList() ?? [];
if (ids.Count == 0)
{
return result;
}
var files = await orderFileRepository.GetByOrderIdsAndTypeAsync(
ids,
OrderFileTypeCodes.SystemLabel,
cancellationToken);
foreach (var file in files)
{
if (string.IsNullOrWhiteSpace(file.FileUrl) || result.ContainsKey(file.OrderId))
{
continue;
}
result[file.OrderId] = fileStorage.GetPublicUrl(file.FileUrl.Trim());
}
return result;
}
public async Task> GetByTypeAsync(
int orderId,
string fileType,
CancellationToken cancellationToken = default)
{
if (!OrderFileTypeCodes.IsValid(fileType))
{
throw new BusinessException("不支持的附件类型");
}
var type = OrderFileTypeCodes.Normalize(fileType);
var list = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
return list
.OrderBy(x => x.Sort)
.ThenBy(x => x.Id)
.Select(MapItem)
.ToList();
}
public async Task GetGroupedAsync(
int orderId,
bool customerView,
CancellationToken cancellationToken = default)
{
var list = await orderFileRepository.GetByOrderIdAsync(orderId, cancellationToken);
if (customerView)
{
list = list
.Where(x => string.Equals(x.FileType, OrderFileTypeCodes.SystemLabel, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var groups = list
.OrderBy(x => x.Sort)
.ThenBy(x => x.Id)
.GroupBy(x => x.FileType, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(MapItem).ToList(),
StringComparer.OrdinalIgnoreCase);
// Ensure known keys present for UI
foreach (var type in OrderFileTypeCodes.All)
{
if (customerView && !string.Equals(type, OrderFileTypeCodes.SystemLabel, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!groups.ContainsKey(type))
{
groups[type] = [];
}
}
return new OrderFileGroupedOutput { OrderId = orderId, Groups = groups };
}
public async Task ReplaceSystemLabelAsync(
int orderId,
string fileUrl,
string? fileName,
string? contentType,
long? fileSize,
string source,
int operatorUserId,
string? remark = null,
CancellationToken cancellationToken = default)
{
await orderFileRepository.SoftDeleteByOrderAndTypeAsync(
orderId,
OrderFileTypeCodes.SystemLabel,
cancellationToken);
var entity = new OrderFile
{
OrderId = orderId,
FileType = OrderFileTypeCodes.SystemLabel,
FileName = fileName,
FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
ContentType = contentType,
FileSize = fileSize,
Sort = 0,
Source = source,
Remark = remark,
CreateUserId = operatorUserId,
CreateTime = DateTime.Now
};
var saved = await orderFileRepository.InsertAsync(entity, cancellationToken);
return MapItem(saved);
}
public async Task AddOrReplaceDownstreamLabelAsync(
int orderId,
string fileUrl,
int operatorUserId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(fileUrl))
{
throw new BusinessException("下游面单地址为空");
}
var existing = await orderFileRepository.GetByOrderAndTypeListAsync(
orderId,
OrderFileTypeCodes.DownstreamLabel,
cancellationToken);
var dup = existing.FirstOrDefault(x =>
string.Equals(x.FileUrl, fileUrl.Trim(), StringComparison.OrdinalIgnoreCase));
if (dup != null)
{
return MapItem(dup);
}
if (existing.Count >= OrderFileTypeCodes.GetMaxCount(OrderFileTypeCodes.DownstreamLabel))
{
// 超额时软删最早一条再追加
var oldest = existing.OrderBy(x => x.Id).First();
oldest.IsDeleted = true;
oldest.UpdateTime = DateTime.Now;
await orderFileRepository.UpdateAsync(oldest, cancellationToken);
}
var entity = new OrderFile
{
OrderId = orderId,
FileType = OrderFileTypeCodes.DownstreamLabel,
FileName = TryFileNameFromUrl(fileUrl),
FileUrl = OmsObjectStorageExtensions.ToStorageKey(fileUrl) ?? fileUrl.Trim(),
ContentType = null,
Sort = existing.Count,
Source = OrderFileSourceCodes.Downstream,
CreateUserId = operatorUserId,
CreateTime = DateTime.Now
};
var saved = await orderFileRepository.InsertAsync(entity, cancellationToken);
return MapItem(saved);
}
///
/// 下载远端文件转存自有对象存储后写入订单附件。
///
public async Task AddOrReplaceFromRemoteUrlAsync(
int orderId,
string fileType,
string remoteUrl,
int operatorUserId,
bool replaceExisting = true,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(remoteUrl))
{
throw new BusinessException("下游文件地址为空");
}
if (!OrderFileTypeCodes.IsValid(fileType))
{
throw new BusinessException("不支持的附件类型");
}
var type = OrderFileTypeCodes.Normalize(fileType);
var sourceUrl = remoteUrl.Trim();
var stored = await DownloadAndStoreRemoteAsync(orderId, type, sourceUrl, cancellationToken);
if (replaceExisting)
{
await orderFileRepository.SoftDeleteByOrderAndTypeAsync(orderId, type, cancellationToken);
}
else
{
var existing = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
var dup = existing.FirstOrDefault(x =>
string.Equals(x.FileUrl, stored.Key, StringComparison.OrdinalIgnoreCase)
|| string.Equals(x.FileUrl, sourceUrl, StringComparison.OrdinalIgnoreCase));
if (dup != null)
{
return MapItem(dup);
}
if (existing.Count >= OrderFileTypeCodes.GetMaxCount(type))
{
var oldest = existing.OrderBy(x => x.Id).First();
oldest.IsDeleted = true;
oldest.UpdateTime = DateTime.Now;
await orderFileRepository.UpdateAsync(oldest, cancellationToken);
}
}
var existingAfter = replaceExisting
? []
: await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
var entity = new OrderFile
{
OrderId = orderId,
FileType = type,
FileName = stored.FileName,
FileUrl = stored.Key,
ContentType = stored.ContentType,
FileSize = stored.Size,
Sort = existingAfter.Count,
Source = OrderFileSourceCodes.Downstream,
Remark = sourceUrl.Length > 500 ? sourceUrl[..500] : sourceUrl,
CreateUserId = operatorUserId,
CreateTime = DateTime.Now
};
var saved = await orderFileRepository.InsertAsync(entity, cancellationToken);
return MapItem(saved);
}
private async Task<(string Key, string? FileName, string? ContentType, long Size)> DownloadAndStoreRemoteAsync(
int orderId,
string fileType,
string remoteUrl,
CancellationToken cancellationToken)
{
byte[] bytes;
string? contentType;
try
{
var client = httpClientFactory.CreateClient(FileStorageService.UrlFetchClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, remoteUrl);
request.Headers.TryAddWithoutValidation("Accept", "*/*");
using var response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new BusinessException($"下载下游文件失败:HTTP {(int)response.StatusCode}");
}
bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
contentType = response.Content.Headers.ContentType?.MediaType;
}
catch (BusinessException)
{
throw;
}
catch (Exception ex)
{
throw new BusinessException($"下载下游文件失败:{ex.Message}");
}
if (bytes.Length == 0)
{
throw new BusinessException("下游文件内容为空");
}
if (bytes.Length > MaxPdfBytes)
{
throw new BusinessException("下游文件不能超过 10MB");
}
var ext = GuessExtension(remoteUrl, contentType);
var fileName = TryFileNameFromUrl(remoteUrl) ?? $"{fileType}{ext}";
var storedName = $"{Guid.NewGuid():N}{ext}";
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
await using var stream = new MemoryStream(bytes);
var key = await fileStorage.SaveObjectAsync(stream, relativeKey, contentType, cancellationToken);
return (key, fileName, contentType, bytes.Length);
}
private static string GuessExtension(string url, string? contentType)
{
var fromUrl = Path.GetExtension(
(url.Contains('?', StringComparison.Ordinal)
? url[..url.IndexOf('?', StringComparison.Ordinal)]
: url));
if (!string.IsNullOrWhiteSpace(fromUrl) && fromUrl.Length <= 8)
{
return fromUrl.ToLowerInvariant();
}
if (!string.IsNullOrWhiteSpace(contentType))
{
if (contentType.Contains("pdf", StringComparison.OrdinalIgnoreCase))
{
return ".pdf";
}
if (contentType.Contains("png", StringComparison.OrdinalIgnoreCase))
{
return ".png";
}
if (contentType.Contains("jpeg", StringComparison.OrdinalIgnoreCase)
|| contentType.Contains("jpg", StringComparison.OrdinalIgnoreCase))
{
return ".jpg";
}
}
return ".pdf";
}
///
/// 从 Base64 写入订单附件:先转存对象存储(COS),再落 t_order_file。
/// 与订单详情附件 Tab「点击上传」(POST /order/files/upload)同源校验与路径约定。
///
public async Task AddFromBase64Async(
int orderId,
string fileType,
string imageBase64,
int operatorUserId,
CancellationToken cancellationToken = default)
{
if (!OrderFileTypeCodes.IsValid(fileType))
{
throw new BusinessException("不支持的附件类型");
}
var type = OrderFileTypeCodes.Normalize(fileType);
if (string.IsNullOrWhiteSpace(imageBase64))
{
throw new BusinessException("图片内容不能为空");
}
var (bytes, contentType, ext) = DecodeBase64Image(imageBase64);
if (bytes.Length == 0)
{
throw new BusinessException("图片内容不能为空");
}
var existing = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
var max = OrderFileTypeCodes.GetMaxCount(type);
if (existing.Count >= max)
{
throw new BusinessException($"类型 {type} 最多 {max} 个文件");
}
var fileName = $"{type}_{DateTime.Now:yyyyMMddHHmmss}{ext}";
var savedPath = await SaveBytesAsync(bytes, fileName, contentType, orderId, type, cancellationToken);
var sort = existing.Count == 0 ? 0 : existing.Max(x => x.Sort) + 1;
var entity = new OrderFile
{
OrderId = orderId,
FileType = type,
FileName = savedPath.FileName,
FileUrl = savedPath.Url,
ContentType = savedPath.ContentType,
FileSize = savedPath.Size,
Sort = sort,
Source = OrderFileSourceCodes.Upload,
CreateUserId = operatorUserId,
CreateTime = DateTime.Now
};
var saved = await orderFileRepository.InsertAsync(entity, cancellationToken);
return MapItem(saved);
}
private static (byte[] Bytes, string ContentType, string Ext) DecodeBase64Image(string imageBase64)
{
var raw = imageBase64.Trim();
string? headerContentType = null;
if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = raw.IndexOf(',');
if (comma <= 0)
{
throw new BusinessException("图片 Base64 格式错误");
}
var header = raw[..comma];
raw = raw[(comma + 1)..];
var mimeStart = header.IndexOf(':');
var mimeEnd = header.IndexOf(';');
if (mimeStart >= 0 && mimeEnd > mimeStart)
{
headerContentType = header[(mimeStart + 1)..mimeEnd].Trim();
}
}
byte[] bytes;
try
{
bytes = Convert.FromBase64String(raw);
}
catch (FormatException)
{
throw new BusinessException("图片 Base64 解码失败");
}
var sniffed = SniffImageFormat(bytes);
var contentType = headerContentType ?? sniffed.ContentType;
var ext = sniffed.Ext;
if (!string.IsNullOrWhiteSpace(headerContentType))
{
ext = headerContentType.ToLowerInvariant() switch
{
"image/png" => ".png",
"image/webp" => ".webp",
"image/gif" => ".gif",
"image/bmp" => ".bmp",
"application/pdf" => ".pdf",
"image/jpeg" or "image/jpg" => ".jpg",
_ => sniffed.Ext
};
}
return (bytes, contentType, ext);
}
private static (string ContentType, string Ext) SniffImageFormat(byte[] bytes)
{
if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
{
return ("image/jpeg", ".jpg");
}
if (bytes.Length >= 8
&& bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
{
return ("image/png", ".png");
}
if (bytes.Length >= 6
&& bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46)
{
return ("image/gif", ".gif");
}
if (bytes.Length >= 12
&& bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46
&& bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50)
{
return ("image/webp", ".webp");
}
if (bytes.Length >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D)
{
return ("image/bmp", ".bmp");
}
if (bytes.Length >= 5
&& bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46)
{
return ("application/pdf", ".pdf");
}
return ("image/jpeg", ".jpg");
}
///
/// 与 multipart 上传共用:校验扩展名/大小后写入 COS,返回逻辑 object key。
///
private async Task<(string Url, string FileName, string? ContentType, long Size)> SaveBytesAsync(
byte[] bytes,
string fileName,
string? contentType,
int orderId,
string fileType,
CancellationToken cancellationToken = default)
{
if (bytes == null || bytes.Length <= 0)
{
throw new BusinessException("上传文件不能为空");
}
var ext = Path.GetExtension(fileName);
if (string.IsNullOrWhiteSpace(ext))
{
ext = ".bin";
}
var isLabelType = fileType is OrderFileTypeCodes.SystemLabel
or OrderFileTypeCodes.DownstreamLabel
or OrderFileTypeCodes.LastMileLabel;
var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage
or OrderFileTypeCodes.TradeProof
or OrderFileTypeCodes.Pod;
if (isLabelType)
{
if (!LabelExt.Contains(ext))
{
throw new BusinessException("面单仅支持 pdf / jpg / png");
}
if (string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && bytes.Length > MaxPdfBytes)
{
throw new BusinessException("PDF 不能超过 10MB");
}
if (!string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && bytes.Length > MaxImageBytes)
{
throw new BusinessException("图片不能超过 5MB");
}
}
else if (isInfraredOrProof)
{
if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("交易证明/红外图片/POD 仅支持图片或 pdf");
}
if (bytes.Length > MaxPdfBytes)
{
throw new BusinessException("文件不能超过 10MB");
}
}
var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}";
await using var stream = new MemoryStream(bytes);
var key = await fileStorage.SaveObjectAsync(stream, relativeKey, contentType, cancellationToken);
return (key, Path.GetFileName(fileName), contentType, bytes.Length);
}
public async Task> UploadAsync(
int orderId,
string fileType,
IFormFileCollection files,
int operatorUserId,
bool isCustomer,
CancellationToken cancellationToken = default)
{
if (isCustomer)
{
throw new BusinessException("客户账号无权上传订单附件");
}
if (!OrderFileTypeCodes.IsValid(fileType))
{
throw new BusinessException("不支持的附件类型");
}
var type = OrderFileTypeCodes.Normalize(fileType);
if (files == null || files.Count == 0)
{
throw new BusinessException("请选择文件");
}
var existing = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken);
var max = OrderFileTypeCodes.GetMaxCount(type);
var results = new List();
if (string.Equals(type, OrderFileTypeCodes.SystemLabel, StringComparison.OrdinalIgnoreCase))
{
if (files.Count != 1)
{
throw new BusinessException("系统面单仅允许上传 1 个文件");
}
var savedPath = await SaveUploadFileAsync(files[0], orderId, type);
results.Add(await ReplaceSystemLabelAsync(
orderId,
savedPath.Url,
savedPath.FileName,
savedPath.ContentType,
savedPath.Size,
OrderFileSourceCodes.Upload,
operatorUserId,
cancellationToken: cancellationToken));
return results;
}
if (existing.Count + files.Count > max)
{
throw new BusinessException($"类型 {type} 最多 {max} 个文件");
}
var sortBase = existing.Count == 0 ? 0 : existing.Max(x => x.Sort) + 1;
var index = 0;
foreach (var file in files)
{
var savedPath = await SaveUploadFileAsync(file, orderId, type);
var entity = new OrderFile
{
OrderId = orderId,
FileType = type,
FileName = savedPath.FileName,
FileUrl = savedPath.Url,
ContentType = savedPath.ContentType,
FileSize = savedPath.Size,
Sort = sortBase + index,
Source = OrderFileSourceCodes.Upload,
CreateUserId = operatorUserId,
CreateTime = DateTime.Now
};
var saved = await orderFileRepository.InsertAsync(entity, cancellationToken);
results.Add(MapItem(saved));
index++;
}
return results;
}
public async Task DeleteAsync(
int fileId,
bool isCustomer,
CancellationToken cancellationToken = default)
{
if (isCustomer)
{
throw new BusinessException("客户账号无权删除订单附件");
}
var entity = await orderFileRepository.FirstOrDefaultAsync(x => x.Id == fileId, cancellationToken)
?? throw new BusinessException("附件不存在");
entity.IsDeleted = true;
entity.UpdateTime = DateTime.Now;
await orderFileRepository.UpdateAsync(entity, cancellationToken);
}
private async Task<(string Url, string FileName, string? ContentType, long Size)> SaveUploadFileAsync(
IFormFile file,
int orderId,
string fileType)
{
if (file == null || file.Length <= 0)
{
throw new BusinessException("上传文件不能为空");
}
await using var stream = file.OpenReadStream();
await using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
return await SaveBytesAsync(
ms.ToArray(),
file.FileName,
file.ContentType,
orderId,
fileType);
}
private OrderFileItemOutput MapItem(OrderFile x) =>
new()
{
Id = x.Id,
OrderId = x.OrderId,
FileType = x.FileType,
FileName = x.FileName,
FileUrl = fileStorage.GetPublicUrl(x.FileUrl),
ContentType = x.ContentType,
FileSize = x.FileSize,
Sort = x.Sort,
Source = x.Source,
Remark = x.Remark,
CreateTime = x.CreateTime
};
private static string? TryFileNameFromUrl(string url)
{
try
{
var path = url.Contains('?', StringComparison.Ordinal)
? url[..url.IndexOf('?', StringComparison.Ordinal)]
: url;
var name = Path.GetFileName(path);
return string.IsNullOrWhiteSpace(name) ? null : name;
}
catch
{
return null;
}
}
}