admin/src/Oms.Application/Services/Orders/OrderFileManager.cs
zzh e85dfb80b5
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s
feat: 仓配开放 API 与后台用户启停
新增 warehouseOpenApi(Token/入库/件重尺/红外图),并在 fk_user 支持 IsEnabled 禁用登录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 17:53:00 +08:00

730 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// 订单附件读写(系统面单仅 1 份,其它类型最多 20)。
/// </summary>
public sealed class OrderFileManager(
IOrderFileRepository orderFileRepository,
IObjectStorage fileStorage,
IHttpClientFactory httpClientFactory) : IScopedDependency
{
private static readonly HashSet<string> ImageExt =
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp" };
private static readonly HashSet<string> 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<string?> 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());
}
/// <summary>
/// 批量取系统面单公开地址(已存在才返回)。
/// </summary>
public async Task<Dictionary<int, string>> GetSystemLabelUrlsAsync(
IReadOnlyCollection<int> orderIds,
CancellationToken cancellationToken = default)
{
var result = new Dictionary<int, string>();
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<List<OrderFileItemOutput>> 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<OrderFileGroupedOutput> 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<OrderFileItemOutput> 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<OrderFileItemOutput> 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);
}
/// <summary>
/// 下载远端文件转存自有对象存储后写入订单附件。
/// </summary>
public async Task<OrderFileItemOutput> 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";
}
/// <summary>
/// 从 Base64 写入订单附件:先转存对象存储(COS),再落 t_order_file。
/// 与订单详情附件 Tab「点击上传」(POST /order/files/upload)同源校验与路径约定。
/// </summary>
public async Task<OrderFileItemOutput> 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");
}
/// <summary>
/// 与 multipart 上传共用:校验扩展名/大小后写入 COS,返回逻辑 object key。
/// </summary>
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<List<OrderFileItemOutput>> 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<OrderFileItemOutput>();
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;
}
}
}