189 lines
8.1 KiB
C#
189 lines
8.1 KiB
C#
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,返回 null(OCR 会回退 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;
|
||
}
|
||
}
|
||
}
|