admin/src/Oms.Application/Services/Storage/FileStorageService.cs
zzh 14c42db752
All checks were successful
Build and Deploy / deploy (push) Successful in 1m8s
feat: 增加 /file-storage 接口,供附件预览按对象 key 流式读取
补 normalize-key 与 stream,兼容带或不带 admin 前缀的 COS 路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 12:21:06 +08:00

302 lines
9.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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