All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s
新增 warehouseOpenApi(Token/入库/件重尺/红外图),并在 fk_user 支持 IsEnabled 禁用登录。 Co-authored-by: Cursor <cursoragent@cursor.com>
730 lines
25 KiB
C#
730 lines
25 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|