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.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<ILabelPdfRenderer, QuestPdfLabelRenderer>();
|
||||
context.Services.AddTransient<LabelPdfGenerationService>();
|
||||
|
||||
context.Services.AddHttpClient(FileStorageService.UrlFetchClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
});
|
||||
|
||||
context.Services.AddOmsSmallBagTrack(configuration);
|
||||
context.Services.AddOmsItfxIntegration(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)
|
||||
&& (value.StartsWith("http://", 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