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