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