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] + "...";
}