Platform upgrade: login auth fix, OCR/OSS upload, system menus and dicts, weather dashboard, weighing and invoice modules
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
using System.Net;
|
||||
using Aliyun.OSS;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>已保存文件的存储 key 与访问地址</summary>
|
||||
@@ -16,6 +19,12 @@ public interface IFileStorage
|
||||
|
||||
/// <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>
|
||||
@@ -57,23 +66,123 @@ public sealed class LocalFileStorage(IWebHostEnvironment env) : IFileStorage
|
||||
}
|
||||
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 存储。授权验证(AccessKeyId/Secret、Endpoint、Bucket)后期配置提供;
|
||||
/// 未配置前调用会抛出 InvalidOperationException,由接口统一转为友好提示。
|
||||
/// 阿里云 OSS 存储。配置见 appsettings.json 的 Oss 节点(AccessKeyId / AccessKeySecret / Endpoint / Bucket);
|
||||
/// 未配置时调用会抛出 InvalidOperationException,由接口统一转为友好提示。
|
||||
/// </summary>
|
||||
public sealed class OssFileStorage(IConfiguration cfg) : IFileStorage
|
||||
{
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(cfg["Oss:AccessKeyId"])
|
||||
&& !string.IsNullOrWhiteSpace(cfg["Oss:AccessKeySecret"])
|
||||
&& !string.IsNullOrWhiteSpace(cfg["Oss:Bucket"]);
|
||||
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)
|
||||
=> throw new InvalidOperationException(
|
||||
"阿里云 OSS 尚未配置授权验证,请在 appsettings.json 的 Oss 节点填写 AccessKeyId / AccessKeySecret / Endpoint / Bucket 后重启服务");
|
||||
{
|
||||
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)
|
||||
=> throw new InvalidOperationException("阿里云 OSS 尚未配置授权验证,暂不支持删除");
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user