admin/src/Oms.Application/Services/Storage/FileStorageService.cs
zzh 40997f7d71
All checks were successful
Build and Deploy / deploy (push) Successful in 1m46s
feat: 新增 /file-storage/stream 供面单预览鉴权读取
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 19:14:26 +08:00

211 lines
6.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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