224 lines
8.7 KiB
C#
224 lines
8.7 KiB
C#
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 });
|
|
}
|
|
|
|
// 反面仅存档至 OSS,不调用 OCR(OCR 只识别正面)
|
|
IdCardOcrResult? result = null;
|
|
if (side == "front")
|
|
result = await ocr.RecognizeAsync(ms, side, file.FileName, saved.Key);
|
|
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, session.Front.Key);
|
|
}
|
|
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
|
|
{
|
|
// 反面仅存档,不调用 OCR
|
|
session.Back = await storage.SaveAsync("idcard", ms, back.FileName, back.ContentType);
|
|
session.BackOcr = null;
|
|
}
|
|
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 });
|
|
|
|
// 反面不再识别,仅返回正面 OCR 结果(可能为 null)
|
|
return Ok(new
|
|
{
|
|
done = true,
|
|
frontUrl = session.Front?.Url,
|
|
frontKey = session.Front?.Key,
|
|
backUrl = session.Back?.Url,
|
|
backKey = session.Back?.Key,
|
|
ocr = session.FrontOcr
|
|
});
|
|
}
|
|
|
|
/// <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");
|
|
}
|
|
}
|