All checks were successful
Build and Deploy / deploy (push) Successful in 1m46s
Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
6.6 KiB
C#
211 lines
6.6 KiB
C#
using Fake.Application;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Oms.Application.Storage;
|
||
|
||
namespace Oms.Application.Services.Storage;
|
||
|
||
/// <summary>
|
||
/// 文件存储直传 / 临时访问。私有桶:前端拿凭证直传 COS,后端只存 key。
|
||
/// </summary>
|
||
[Authorize]
|
||
[ApiExplorerSettings(GroupName = "OMS-Storage")]
|
||
public class FileStorageService : ApplicationService
|
||
{
|
||
private IFileStorage FileStorage => LazyServiceProvider.GetRequiredService<IFileStorage>();
|
||
|
||
/// <summary>
|
||
/// 是否支持前端直传(Cos=true)。
|
||
/// </summary>
|
||
[HttpGet("/file-storage/capabilities")]
|
||
public object GetCapabilities() => new
|
||
{
|
||
supportsClientDirectUpload = FileStorage.SupportsClientDirectUpload,
|
||
provider = FileStorage.GetType().Name
|
||
};
|
||
|
||
/// <summary>
|
||
/// 签发预签名 PUT 上传地址。前端对该 URL 直接 PUT 文件体,成功后把返回的 key 交给业务接口落库。
|
||
/// </summary>
|
||
[HttpPost("/file-storage/presign-upload")]
|
||
public async Task<PresignUploadOutput> 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
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 签发 STS 临时密钥。前端用 cos-js-sdk-v5 等 SDK 直传;落库只存 key。
|
||
/// </summary>
|
||
[HttpPost("/file-storage/sts-upload")]
|
||
public async Task<StsUploadOutput> 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
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按库内 key 生成临时访问地址(私有桶预签名 GET)。
|
||
/// </summary>
|
||
[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)
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将完整 URL / 相对路径规范为库内 key(业务保存前可调用)。
|
||
/// </summary>
|
||
[HttpPost("/file-storage/normalize-key")]
|
||
public NormalizeKeyOutput NormalizeKey([FromBody] NormalizeKeyInput input)
|
||
{
|
||
var key = FileStorage.ToStorageKey(input.PathOrUrl);
|
||
return new NormalizeKeyOutput { Key = key };
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按 key / URL 流式读取文件(供面单预览等场景,避免前端跨域 iframe 空白)。
|
||
/// </summary>
|
||
[HttpGet("/file-storage/stream")]
|
||
public async Task<IActionResult> 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
|
||
{
|
||
/// <summary>业务目录:avatars / sku / parcel / recharge / order-files / xhs 等。</summary>
|
||
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<string, string> Headers { get; set; } =
|
||
new Dictionary<string, string>();
|
||
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; }
|
||
}
|