新增农户功能升级:身份证OCR/OSS上传、头像采集、同名村检测、Region热度排序及表单体验优化
- Region 表新增热度列,热门省份优先显示 - 区县下拉按地级市分组、已录区县优先、支持全省搜索 - 组选择内置一组至二十组 - 银行卡号独立行+粗体预览,开户行独立行 - 新增农户类型:农户/个体户/合作社/经营集体/公司 - 身份证正反面支持高拍仪/摄像头OCR与手机扫码上传,附件存本地/OSS - 新增农户头像采集组件(摄像头/本地上传),预留人脸识别收购扩展 - 同名村弹窗提醒(防选错乡镇) - 编辑窗体禁止遮罩/Esc误关,整体加宽适配高频操作 - 修复二维码局域网手机无法访问(监听0.0.0.0、局域网IP识别、Vite代理兜底)
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
using System.Security.Claims;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>从 JWT 声明中解析的当前登录用户</summary>
|
||||
public class CurrentUser
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Username { get; init; } = string.Empty;
|
||||
public string RealName { get; init; } = string.Empty;
|
||||
public UserRole Role { get; init; }
|
||||
public int? OrgId { get; init; }
|
||||
|
||||
public bool IsSuperAdmin => Role == UserRole.SuperAdmin;
|
||||
}
|
||||
|
||||
public class CurrentUserService(IHttpContextAccessor accessor)
|
||||
{
|
||||
public CurrentUser? Get()
|
||||
{
|
||||
var principal = accessor.HttpContext?.User;
|
||||
if (principal is null) return null;
|
||||
var idClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!int.TryParse(idClaim, out var id)) return null;
|
||||
|
||||
Enum.TryParse<UserRole>(principal.FindFirstValue(ClaimTypes.Role), out var role);
|
||||
int? orgId = int.TryParse(principal.FindFirstValue("orgId"), out var o) ? o : null;
|
||||
|
||||
return new CurrentUser
|
||||
{
|
||||
Id = id,
|
||||
Username = principal.FindFirstValue(ClaimTypes.Name) ?? "",
|
||||
RealName = principal.FindFirstValue("realName") ?? "",
|
||||
Role = role,
|
||||
OrgId = orgId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 数据权限:根据当前用户角色计算其可见的组织范围
|
||||
/// - 系统管理员:全部
|
||||
/// - 公司管理员:本公司 + 旗下收购站
|
||||
/// - 收购站员工:本站
|
||||
/// - 收购个体:本人
|
||||
/// </summary>
|
||||
public class DataScopeService(AppDbContext db)
|
||||
{
|
||||
/// <summary>返回可见组织 ID 集合;null 表示全部可见</summary>
|
||||
public async Task<HashSet<int>?> GetVisibleOrgIdsAsync(CurrentUser? user)
|
||||
{
|
||||
if (user is null) return new HashSet<int>();
|
||||
if (user.IsSuperAdmin) return null;
|
||||
|
||||
var ids = new HashSet<int>();
|
||||
switch (user.Role)
|
||||
{
|
||||
case UserRole.CompanyAdmin:
|
||||
if (user.OrgId.HasValue)
|
||||
{
|
||||
ids.Add(user.OrgId.Value);
|
||||
var stationIds = await db.Organizations
|
||||
.Where(o => o.ParentId == user.OrgId && o.Type == OrgType.Station)
|
||||
.Select(o => o.Id).ToListAsync();
|
||||
ids.UnionWith(stationIds);
|
||||
}
|
||||
break;
|
||||
case UserRole.StationStaff:
|
||||
case UserRole.Individual:
|
||||
if (user.OrgId.HasValue) ids.Add(user.OrgId.Value);
|
||||
break;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>已保存文件的存储 key 与访问地址</summary>
|
||||
public record StoredFile(string Key, string Url);
|
||||
|
||||
/// <summary>
|
||||
/// 附件文件存储抽象。
|
||||
/// 当前默认实现为本地存储(wwwroot/uploads);
|
||||
/// 阿里云 OSS 授权验证配置好后(appsettings.json 的 Oss 节点),
|
||||
/// 将 Storage:UseOss 设为 true 即可切换到 OSS。
|
||||
/// </summary>
|
||||
public interface IFileStorage
|
||||
{
|
||||
/// <summary>保存文件,返回存储 key 与访问 URL</summary>
|
||||
Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType);
|
||||
|
||||
/// <summary>按 key 删除文件(本地/OSS 通用)</summary>
|
||||
Task DeleteAsync(string key);
|
||||
}
|
||||
|
||||
/// <summary>本地磁盘存储(wwwroot/uploads),URL 即相对路径,由静态文件中间件直接访问</summary>
|
||||
public sealed class LocalFileStorage(IWebHostEnvironment env) : IFileStorage
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
env.WebRootPath ?? Path.Combine(env.ContentRootPath, "wwwroot"), "uploads");
|
||||
|
||||
public async Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType)
|
||||
{
|
||||
var dateDir = DateTime.Now.ToString("yyyyMM");
|
||||
var dir = Path.Combine(_root, subDir, dateDir);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var ext = Path.GetExtension(fileName);
|
||||
if (string.IsNullOrWhiteSpace(ext)) ext = ".jpg";
|
||||
if (ext.Length > 10) ext = ".jpg";
|
||||
var name = $"{Guid.NewGuid():N}{ext}";
|
||||
var full = Path.Combine(dir, name);
|
||||
|
||||
await using var fs = File.Create(full);
|
||||
await stream.CopyToAsync(fs);
|
||||
|
||||
var key = $"/uploads/{subDir}/{dateDir}/{name}";
|
||||
return new StoredFile(key, key);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || !key.StartsWith("/uploads/")) return Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
var full = Path.Combine(_root, key.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(full)) File.Delete(full);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云 OSS 存储。授权验证(AccessKeyId/Secret、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"]);
|
||||
|
||||
public Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType)
|
||||
=> throw new InvalidOperationException(
|
||||
"阿里云 OSS 尚未配置授权验证,请在 appsettings.json 的 Oss 节点填写 AccessKeyId / AccessKeySecret / Endpoint / Bucket 后重启服务");
|
||||
|
||||
public Task DeleteAsync(string key)
|
||||
=> throw new InvalidOperationException("阿里云 OSS 尚未配置授权验证,暂不支持删除");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
public class JwtService(IConfiguration config)
|
||||
{
|
||||
private readonly string _key = config["Jwt:Key"] ?? "AgriculturalPlatform-Default-Jwt-Secret-Key-2026!";
|
||||
private readonly string _issuer = config["Jwt:Issuer"] ?? "AgriculturalPlatform";
|
||||
private readonly string _audience = config["Jwt:Audience"] ?? "AgriculturalPlatform.Client";
|
||||
|
||||
public string Generate(User user)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(ClaimTypes.Name, user.Username),
|
||||
new(ClaimTypes.Role, user.Role.ToString()),
|
||||
new("realName", user.RealName),
|
||||
new("orgId", user.OrgId?.ToString() ?? "")
|
||||
};
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _issuer,
|
||||
audience: _audience,
|
||||
claims: claims,
|
||||
expires: DateTime.Now.AddDays(7),
|
||||
signingCredentials: creds);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>手机传图上传会话(内存存储,10 分钟过期)</summary>
|
||||
public sealed class MobileUploadSession
|
||||
{
|
||||
public string Code { get; init; } = string.Empty;
|
||||
public DateTime CreatedAt { get; init; } = DateTime.Now;
|
||||
public StoredFile? Front { get; set; }
|
||||
public StoredFile? Back { get; set; }
|
||||
public IdCardOcrResult? FrontOcr { get; set; }
|
||||
public IdCardOcrResult? BackOcr { get; set; }
|
||||
public bool Done { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>手机传图上传会话存储(单机内存版,后续可换 Redis)</summary>
|
||||
public sealed class MobileUploadSessionStore
|
||||
{
|
||||
private static readonly TimeSpan Expire = TimeSpan.FromMinutes(10);
|
||||
private readonly ConcurrentDictionary<string, MobileUploadSession> _sessions = new();
|
||||
|
||||
public string Create()
|
||||
{
|
||||
Cleanup();
|
||||
var code = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
|
||||
_sessions[code] = new MobileUploadSession { Code = code };
|
||||
return code;
|
||||
}
|
||||
|
||||
public MobileUploadSession? Get(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code)) return null;
|
||||
return _sessions.TryGetValue(code.Trim().ToUpperInvariant(), out var s) ? s : null;
|
||||
}
|
||||
|
||||
public void Remove(string code) => _sessions.TryRemove(code, out _);
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
foreach (var kv in _sessions)
|
||||
{
|
||||
if (now - kv.Value.CreatedAt > Expire) _sessions.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>业务单号生成:前缀 + 日期 + 当日序号(如 CG20260812-0001)</summary>
|
||||
public class NumberGenerator(AppDbContext db)
|
||||
{
|
||||
public async Task<string> NextAsync(string prefix, DateOnly? date = null)
|
||||
{
|
||||
var day = date ?? DateOnly.FromDateTime(DateTime.Now);
|
||||
var seq = 1;
|
||||
try
|
||||
{
|
||||
// 从各单据表按当日已存在数量生成序号
|
||||
var todayStart = day.ToDateTime(TimeOnly.MinValue);
|
||||
var todayEnd = todayStart.AddDays(1);
|
||||
var count = prefix switch
|
||||
{
|
||||
"CG" => await db.PurchaseOrders.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
|
||||
"ZF" => await db.PaymentRecords.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
|
||||
"FP" => await db.Invoices.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
|
||||
"PK" => await db.Invoices
|
||||
.Where(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd && x.BatchNo != "")
|
||||
.Select(x => x.BatchNo).Distinct().CountAsync(),
|
||||
_ => 0
|
||||
};
|
||||
seq = count + 1;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 数据库不可用时使用随机序号
|
||||
}
|
||||
|
||||
return $"{prefix}{day:yyyyMMdd}-{seq:D4}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>身份证 OCR 识别结果(未识别成功时 Success=false 并给出原因)</summary>
|
||||
public record IdCardOcrResult(
|
||||
string Name = "",
|
||||
string IdCard = "",
|
||||
string Gender = "",
|
||||
string Address = "",
|
||||
string BankName = "",
|
||||
string BankAccount = "",
|
||||
bool Success = false,
|
||||
string Message = "");
|
||||
|
||||
/// <summary>
|
||||
/// OCR 识别服务抽象。当前默认实现为 DisabledOcrService(仅占位);
|
||||
/// 后续接入阿里云/百度 OCR 时,实现本接口并在 Program.cs 替换注册即可,调用方无需改动。
|
||||
/// </summary>
|
||||
public interface IOcrService
|
||||
{
|
||||
/// <summary>识别身份证图片,side: front(正面)/ back(反面)</summary>
|
||||
Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg");
|
||||
}
|
||||
|
||||
/// <summary>默认 OCR 实现:未配置凭证时给出友好提示,图片仍会正常保存为附件</summary>
|
||||
public sealed class DisabledOcrService(IConfiguration cfg) : IOcrService
|
||||
{
|
||||
public Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg")
|
||||
{
|
||||
var message = cfg["Ocr:DisabledMessage"]
|
||||
?? "未配置 OCR 识别服务(待接入阿里云/百度 OCR 凭证),图片已保存为附件,识别字段请手动填写";
|
||||
return Task.FromResult(new IdCardOcrResult(Success: false, Message: message));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user