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