feat: 增加 /file-storage 接口,供附件预览按对象 key 流式读取
All checks were successful
Build and Deploy / deploy (push) Successful in 1m8s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m8s
补 normalize-key 与 stream,兼容带或不带 admin 前缀的 COS 路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
04dc9ca1ff
commit
14c42db752
49
src/Oms.Application/Dtos/Storage/FileStorageDtos.cs
Normal file
49
src/Oms.Application/Dtos/Storage/FileStorageDtos.cs
Normal file
@ -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<string, string>? Headers { get; set; }
|
||||||
|
|
||||||
|
public string? ExpireAt { get; set; }
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ using Oms.Application.Integrations.Itfx;
|
|||||||
using Oms.Application.Integrations.PlatformShop;
|
using Oms.Application.Integrations.PlatformShop;
|
||||||
using Oms.Application.Labeling;
|
using Oms.Application.Labeling;
|
||||||
using Oms.Application.Labeling.Hiprint;
|
using Oms.Application.Labeling.Hiprint;
|
||||||
|
using Oms.Application.Services.Storage;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
using Oms.Application.Workers;
|
using Oms.Application.Workers;
|
||||||
using Oms.Domain;
|
using Oms.Domain;
|
||||||
@ -52,6 +53,11 @@ public class OmsApplicationModule : FakeModule
|
|||||||
context.Services.AddTransient<ILabelPdfRenderer, QuestPdfLabelRenderer>();
|
context.Services.AddTransient<ILabelPdfRenderer, QuestPdfLabelRenderer>();
|
||||||
context.Services.AddTransient<LabelPdfGenerationService>();
|
context.Services.AddTransient<LabelPdfGenerationService>();
|
||||||
|
|
||||||
|
context.Services.AddHttpClient(FileStorageService.UrlFetchClientName, client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(60);
|
||||||
|
});
|
||||||
|
|
||||||
context.Services.AddOmsSmallBagTrack(configuration);
|
context.Services.AddOmsSmallBagTrack(configuration);
|
||||||
context.Services.AddOmsItfxIntegration(configuration);
|
context.Services.AddOmsItfxIntegration(configuration);
|
||||||
context.Services.AddOmsPlatformShopIntegration(configuration);
|
context.Services.AddOmsPlatformShopIntegration(configuration);
|
||||||
|
|||||||
301
src/Oms.Application/Services/Storage/FileStorageService.cs
Normal file
301
src/Oms.Application/Services/Storage/FileStorageService.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 对象存储辅助 API(预览 stream / 规范化 key / 直传预签名)。
|
||||||
|
/// </summary>
|
||||||
|
[ApiExplorerSettings(GroupName = "OMS")]
|
||||||
|
public class FileStorageService : ApplicationService
|
||||||
|
{
|
||||||
|
public const string UrlFetchClientName = "oms-storage-url-fetch";
|
||||||
|
|
||||||
|
private static readonly HashSet<string> AllowedFolders = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"avatars",
|
||||||
|
"sku",
|
||||||
|
"parcel",
|
||||||
|
"recharge",
|
||||||
|
"order-files",
|
||||||
|
"order-templates",
|
||||||
|
"xhs",
|
||||||
|
"prints",
|
||||||
|
"exports",
|
||||||
|
"misc"
|
||||||
|
};
|
||||||
|
|
||||||
|
private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService<IObjectStorage>();
|
||||||
|
|
||||||
|
private IHttpClientFactory HttpClientFactory =>
|
||||||
|
LazyServiceProvider.GetRequiredService<IHttpClientFactory>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否支持浏览器直传 COS。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/file-storage/capabilities")]
|
||||||
|
public FileStorageCapabilitiesOutput GetCapabilities()
|
||||||
|
{
|
||||||
|
return new FileStorageCapabilitiesOutput
|
||||||
|
{
|
||||||
|
SupportsClientDirectUpload = !FileStorage.IsLocalProvider(),
|
||||||
|
Provider = FileStorage.Name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 COS URL / 相对路径规范为 object key。
|
||||||
|
/// </summary>
|
||||||
|
[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) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按 key 换临时访问地址。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/file-storage/access-url")]
|
||||||
|
public async Task<FileStorageAccessUrlOutput> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 签发预签名 PUT,供前端直传。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("/file-storage/presign-upload")]
|
||||||
|
public async Task<FileStoragePresignOutput> 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<string, string>? 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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鉴权读取对象字节(面单/附件预览)。key 可为 object key 或 COS 签名 URL。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("/file-storage/stream")]
|
||||||
|
public async Task<FileContentResult> 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<byte[]?> 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<byte[]?> 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,4 +38,62 @@ public static class FileStoragePath
|
|||||||
!string.IsNullOrWhiteSpace(value)
|
!string.IsNullOrWhiteSpace(value)
|
||||||
&& (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
&& (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||||
|| value.StartsWith("https://", StringComparison.OrdinalIgnoreCase));
|
|| value.StartsWith("https://", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 读取对象时尝试的 key:含/不含 <c>admin/</c> 前缀(自定义域路径常带 PathBase)。
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> ExpandReadKeys(string? pathOrUrl)
|
||||||
|
{
|
||||||
|
var keys = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/Oms.Tests/FileStoragePathTests.cs
Normal file
19
src/Oms.Tests/FileStoragePathTests.cs
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user