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
@@ -0,0 +1,192 @@
using System.Text.Json;
namespace AgriculturalPlatform.Api.Services;
/// <summary>
/// 阿里云市场身份证 OCR 识别服务。
/// 接口:POST https://swcardpack.market.alicloudapi.com/ocr/idcard
/// 认证:请求头 Authorization: APPCODE &lt;AppCode&gt;(无需 AppKey/AppSecret
/// 传输:优先 image_urlOSS 私有桶 10 分钟签名 URL,实测兼容性最好);
/// 本地存储或签名失败时回退 image_base64。
/// 返回:result 容器(兼容 data),成功码 0000/1000/0/200 等;
/// 正面字段 name/number/sex/address,反面 authority/valid_date,字段名做多别名兼容。
/// 配置:appsettings.json 的 Ocr 节点(Provider=aliyun、AppCode、Url、MaxBytes)。
/// </summary>
public sealed class AliyunOcrService(IConfiguration cfg, IHttpClientFactory httpFactory, IFileStorage storage) : IOcrService
{
public async Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg", string? storageKey = null)
{
var appCode = cfg["Ocr:AppCode"];
var url = cfg["Ocr:Url"] ?? "https://swcardpack.market.alicloudapi.com/ocr/idcard";
if (string.IsNullOrWhiteSpace(appCode))
return new IdCardOcrResult(Success: false,
Message: "未配置阿里云 OCR AppCodeappsettings.json 的 Ocr:AppCode");
var client = httpFactory.CreateClient("ocr");
// 方式一:image_urlOSS 签名 URL),可绕过接口对 base64 的兼容问题
if (!string.IsNullOrWhiteSpace(storageKey))
{
var presigned = storage.GeneratePresignedUrl(storageKey);
if (!string.IsNullOrWhiteSpace(presigned))
{
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["image_url"] = presigned })
};
req.Headers.TryAddWithoutValidation("Authorization", $"APPCODE {appCode}");
try
{
using var resp = await client.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (resp.IsSuccessStatusCode) return ParseResult(body, side);
if (!TryParseResult(body, out var r)) // 业务层报错(8888 等)也透传给用户
return r;
}
catch (Exception ex)
{
return new IdCardOcrResult(Success: false, Message: $"OCR 调用异常(image_url):{ex.Message}");
}
}
}
// 方式二:image_base64 回退
if (image.CanSeek) image.Position = 0;
await using var buffer = new MemoryStream();
await image.CopyToAsync(buffer);
var maxBytes = cfg.GetValue("Ocr:MaxBytes", 4L * 1024 * 1024);
if (buffer.Length > maxBytes)
return new IdCardOcrResult(Success: false, Message: "图片过大(超过 4MB),请压缩后重试");
var base64 = Convert.ToBase64String(buffer.ToArray());
using var req2 = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["image_base64"] = base64 })
};
req2.Headers.TryAddWithoutValidation("Authorization", $"APPCODE {appCode}");
try
{
using var resp2 = await client.SendAsync(req2);
var body2 = await resp2.Content.ReadAsStringAsync();
if (!resp2.IsSuccessStatusCode)
return new IdCardOcrResult(Success: false,
Message: $"OCR 接口返回 HTTP {(int)resp2.StatusCode}{Truncate(body2, 200)}");
return ParseResult(body2, side);
}
catch (Exception ex)
{
return new IdCardOcrResult(Success: false, Message: $"OCR 调用异常:{ex.Message}");
}
}
/// <summary>尝试解析接口返回;无法解析出 JSON 时返回 false</summary>
private static bool TryParseResult(string json, out IdCardOcrResult result)
{
try
{
using var doc = JsonDocument.Parse(json);
result = ParseDoc(doc.RootElement, "");
return true;
}
catch (JsonException)
{
result = new IdCardOcrResult(Success: false, Message: "OCR 返回数据无法解析");
return false;
}
}
/// <summary>解析接口返回 JSON(兼容 result/data 容器、多字段别名)</summary>
private static IdCardOcrResult ParseResult(string json, string side)
{
try
{
using var doc = JsonDocument.Parse(json);
return ParseDoc(doc.RootElement, side);
}
catch (JsonException)
{
return new IdCardOcrResult(Success: false, Message: "OCR 返回数据无法解析");
}
}
private static IdCardOcrResult ParseDoc(JsonElement root, string side)
{
var data = root;
if (root.ValueKind == JsonValueKind.Object)
{
if (root.TryGetProperty("result", out var r) && r.ValueKind == JsonValueKind.Object)
data = r;
else if (root.TryGetProperty("data", out var d) && d.ValueKind == JsonValueKind.Object)
data = d;
}
// 接口级失败判定(success=false 或 result_code/code 非成功码)
var ok = GetBool(root, "success") ?? GetBool(root, "ok") ?? true;
var code = GetStr(root, "result_code") ?? GetStr(root, "code") ?? GetStr(root, "status") ?? "";
var msg = GetStr(root, "result_msg") ?? GetStr(root, "msg")
?? GetStr(root, "message") ?? GetStr(root, "errmsg") ?? "";
if (!ok && !IsSuccessCode(code))
return new IdCardOcrResult(Success: false,
Message: string.IsNullOrWhiteSpace(msg) ? $"OCR 识别失败(code={code}" : $"{msg}code={code}");
var name = GetFirst(data, "name", "realName", "trueName", "姓名");
var idCard = GetFirst(data, "number", "id", "idCard", "idNumber", "cardNo", "num", "身份证号");
var gender = NormalizeGender(GetFirst(data, "sex", "gender", "性别"));
var address = GetFirst(data, "address", "addr", "住址");
var authority = GetFirst(data, "authority", "issuingAuthority", "issueOrg", "signOrg", "签发机关");
var validDate = GetFirst(data, "valid_date", "validDate", "validity", "expiry", "有效期");
// 正面看姓名/证件号,反面看签发机关/有效期;任一命中即视为识别成功
var success = side == "back"
? !string.IsNullOrWhiteSpace(authority) || !string.IsNullOrWhiteSpace(validDate)
: !string.IsNullOrWhiteSpace(name) || !string.IsNullOrWhiteSpace(idCard);
return new IdCardOcrResult(
name, idCard, gender, address, "", "",
success, success ? "识别完成" : "未识别到有效信息,请确保图片清晰、证件完整");
}
private static bool IsSuccessCode(string code) =>
code is "0000" or "0" or "200" or "1000" or "000000" or "1";
private static bool? GetBool(JsonElement e, string key) =>
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(key, out var v)
? v.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String when bool.TryParse(v.GetString(), out var b) => b,
_ => null
}
: null;
private static string? GetStr(JsonElement e, string key) =>
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
private static string GetFirst(JsonElement e, params string[] keys)
{
if (e.ValueKind != JsonValueKind.Object) return "";
foreach (var k in keys)
if (e.TryGetProperty(k, out var v) && v.ValueKind is JsonValueKind.String)
{
var s = v.GetString()?.Trim();
if (!string.IsNullOrWhiteSpace(s)) return s!;
}
return "";
}
private static string NormalizeGender(string? v) => v switch
{
null or "" => "",
"1" or "M" or "male" or "Male" or "男" => "男",
"2" or "F" or "female" or "Female" or "女" => "女",
_ => v
};
private static string Truncate(string s, int max) =>
s.Length <= max ? s : s[..max] + "...";
}
@@ -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;
}
}
}
@@ -17,6 +17,7 @@ public class JwtService(IConfiguration config)
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new("uid", user.Id.ToString()),
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.Role, user.Role.ToString()),
new("realName", user.RealName),
@@ -23,6 +23,7 @@ public class NumberGenerator(AppDbContext db)
"PK" => await db.Invoices
.Where(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd && x.BatchNo != "")
.Select(x => x.BatchNo).Distinct().CountAsync(),
"GH" => await db.PurchaseTransfers.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
_ => 0
};
seq = count + 1;
@@ -17,14 +17,17 @@ public record IdCardOcrResult(
/// </summary>
public interface IOcrService
{
/// <summary>识别身份证图片,side: front(正面)/ back(反面)</summary>
Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg");
/// <summary>
/// 识别身份证图片,side: front(正面)/ back(反面)。
/// storageKey 为已保存文件的存储 key(OSS 模式下可据此生成临时访问 URL 传给识别服务)。
/// </summary>
Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg", string? storageKey = null);
}
/// <summary>默认 OCR 实现:未配置凭证时给出友好提示,图片仍会正常保存为附件</summary>
public sealed class DisabledOcrService(IConfiguration cfg) : IOcrService
{
public Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg")
public Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg", string? storageKey = null)
{
var message = cfg["Ocr:DisabledMessage"]
?? "未配置 OCR 识别服务(待接入阿里云/百度 OCR 凭证),图片已保存为附件,识别字段请手动填写";