Files

189 lines
8.1 KiB
C#
Raw Permalink 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 System.Net;
using Aliyun.OSS;
namespace AgriculturalPlatform.Api.Services;
/// <summary>已保存文件的存储 key 与访问地址</summary>
public record StoredFile(string Key, string Url);
/// <summary>
/// 附件文件存储抽象。
/// 当前默认实现为本地存储(wwwroot/uploads);
/// 阿里云 OSS 授权验证配置好后(appsettings.json 的 Oss 节点),
/// 将 Storage:UseOss 设为 true 即可切换到 OSS。
/// </summary>
public interface IFileStorage
{
/// <summary>保存文件,返回存储 key 与访问 URL</summary>
Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType);
/// <summary>按 key 删除文件(本地/OSS 通用)</summary>
Task DeleteAsync(string key);
/// <summary>打开文件流供读取(OSS 模式下用于私有桶代理访问),不存在返回 null</summary>
Task<(Stream Stream, string ContentType)?> OpenReadAsync(string key);
/// <summary>生成临时访问 URL(仅 OSS 模式支持),本地存储返回 null</summary>
string? GeneratePresignedUrl(string key);
}
/// <summary>本地磁盘存储(wwwroot/uploads),URL 即相对路径,由静态文件中间件直接访问</summary>
public sealed class LocalFileStorage(IWebHostEnvironment env) : IFileStorage
{
private readonly string _root = Path.Combine(
env.WebRootPath ?? Path.Combine(env.ContentRootPath, "wwwroot"), "uploads");
public async Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType)
{
var dateDir = DateTime.Now.ToString("yyyyMM");
var dir = Path.Combine(_root, subDir, dateDir);
Directory.CreateDirectory(dir);
var ext = Path.GetExtension(fileName);
if (string.IsNullOrWhiteSpace(ext)) ext = ".jpg";
if (ext.Length > 10) ext = ".jpg";
var name = $"{Guid.NewGuid():N}{ext}";
var full = Path.Combine(dir, name);
await using var fs = File.Create(full);
await stream.CopyToAsync(fs);
var key = $"/uploads/{subDir}/{dateDir}/{name}";
return new StoredFile(key, key);
}
public Task DeleteAsync(string key)
{
if (string.IsNullOrWhiteSpace(key) || !key.StartsWith("/uploads/")) return Task.CompletedTask;
try
{
var full = Path.Combine(_root, key.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(full)) File.Delete(full);
}
catch
{
// 忽略删除失败
}
return Task.CompletedTask;
}
public Task<(Stream Stream, string ContentType)?> OpenReadAsync(string key)
{
if (string.IsNullOrWhiteSpace(key) || !key.StartsWith("/uploads/"))
return Task.FromResult<(Stream, string)?>(null);
var full = Path.Combine(_root, key.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(full)) return Task.FromResult<(Stream, string)?>(null);
var contentType = _contentTypes[Path.GetExtension(full)] ?? "application/octet-stream";
return Task.FromResult<(Stream, string)?>((File.OpenRead(full), contentType));
}
/// <summary>本地存储无公开 URL,返回 nullOCR 会回退 base64 方式)</summary>
public string? GeneratePresignedUrl(string key) => null;
private static readonly Dictionary<string, string> _contentTypes = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png",
[".gif"] = "image/gif", [".webp"] = "image/webp", [".bmp"] = "image/bmp",
};
}
/// <summary>
/// 阿里云 OSS 存储。配置见 appsettings.json 的 Oss 节点(AccessKeyId / AccessKeySecret / Endpoint / Bucket);
/// 未配置时调用会抛出 InvalidOperationException,由接口统一转为友好提示。
/// </summary>
public sealed class OssFileStorage(IConfiguration cfg) : IFileStorage
{
private readonly OssClient? _client = CreateClient(cfg);
private readonly string? _bucket = cfg["Oss:Bucket"];
/// <summary>配置完整(AccessKeyId / AccessKeySecret / Endpoint / Bucket 均非空)时才能创建客户端</summary>
private static OssClient? CreateClient(IConfiguration cfg)
{
var id = cfg["Oss:AccessKeyId"];
var secret = cfg["Oss:AccessKeySecret"];
var endpoint = cfg["Oss:Endpoint"];
var bucket = cfg["Oss:Bucket"];
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(secret)
|| string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(bucket))
return null;
return new OssClient(endpoint, id, secret);
}
public bool IsConfigured => _client != null;
/// <summary>
/// 上传到 OSS,返回 StoredFile。
/// Key 使用 "uploads/{subDir}/{yyyyMM}/{guid}{ext}"OSS 对象键不带前导斜杠)。
/// URL 返回后端代理相对路径 /api/files/{key},由 FilesController 实时从私有桶拉流返回,
/// 保证 Bucket 可保持私有(身份证等敏感信息不暴露公网)。
/// </summary>
public Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType)
{
if (_client is null || string.IsNullOrWhiteSpace(_bucket))
throw new InvalidOperationException(
"阿里云 OSS 尚未配置授权验证,请在 appsettings.json 的 Oss 节点填写 AccessKeyId / AccessKeySecret / Endpoint / Bucket 后重启服务");
var dateDir = DateTime.Now.ToString("yyyyMM");
var ext = Path.GetExtension(fileName);
if (string.IsNullOrWhiteSpace(ext) || ext.Length > 10) ext = ".jpg";
var key = $"uploads/{subDir}/{dateDir}/{Guid.NewGuid():N}{ext}";
var metadata = new ObjectMetadata { ContentType = contentType };
_client.PutObject(_bucket, key, stream, metadata);
return Task.FromResult(new StoredFile(key, $"/api/files/{key}"));
}
/// <summary>按 key 删除 OSS 对象(兼容本地存储带前导斜杠的 key 格式)</summary>
public Task DeleteAsync(string key)
{
if (_client is null || string.IsNullOrWhiteSpace(_bucket)) return Task.CompletedTask;
if (string.IsNullOrWhiteSpace(key)) return Task.CompletedTask;
try
{
var objectKey = key.StartsWith('/') ? key.TrimStart('/') : key;
if (objectKey.StartsWith("uploads/"))
_client.DeleteObject(_bucket, objectKey);
}
catch
{
// 忽略删除失败
}
return Task.CompletedTask;
}
/// <summary>从私有桶拉取对象流(供代理接口使用),不存在返回 null</summary>
public Task<(Stream Stream, string ContentType)?> OpenReadAsync(string key)
{
if (_client is null || string.IsNullOrWhiteSpace(_bucket)) return Task.FromResult<(Stream, string)?>(null);
var objectKey = key.StartsWith('/') ? key.TrimStart('/') : key;
if (!objectKey.StartsWith("uploads/")) return Task.FromResult<(Stream, string)?>(null);
try
{
var obj = _client.GetObject(_bucket, objectKey);
var contentType = obj.Metadata.ContentType ?? "application/octet-stream";
return Task.FromResult<(Stream, string)?>((obj.Content, contentType));
}
catch (Exception ex) when (ex is Aliyun.OSS.Common.OssException or WebException or System.Net.Http.HttpRequestException)
{
return Task.FromResult<(Stream, string)?>(null);
}
}
/// <summary>生成 10 分钟有效的私有桶临时访问 URL(供 OCR 等第三方服务拉取图片),失败返回 null</summary>
public string? GeneratePresignedUrl(string key)
{
if (_client is null || string.IsNullOrWhiteSpace(_bucket)) return null;
var objectKey = key.StartsWith('/') ? key.TrimStart('/') : key;
if (!objectKey.StartsWith("uploads/")) return null;
try
{
return _client.GeneratePresignedUri(_bucket, objectKey, DateTime.Now.AddMinutes(10)).ToString();
}
catch
{
return null;
}
}
}