using System.Net;
using Aliyun.OSS;
namespace AgriculturalPlatform.Api.Services;
/// 已保存文件的存储 key 与访问地址
public record StoredFile(string Key, string Url);
///
/// 附件文件存储抽象。
/// 当前默认实现为本地存储(wwwroot/uploads);
/// 阿里云 OSS 授权验证配置好后(appsettings.json 的 Oss 节点),
/// 将 Storage:UseOss 设为 true 即可切换到 OSS。
///
public interface IFileStorage
{
/// 保存文件,返回存储 key 与访问 URL
Task SaveAsync(string subDir, Stream stream, string fileName, string contentType);
/// 按 key 删除文件(本地/OSS 通用)
Task DeleteAsync(string key);
/// 打开文件流供读取(OSS 模式下用于私有桶代理访问),不存在返回 null
Task<(Stream Stream, string ContentType)?> OpenReadAsync(string key);
/// 生成临时访问 URL(仅 OSS 模式支持),本地存储返回 null
string? GeneratePresignedUrl(string key);
}
/// 本地磁盘存储(wwwroot/uploads),URL 即相对路径,由静态文件中间件直接访问
public sealed class LocalFileStorage(IWebHostEnvironment env) : IFileStorage
{
private readonly string _root = Path.Combine(
env.WebRootPath ?? Path.Combine(env.ContentRootPath, "wwwroot"), "uploads");
public async Task 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));
}
/// 本地存储无公开 URL,返回 null(OCR 会回退 base64 方式)
public string? GeneratePresignedUrl(string key) => null;
private static readonly Dictionary _contentTypes = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png",
[".gif"] = "image/gif", [".webp"] = "image/webp", [".bmp"] = "image/bmp",
};
}
///
/// 阿里云 OSS 存储。配置见 appsettings.json 的 Oss 节点(AccessKeyId / AccessKeySecret / Endpoint / Bucket);
/// 未配置时调用会抛出 InvalidOperationException,由接口统一转为友好提示。
///
public sealed class OssFileStorage(IConfiguration cfg) : IFileStorage
{
private readonly OssClient? _client = CreateClient(cfg);
private readonly string? _bucket = cfg["Oss:Bucket"];
/// 配置完整(AccessKeyId / AccessKeySecret / Endpoint / Bucket 均非空)时才能创建客户端
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;
///
/// 上传到 OSS,返回 StoredFile。
/// Key 使用 "uploads/{subDir}/{yyyyMM}/{guid}{ext}"(OSS 对象键不带前导斜杠)。
/// URL 返回后端代理相对路径 /api/files/{key},由 FilesController 实时从私有桶拉流返回,
/// 保证 Bucket 可保持私有(身份证等敏感信息不暴露公网)。
///
public Task 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}"));
}
/// 按 key 删除 OSS 对象(兼容本地存储带前导斜杠的 key 格式)
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;
}
/// 从私有桶拉取对象流(供代理接口使用),不存在返回 null
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);
}
}
/// 生成 10 分钟有效的私有桶临时访问 URL(供 OCR 等第三方服务拉取图片),失败返回 null
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;
}
}
}