20261008-ide1.0发布 #1

Merged
zzh merged 42 commits from test into master 2026-10-08 09:18:00 +08:00
5 changed files with 433 additions and 0 deletions
Showing only changes of commit 14c42db752 - Show all commits

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

View File

@ -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);

View 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()
};
}
}

View File

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

View 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");
}
}