using System.Net.NetworkInformation; using System.Net.Sockets; using AgriculturalPlatform.Api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace AgriculturalPlatform.Api.Controllers; /// /// 附件上传(身份证正反面):高拍仪/本地拍照直传 + 手机扫码上传。 /// 图片保存为附件(本地存储,OSS 配置后自动切换),并返回 OCR 识别结果。 /// [ApiController] [Route("api/uploads")] public class UploadsController( IFileStorage storage, IOcrService ocr, MobileUploadSessionStore sessions, IConfiguration cfg) : ControllerBase { /// /// 高拍仪/本地拍照:上传身份证单面图片(side = front 正面 | back 反面), /// 返回附件地址(url/key)与 OCR 识别结果。 /// [HttpPost("idcard")] [Authorize] [RequestSizeLimit(15 * 1024 * 1024)] public async Task 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 }); } /// /// 手机传图:创建上传会话,返回二维码内容(手机访问的 URL)。 /// 电脑端展示二维码,手机扫码后访问 /mobile-upload.html?code=xxx 上传身份证正反面。 /// 注意:端口固定取 Server:LanPort(默认 5246),不依赖请求 Host, /// 因为经 Vite 代理(5173)访问时 Host 端口可能是前端端口,手机无法访问该端口。 /// [HttpGet("qr")] [Authorize] public ActionResult 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) }); } /// /// 农户头像上传:摄像头/本地图片直传,返回附件地址。 /// 图片保存于 avatar 目录,未来可用于人脸识别收购(届时在识别服务中引用 AvatarUrl)。 /// [HttpPost("avatar")] [Authorize] [RequestSizeLimit(15 * 1024 * 1024)] public async Task 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 }); } /// 手机上传身份证正反面(front/back 两张图,允许只传一张) [HttpPost("mobile")] [AllowAnonymous] [RequestSizeLimit(30 * 1024 * 1024)] public async Task 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 = "上传成功,请回到电脑端查看" }); } /// 电脑端轮询手机上传结果(done=false 表示手机还未传完) [HttpGet("mobile/{code}/result")] [Authorize] public ActionResult 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 }); } /// 合并正反面 OCR 结果(正面的姓名/证件号/性别/住址为主) 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 服务")); } /// /// 获取本机局域网 IP,供手机扫码访问。 /// 优先选择物理网卡(排除 VMware/VirtualBox/Hyper-V/WSL/VPN 等虚拟网卡), /// 避免多网卡环境(虚拟机、VPN、Docker)下拿到错误的 IP 导致手机无法访问。 /// 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"; } /// 根据网卡描述判断是否为虚拟/隧道/环回网卡 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"); } }