All checks were successful
Build and Deploy / deploy (push) Successful in 1m8s
补 normalize-key 与 stream,兼容带或不带 admin 前缀的 COS 路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
302 lines
9.2 KiB
C#
302 lines
9.2 KiB
C#
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()
|
||
};
|
||
}
|
||
}
|