using Fake.Application; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Oms.Application.Storage; namespace Oms.Application.Services.Storage; /// /// 文件存储直传 / 临时访问。私有桶:前端拿凭证直传 COS,后端只存 key。 /// [Authorize] [ApiExplorerSettings(GroupName = "OMS-Storage")] public class FileStorageService : ApplicationService { private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService(); /// /// 是否支持前端直传(Cos=true)。 /// [HttpGet("/file-storage/capabilities")] public object GetCapabilities() => new { supportsClientDirectUpload = FileStorage.SupportsClientDirectUpload, provider = FileStorage.GetType().Name }; /// /// 签发预签名 PUT 上传地址。前端对该 URL 直接 PUT 文件体,成功后把返回的 key 交给业务接口落库。 /// [HttpPost("/file-storage/presign-upload")] public async Task PresignUploadAsync([FromBody] PresignUploadInput input) { if (string.IsNullOrWhiteSpace(input.FileName)) { throw new BusinessException("fileName 不能为空"); } var ticket = await FileStorage.CreatePresignedUploadAsync( input.Folder ?? "misc", input.FileName.Trim(), input.ContentType); return new PresignUploadOutput { Key = ticket.Key, UploadUrl = ticket.UploadUrl, Method = ticket.Method, ContentType = ticket.ContentType, Headers = ticket.Headers, ExpireAt = ticket.ExpireAt }; } /// /// 签发 STS 临时密钥。前端用 cos-js-sdk-v5 等 SDK 直传;落库只存 key。 /// [HttpPost("/file-storage/sts-upload")] public async Task StsUploadAsync([FromBody] PresignUploadInput input) { if (string.IsNullOrWhiteSpace(input.FileName)) { throw new BusinessException("fileName 不能为空"); } var ticket = await FileStorage.CreateStsUploadAsync( input.Folder ?? "misc", input.FileName.Trim()); return new StsUploadOutput { Key = ticket.Key, CosKey = ticket.CosKey, Bucket = ticket.Bucket, Region = ticket.Region, TmpSecretId = ticket.TmpSecretId, TmpSecretKey = ticket.TmpSecretKey, SessionToken = ticket.SessionToken, StartTime = ticket.StartTime, ExpiredTime = ticket.ExpiredTime }; } /// /// 按库内 key 生成临时访问地址(私有桶预签名 GET)。 /// [HttpGet("/file-storage/access-url")] public AccessUrlOutput GetAccessUrl([FromQuery] string key) { if (string.IsNullOrWhiteSpace(key)) { throw new BusinessException("key 不能为空"); } var storageKey = FileStorage.ToStorageKey(key) ?? key.Trim(); return new AccessUrlOutput { Key = storageKey, Url = FileStorage.GetPublicUrl(storageKey) }; } /// /// 将完整 URL / 相对路径规范为库内 key(业务保存前可调用)。 /// [HttpPost("/file-storage/normalize-key")] public NormalizeKeyOutput NormalizeKey([FromBody] NormalizeKeyInput input) { var key = FileStorage.ToStorageKey(input.PathOrUrl); return new NormalizeKeyOutput { Key = key }; } /// /// 按 key / URL 流式读取文件(供面单预览等场景,避免前端跨域 iframe 空白)。 /// [HttpGet("/file-storage/stream")] public async Task StreamAsync([FromQuery] string key, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(key)) { throw new BusinessException("key 不能为空"); } string storageKey; try { storageKey = FileStorage.ToStorageKey(key) ?? FileStoragePath.NormalizeKey(key); } catch (Exception) { throw new BusinessException("无法解析文件 key"); } if (!await FileStorage.ExistsAsync(storageKey, cancellationToken)) { throw new BusinessException("文件不存在或已过期"); } var stream = await FileStorage.OpenReadAsync(storageKey, cancellationToken); return new FileStreamResult(stream, GuessStreamContentType(storageKey)) { EnableRangeProcessing = true, }; } private static string GuessStreamContentType(string storageKey) { if (storageKey.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) { return "application/pdf"; } if (storageKey.EndsWith(".html", StringComparison.OrdinalIgnoreCase) || storageKey.EndsWith(".htm", StringComparison.OrdinalIgnoreCase)) { return "text/html; charset=utf-8"; } return "application/octet-stream"; } } public class PresignUploadInput { /// 业务目录:avatars / sku / parcel / recharge / order-files / xhs 等。 public string? Folder { get; set; } public string FileName { get; set; } = string.Empty; public string? ContentType { get; set; } } public class PresignUploadOutput { 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 IReadOnlyDictionary Headers { get; set; } = new Dictionary(); public DateTime ExpireAt { get; set; } } public class StsUploadOutput { public string Key { get; set; } = string.Empty; public string CosKey { get; set; } = string.Empty; public string Bucket { get; set; } = string.Empty; public string Region { get; set; } = string.Empty; public string TmpSecretId { get; set; } = string.Empty; public string TmpSecretKey { get; set; } = string.Empty; public string SessionToken { get; set; } = string.Empty; public long StartTime { get; set; } public long ExpiredTime { get; set; } } public class AccessUrlOutput { public string Key { get; set; } = string.Empty; public string Url { get; set; } = string.Empty; } public class NormalizeKeyInput { public string? PathOrUrl { get; set; } } public class NormalizeKeyOutput { public string? Key { get; set; } }