Platform upgrade: login auth fix, OCR/OSS upload, system menus and dicts, weather dashboard, weighing and invoice modules

This commit is contained in:
2026-08-14 01:12:33 +08:00
parent c377421a3e
commit 8099ebae0a
75 changed files with 5611 additions and 473 deletions
@@ -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,返回 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 存储。授权验证(AccessKeyId/SecretEndpointBucket后期配置提供
/// 未配置调用会抛出 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;
}
}
}