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;
///
/// 对象存储辅助 API(预览 stream / 规范化 key / 直传预签名)。
///
[ApiExplorerSettings(GroupName = "OMS")]
public class FileStorageService : ApplicationService
{
public const string UrlFetchClientName = "oms-storage-url-fetch";
private static readonly HashSet AllowedFolders = new(StringComparer.OrdinalIgnoreCase)
{
"avatars",
"sku",
"parcel",
"recharge",
"order-files",
"order-templates",
"xhs",
"prints",
"exports",
"misc"
};
private IObjectStorage FileStorage => LazyServiceProvider.GetRequiredService();
private IHttpClientFactory HttpClientFactory =>
LazyServiceProvider.GetRequiredService();
///
/// 是否支持浏览器直传 COS。
///
[HttpGet("/file-storage/capabilities")]
public FileStorageCapabilitiesOutput GetCapabilities()
{
return new FileStorageCapabilitiesOutput
{
SupportsClientDirectUpload = !FileStorage.IsLocalProvider(),
Provider = FileStorage.Name
};
}
///
/// 将 COS URL / 相对路径规范为 object key。
///
[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) };
}
///
/// 按 key 换临时访问地址。
///
[HttpGet("/file-storage/access-url")]
public async Task 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
};
}
///
/// 签发预签名 PUT,供前端直传。
///
[HttpPost("/file-storage/presign-upload")]
public async Task 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? 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)
};
}
///
/// 鉴权读取对象字节(面单/附件预览)。key 可为 object key 或 COS 签名 URL。
///
[HttpGet("/file-storage/stream")]
public async Task 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 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 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()
};
}
}