diff --git a/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs b/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs new file mode 100644 index 0000000..2e38732 --- /dev/null +++ b/src/Oms.Application/Dtos/Storage/FileStorageDtos.cs @@ -0,0 +1,49 @@ +namespace Oms.Application.Dtos.Storage; + +public class FileStorageNormalizeKeyInput +{ + public string? PathOrUrl { get; set; } +} + +public class FileStorageKeyOutput +{ + public string Key { get; set; } = string.Empty; +} + +public class FileStorageCapabilitiesOutput +{ + public bool SupportsClientDirectUpload { get; set; } + + public string? Provider { get; set; } +} + +public class FileStorageAccessUrlOutput +{ + public string Key { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; +} + +public class FileStoragePresignInput +{ + public string? Folder { get; set; } + + public string? FileName { get; set; } + + public string? ContentType { get; set; } +} + +public class FileStoragePresignOutput +{ + public string Key { get; set; } = string.Empty; + + public string UploadUrl { get; set; } = string.Empty; + + public string Method { get; set; } = "PUT"; + + public string? ContentType { get; set; } + + public Dictionary? Headers { get; set; } + + public string? ExpireAt { get; set; } +} diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs index acd9b8a..c24e699 100644 --- a/src/Oms.Application/OmsApplicationModule.cs +++ b/src/Oms.Application/OmsApplicationModule.cs @@ -12,6 +12,7 @@ using Oms.Application.Integrations.Itfx; using Oms.Application.Integrations.PlatformShop; using Oms.Application.Labeling; using Oms.Application.Labeling.Hiprint; +using Oms.Application.Services.Storage; using Oms.Application.Shared; using Oms.Application.Workers; using Oms.Domain; @@ -52,6 +53,11 @@ public class OmsApplicationModule : FakeModule context.Services.AddTransient(); context.Services.AddTransient(); + context.Services.AddHttpClient(FileStorageService.UrlFetchClientName, client => + { + client.Timeout = TimeSpan.FromSeconds(60); + }); + context.Services.AddOmsSmallBagTrack(configuration); context.Services.AddOmsItfxIntegration(configuration); context.Services.AddOmsPlatformShopIntegration(configuration); diff --git a/src/Oms.Application/Services/Storage/FileStorageService.cs b/src/Oms.Application/Services/Storage/FileStorageService.cs new file mode 100644 index 0000000..d043bc2 --- /dev/null +++ b/src/Oms.Application/Services/Storage/FileStorageService.cs @@ -0,0 +1,301 @@ +using System.Text.RegularExpressions; +using Fake.Application; +using Fake.ObjectStorage; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Storage; +using Oms.Application.Storage; + +namespace Oms.Application.Services.Storage; + +/// +/// 对象存储辅助 API(预览 stream / 规范化 key / 直传预签名)。 +/// +[ApiExplorerSettings(GroupName = "OMS")] +public class FileStorageService : ApplicationService +{ + public const string UrlFetchClientName = "oms-storage-url-fetch"; + + private static readonly HashSet AllowedFolders = new(StringComparer.OrdinalIgnoreCase) + { + "avatars", + "sku", + "parcel", + "recharge", + "order-files", + "order-templates", + "xhs", + "prints", + "exports", + "misc" + }; + + private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService(); + + private IHttpClientFactory HttpClientFactory => + LazyServiceProvider.GetRequiredService(); + + /// + /// 是否支持浏览器直传 COS。 + /// + [HttpGet("/file-storage/capabilities")] + public FileStorageCapabilitiesOutput GetCapabilities() + { + return new FileStorageCapabilitiesOutput + { + SupportsClientDirectUpload = !FileStorage.IsLocalProvider(), + Provider = FileStorage.Name + }; + } + + /// + /// 将 COS URL / 相对路径规范为 object key。 + /// + [HttpPost("/file-storage/normalize-key")] + public FileStorageKeyOutput NormalizeKey([FromBody] FileStorageNormalizeKeyInput input) + { + if (string.IsNullOrWhiteSpace(input?.PathOrUrl)) + { + throw new BusinessException("路径不能为空"); + } + + return new FileStorageKeyOutput { Key = FileStoragePath.NormalizeKey(input.PathOrUrl) }; + } + + /// + /// 按 key 换临时访问地址。 + /// + [HttpGet("/file-storage/access-url")] + public async Task GetAccessUrlAsync( + [FromQuery] string key, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new BusinessException("key 不能为空"); + } + + var trimmed = key.Trim(); + var url = await FileStorage.GetPublicUrlAsync(trimmed, cancellationToken); + if (string.IsNullOrWhiteSpace(url)) + { + throw new BusinessException("未返回访问地址"); + } + + return new FileStorageAccessUrlOutput + { + Key = FileStoragePath.NormalizeKey(trimmed), + Url = url + }; + } + + /// + /// 签发预签名 PUT,供前端直传。 + /// + [HttpPost("/file-storage/presign-upload")] + public async Task PresignUploadAsync( + [FromBody] FileStoragePresignInput input, + CancellationToken cancellationToken) + { + var folder = (input.Folder ?? "misc").Trim().Trim('/'); + if (string.IsNullOrWhiteSpace(folder) || folder.Contains("..") || folder.Contains('/') || folder.Contains('\\')) + { + throw new BusinessException("folder 无效"); + } + + if (!AllowedFolders.Contains(folder)) + { + throw new BusinessException("不支持的上传目录"); + } + + var fileName = string.IsNullOrWhiteSpace(input.FileName) ? "file.bin" : Path.GetFileName(input.FileName.Trim()); + var ext = Path.GetExtension(fileName); + if (ext.Length > 16) + { + ext = ""; + } + + var objectKey = $"uploads/{folder}/{DateTime.Now:yyyyMM}/{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; + var result = await FileStorage.CreatePresignedUploadAsync( + new ObjectStoragePresignUploadArgs + { + ObjectKey = objectKey, + ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? null : input.ContentType.Trim() + }, + cancellationToken); + + var uploadUrl = result.UploadUrl; + if (string.IsNullOrWhiteSpace(uploadUrl)) + { + throw new BusinessException("预签名上传凭证不完整"); + } + + Dictionary? headers = null; + if (result.Headers is { Count: > 0 }) + { + headers = result.Headers.ToDictionary(x => x.Key, x => x.Value ?? "", StringComparer.OrdinalIgnoreCase); + } + + return new FileStoragePresignOutput + { + Key = string.IsNullOrWhiteSpace(result.ObjectKey) ? objectKey : result.ObjectKey, + UploadUrl = uploadUrl, + Method = string.IsNullOrWhiteSpace(result.Method) ? "PUT" : result.Method, + ContentType = result.ContentType ?? input.ContentType, + Headers = headers, + ExpireAt = FormatExpireAt(result.ExpireAt) + }; + } + + /// + /// 鉴权读取对象字节(面单/附件预览)。key 可为 object key 或 COS 签名 URL。 + /// + [HttpGet("/file-storage/stream")] + public async Task StreamAsync( + [FromQuery] string key, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new BusinessException("key 不能为空"); + } + + var raw = key.Trim(); + foreach (var candidate in FileStoragePath.ExpandReadKeys(raw)) + { + var bytes = await TryReadObjectBytesAsync(candidate, cancellationToken); + if (bytes is { Length: > 0 }) + { + return FileResult(bytes, candidate); + } + } + + if (FileStoragePath.IsAbsoluteUrl(raw)) + { + var fetched = await TryFetchUrlBytesAsync(raw, cancellationToken); + if (fetched is { Length: > 0 }) + { + return FileResult(fetched, raw); + } + } + + throw new BusinessException("文件不存在或无法读取"); + } + + private async Task TryReadObjectBytesAsync(string objectKey, CancellationToken cancellationToken) + { + try + { + if (!await FileStorage.ExistsAsync(objectKey, cancellationToken)) + { + return null; + } + + await using var stream = await FileStorage.GetAsync(objectKey, cancellationToken); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, cancellationToken); + return ms.ToArray(); + } + catch (UnauthorizedAccessException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + catch (FileNotFoundException) + { + return null; + } + catch (IOException) + { + return null; + } + catch (Exception) + { + return null; + } + } + + private async Task TryFetchUrlBytesAsync(string url, CancellationToken cancellationToken) + { + try + { + var client = HttpClientFactory.CreateClient(UrlFetchClientName); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("Accept", "*/*"); + using var response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + catch (Exception) + { + return null; + } + } + + private static FileContentResult FileResult(byte[] bytes, string nameHint) + { + var mime = GuessMime(nameHint); + var path = nameHint.Contains('?', StringComparison.Ordinal) + ? nameHint[..nameHint.IndexOf('?', StringComparison.Ordinal)] + : nameHint; + var fileName = Path.GetFileName(path); + if (string.IsNullOrWhiteSpace(fileName)) + { + fileName = "file"; + } + + return new FileContentResult(bytes, mime) + { + FileDownloadName = fileName + }; + } + + private static string GuessMime(string pathOrUrl) + { + if (Regex.IsMatch(pathOrUrl, @"\.pdf(\?|#|$)", RegexOptions.IgnoreCase)) + { + return "application/pdf"; + } + + if (Regex.IsMatch(pathOrUrl, @"\.png(\?|#|$)", RegexOptions.IgnoreCase)) + { + return "image/png"; + } + + if (Regex.IsMatch(pathOrUrl, @"\.jpe?g(\?|#|$)", RegexOptions.IgnoreCase)) + { + return "image/jpeg"; + } + + if (Regex.IsMatch(pathOrUrl, @"\.webp(\?|#|$)", RegexOptions.IgnoreCase)) + { + return "image/webp"; + } + + if (Regex.IsMatch(pathOrUrl, @"\.gif(\?|#|$)", RegexOptions.IgnoreCase)) + { + return "image/gif"; + } + + return "application/octet-stream"; + } + + private static string? FormatExpireAt(object? expireAt) + { + return expireAt switch + { + DateTimeOffset dto when dto != default => dto.ToString("o"), + DateTime dt when dt != default => dt.ToString("o"), + _ => expireAt?.ToString() + }; + } +} diff --git a/src/Oms.Application/Storage/FileStoragePath.cs b/src/Oms.Application/Storage/FileStoragePath.cs index d0322a8..45cae8f 100644 --- a/src/Oms.Application/Storage/FileStoragePath.cs +++ b/src/Oms.Application/Storage/FileStoragePath.cs @@ -38,4 +38,62 @@ public static class FileStoragePath !string.IsNullOrWhiteSpace(value) && (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)); + + /// + /// 读取对象时尝试的 key:含/不含 admin/ 前缀(自定义域路径常带 PathBase)。 + /// + public static IReadOnlyList ExpandReadKeys(string? pathOrUrl) + { + var keys = new List(); + void Push(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return; + } + + var key = raw.Replace('\\', '/').Trim().TrimStart('/'); + if (key.Length == 0) + { + return; + } + + if (keys.Exists(x => string.Equals(x, key, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + keys.Add(key); + } + + if (IsAbsoluteUrl(pathOrUrl)) + { + try + { + Push(NormalizeKey(pathOrUrl)); + } + catch (ArgumentException) + { + // ignore + } + } + else + { + Push(pathOrUrl); + } + + foreach (var existing in keys.ToArray()) + { + if (existing.StartsWith("admin/", StringComparison.OrdinalIgnoreCase)) + { + Push(existing["admin/".Length..]); + } + else + { + Push("admin/" + existing); + } + } + + return keys; + } } diff --git a/src/Oms.Tests/FileStoragePathTests.cs b/src/Oms.Tests/FileStoragePathTests.cs new file mode 100644 index 0000000..07b0843 --- /dev/null +++ b/src/Oms.Tests/FileStoragePathTests.cs @@ -0,0 +1,19 @@ +using FluentAssertions; +using Oms.Application.Storage; + +namespace Oms.Tests; + +public class FileStoragePathTests +{ + [Fact] + public void ExpandReadKeys_ShouldStripQueryAndTryAdminPrefix() + { + var url = + "https://cos.test.ideship.com/admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf?q-sign-algorithm=sha1"; + + var keys = FileStoragePath.ExpandReadKeys(url); + + keys.Should().Contain("admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf"); + keys.Should().Contain("uploads/prints/orders/IDE2610061059117673-20261006105912.pdf"); + } +}