新增农户功能升级:身份证OCR/OSS上传、头像采集、同名村检测、Region热度排序及表单体验优化

- Region 表新增热度列,热门省份优先显示
- 区县下拉按地级市分组、已录区县优先、支持全省搜索
- 组选择内置一组至二十组
- 银行卡号独立行+粗体预览,开户行独立行
- 新增农户类型:农户/个体户/合作社/经营集体/公司
- 身份证正反面支持高拍仪/摄像头OCR与手机扫码上传,附件存本地/OSS
- 新增农户头像采集组件(摄像头/本地上传),预留人脸识别收购扩展
- 同名村弹窗提醒(防选错乡镇)
- 编辑窗体禁止遮罩/Esc误关,整体加宽适配高频操作
- 修复二维码局域网手机无法访问(监听0.0.0.0、局域网IP识别、Vite代理兜底)
This commit is contained in:
2026-08-13 16:07:35 +08:00
commit c377421a3e
93 changed files with 12934 additions and 0 deletions
@@ -0,0 +1,229 @@
using System.Net.NetworkInformation;
using System.Net.Sockets;
using AgriculturalPlatform.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace AgriculturalPlatform.Api.Controllers;
/// <summary>
/// 附件上传(身份证正反面):高拍仪/本地拍照直传 + 手机扫码上传。
/// 图片保存为附件(本地存储,OSS 配置后自动切换),并返回 OCR 识别结果。
/// </summary>
[ApiController]
[Route("api/uploads")]
public class UploadsController(
IFileStorage storage,
IOcrService ocr,
MobileUploadSessionStore sessions,
IConfiguration cfg) : ControllerBase
{
/// <summary>
/// 高拍仪/本地拍照:上传身份证单面图片(side = front 正面 | back 反面),
/// 返回附件地址(url/key)与 OCR 识别结果。
/// </summary>
[HttpPost("idcard")]
[Authorize]
[RequestSizeLimit(15 * 1024 * 1024)]
public async Task<ActionResult> UploadIdCard(IFormFile? file, [FromForm] string? side)
{
if (file is null || file.Length == 0)
return BadRequest(new { message = "请先拍摄或选择身份证图片" });
side = side == "back" ? "back" : "front";
await using var ms = new MemoryStream();
await file.CopyToAsync(ms);
ms.Position = 0;
StoredFile saved;
try
{
saved = await storage.SaveAsync("idcard", ms, file.FileName, file.ContentType);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
var result = await ocr.RecognizeAsync(ms, side, file.FileName);
return Ok(new { url = saved.Url, key = saved.Key, side, ocr = result });
}
/// <summary>
/// 手机传图:创建上传会话,返回二维码内容(手机访问的 URL)。
/// 电脑端展示二维码,手机扫码后访问 /mobile-upload.html?code=xxx 上传身份证正反面。
/// 注意:端口固定取 Server:LanPort(默认 5246),不依赖请求 Host,
/// 因为经 Vite 代理(5173)访问时 Host 端口可能是前端端口,手机无法访问该端口。
/// </summary>
[HttpGet("qr")]
[Authorize]
public ActionResult<object> CreateQr()
{
var code = sessions.Create();
// 优先取 TCP 连接本端地址(后端监听 0.0.0.0 时即手机可直达的局域网 IP),回环则回退枚举网卡
var localIp = HttpContext.Connection.LocalIpAddress?.ToString() ?? "";
var lanIp = localIp.StartsWith("127.") || localIp.StartsWith("::1") || localIp.StartsWith("::ffff:127.")
? GetLanIp()
: localIp;
var port = cfg["Server:LanPort"] ?? "5246";
var url = $"http://{lanIp}:{port}/mobile-upload.html?code={code}";
return Ok(new { code, url, expiresAt = DateTime.Now.AddMinutes(10) });
}
/// <summary>
/// 农户头像上传:摄像头/本地图片直传,返回附件地址。
/// 图片保存于 avatar 目录,未来可用于人脸识别收购(届时在识别服务中引用 AvatarUrl)。
/// </summary>
[HttpPost("avatar")]
[Authorize]
[RequestSizeLimit(15 * 1024 * 1024)]
public async Task<ActionResult> UploadAvatar(IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { message = "请先拍摄或选择头像图片" });
await using var ms = new MemoryStream();
await file.CopyToAsync(ms);
ms.Position = 0;
StoredFile saved;
try
{
saved = await storage.SaveAsync("avatar", ms, file.FileName, file.ContentType);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
return Ok(new { url = saved.Url, key = saved.Key });
}
/// <summary>手机上传身份证正反面(front/back 两张图,允许只传一张)</summary>
[HttpPost("mobile")]
[AllowAnonymous]
[RequestSizeLimit(30 * 1024 * 1024)]
public async Task<ActionResult> MobileUpload(
[FromForm] string? code,
IFormFile? front,
IFormFile? back)
{
var session = sessions.Get(code ?? "");
if (session is null || session.Done)
return BadRequest(new { message = "二维码已失效,请回到电脑端重新生成" });
if (front is not null && front.Length > 0)
{
await using var ms = new MemoryStream();
await front.CopyToAsync(ms);
ms.Position = 0;
try
{
session.Front = await storage.SaveAsync("idcard", ms, front.FileName, front.ContentType);
session.FrontOcr = await ocr.RecognizeAsync(ms, "front", front.FileName);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
if (back is not null && back.Length > 0)
{
await using var ms = new MemoryStream();
await back.CopyToAsync(ms);
ms.Position = 0;
try
{
session.Back = await storage.SaveAsync("idcard", ms, back.FileName, back.ContentType);
session.BackOcr = await ocr.RecognizeAsync(ms, "back", back.FileName);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
session.Done = true;
return Ok(new { ok = true, message = "上传成功,请回到电脑端查看" });
}
/// <summary>电脑端轮询手机上传结果(done=false 表示手机还未传完)</summary>
[HttpGet("mobile/{code}/result")]
[Authorize]
public ActionResult<object> MobileResult(string code)
{
var session = sessions.Get(code);
if (session is null)
return NotFound(new { message = "二维码已失效,请重新生成" });
if (!session.Done)
return Ok(new { done = false });
var ocr = MergeOcr(session.FrontOcr, session.BackOcr);
return Ok(new
{
done = true,
frontUrl = session.Front?.Url,
frontKey = session.Front?.Key,
backUrl = session.Back?.Url,
backKey = session.Back?.Key,
ocr
});
}
/// <summary>合并正反面 OCR 结果(正面的姓名/证件号/性别/住址为主)</summary>
private static IdCardOcrResult MergeOcr(IdCardOcrResult? front, IdCardOcrResult? back)
{
var any = front is { Success: true } || back is { Success: true };
var f = front ?? new IdCardOcrResult();
return new IdCardOcrResult(
f.Name, f.IdCard, f.Gender, f.Address, f.BankName, f.BankAccount,
any, any ? "识别完成" : (f.Message ?? "未配置 OCR 服务"));
}
/// <summary>
/// 获取本机局域网 IP,供手机扫码访问。
/// 优先选择物理网卡(排除 VMware/VirtualBox/Hyper-V/WSL/VPN 等虚拟网卡),
/// 避免多网卡环境(虚拟机、VPN、Docker)下拿到错误的 IP 导致手机无法访问。
/// </summary>
private static string GetLanIp()
{
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
if (IsVirtualAdapter(ni)) continue;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
var ip = ua.Address.ToString();
if (ip.StartsWith("192.168.") || ip.StartsWith("10.") || ip.StartsWith("172."))
return ip;
}
}
// 兜底:物理网卡找不到时,在虚拟网卡里再找一次
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
var ip = ua.Address.ToString();
if (ip.StartsWith("192.168.") || ip.StartsWith("10.") || ip.StartsWith("172."))
return ip;
}
}
return "127.0.0.1";
}
/// <summary>根据网卡描述判断是否为虚拟/隧道/环回网卡</summary>
private static bool IsVirtualAdapter(NetworkInterface ni)
{
var desc = (ni.Description ?? "").ToLowerInvariant();
return desc.Contains("virtual") || desc.Contains("vmware") || desc.Contains("virtualbox")
|| desc.Contains("hyper-v") || desc.Contains("tunnel") || desc.Contains("loopback")
|| desc.Contains("docker") || desc.Contains("wsl") || desc.Contains("tailscale")
|| desc.Contains("zerotier") || desc.Contains("vpn") || desc.Contains("tap-")
|| desc.Contains("tun-") || desc.Contains("hamachi");
}
}