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; } } }