Platform upgrade: login auth fix, OCR/OSS upload, system menus and dicts, weather dashboard, weighing and invoice modules
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.14.1" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
/// <summary>关于信息</summary>
|
||||
[ApiController]
|
||||
[Route("api/about")]
|
||||
public class AboutController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public ActionResult<AboutDto> Get() => Ok(new AboutDto(
|
||||
AppName: "农易富农产品收购交易平台",
|
||||
Version: "2.0.0",
|
||||
Copyright: "Copyright © 2026 bbitcn.com 版权所有",
|
||||
Company: "百博信息技术有限公司",
|
||||
License: "企业版授权 · 单组织部署许可",
|
||||
Description: "面向农产品收购企业的数字化管理平台,覆盖农户档案、过磅称重、收购结算、票据打印、通知公告等功能。",
|
||||
BuiltAt: new DateTime(2026, 8, 13)));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Security.Claims;
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
/// <summary>通知公告</summary>
|
||||
[ApiController]
|
||||
[Route("api/announcements")]
|
||||
[Authorize]
|
||||
public class AnnouncementsController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
private int UserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "0");
|
||||
|
||||
/// <summary>我的公告列表(含已读状态、未读数)</summary>
|
||||
[HttpGet("my")]
|
||||
public async Task<ActionResult> My([FromQuery] int page = 1, [FromQuery] int pageSize = 10)
|
||||
{
|
||||
var uid = UserId;
|
||||
var query = db.Announcements.AsNoTracking();
|
||||
// 范围过滤:空范围=全员可见;含本人组织
|
||||
var orgId = await db.Users.Where(u => u.Id == uid).Select(u => u.OrgId).FirstOrDefaultAsync();
|
||||
var queryFiltered = query.Where(a => a.ScopeOrgIds == "" || (orgId != null && a.ScopeOrgIds.Split(',', StringSplitOptions.RemoveEmptyEntries).Contains(orgId.ToString())));
|
||||
|
||||
var total = await queryFiltered.CountAsync();
|
||||
var items = await queryFiltered.OrderByDescending(a => a.IsPinned).ThenByDescending(a => a.PublishedAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(a => new
|
||||
{
|
||||
a.Id, a.Title, a.Content, a.Type, a.ScopeOrgIds, a.IsPinned,
|
||||
PublisherName = a.Publisher != null ? a.Publisher.RealName : "",
|
||||
a.PublishedAt, a.CreatedAt,
|
||||
IsRead = db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var unread = await queryFiltered
|
||||
.CountAsync(a => !db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid));
|
||||
return Ok(new { items, total, unread });
|
||||
}
|
||||
|
||||
/// <summary>未读公告数(顶栏徽标)</summary>
|
||||
[HttpGet("unread-count")]
|
||||
public async Task<ActionResult> UnreadCount()
|
||||
{
|
||||
var uid = UserId;
|
||||
var orgId = await db.Users.Where(u => u.Id == uid).Select(u => u.OrgId).FirstOrDefaultAsync();
|
||||
var count = await db.Announcements.CountAsync(a =>
|
||||
(a.ScopeOrgIds == "" || (orgId != null && a.ScopeOrgIds.Split(',', StringSplitOptions.RemoveEmptyEntries).Contains(orgId.ToString())))
|
||||
&& !db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid));
|
||||
return Ok(new { count });
|
||||
}
|
||||
|
||||
/// <summary>公告详情(标记已读)</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<ActionResult> Get(int id)
|
||||
{
|
||||
var uid = UserId;
|
||||
var a = await db.Announcements.Include(x => x.Publisher).FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (a is null) return NotFound();
|
||||
|
||||
// 标记已读
|
||||
if (!await db.AnnouncementReads.AnyAsync(r => r.AnnouncementId == id && r.UserId == uid))
|
||||
{
|
||||
db.AnnouncementReads.Add(new AnnouncementRead { AnnouncementId = id, UserId = uid, ReadAt = DateTime.Now });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var dto = new AnnouncementDto(a.Id, a.Title, a.Content, a.Type, a.ScopeOrgIds, a.IsPinned,
|
||||
a.Publisher?.RealName ?? "", a.PublishedAt, a.CreatedAt, true);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>全部公告(管理端)</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<AnnouncementDto>>> List(string? keyword, string? type, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
var uid = UserId;
|
||||
var query = db.Announcements.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(a => a.Title.Contains(keyword) || a.Content.Contains(keyword));
|
||||
if (!string.IsNullOrWhiteSpace(type))
|
||||
query = query.Where(a => a.Type == type);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query.OrderByDescending(a => a.PublishedAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(a => new AnnouncementDto(
|
||||
a.Id, a.Title, a.Content, a.Type, a.ScopeOrgIds, a.IsPinned,
|
||||
a.Publisher != null ? a.Publisher.RealName : "",
|
||||
a.PublishedAt, a.CreatedAt,
|
||||
db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid)))
|
||||
.ToListAsync();
|
||||
return Ok(new PagedResult<AnnouncementDto>(items, total));
|
||||
}
|
||||
|
||||
/// <summary>发布公告</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> Create(AnnouncementSaveRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Title))
|
||||
return BadRequest(new { message = "标题不能为空" });
|
||||
var a = new Announcement
|
||||
{
|
||||
Title = req.Title, Content = req.Content ?? "", Type = req.Type,
|
||||
ScopeOrgIds = req.ScopeOrgIds ?? "", IsPinned = req.IsPinned,
|
||||
PublisherId = UserId, PublishedAt = DateTime.Now, CreatedAt = DateTime.Now
|
||||
};
|
||||
db.Announcements.Add(a);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "发布成功", id = a.Id });
|
||||
}
|
||||
|
||||
/// <summary>修改公告</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult> Update(int id, AnnouncementSaveRequest req)
|
||||
{
|
||||
var a = await db.Announcements.FindAsync(id);
|
||||
if (a is null) return NotFound();
|
||||
a.Title = req.Title; a.Content = req.Content ?? ""; a.Type = req.Type;
|
||||
a.ScopeOrgIds = req.ScopeOrgIds ?? ""; a.IsPinned = req.IsPinned;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "保存成功" });
|
||||
}
|
||||
|
||||
/// <summary>删除公告</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var a = await db.Announcements.FindAsync(id);
|
||||
if (a is null) return NotFound();
|
||||
db.Announcements.Remove(a);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public class AuthController(AppDbContext db, JwtService jwt, CurrentUserService
|
||||
public async Task<ActionResult<UserInfoDto>> Me()
|
||||
{
|
||||
var cu = currentUser.Get()!;
|
||||
var user = await db.Users.Include(u => u.Org).FirstAsync(u => u.Id == cu.Id);
|
||||
var user = await db.Users.Include(u => u.Org).Include(u => u.SysRole).FirstAsync(u => u.Id == cu.Id);
|
||||
return Ok(BuildInfo(user));
|
||||
}
|
||||
|
||||
@@ -58,5 +58,5 @@ public class AuthController(AppDbContext db, JwtService jwt, CurrentUserService
|
||||
|
||||
private static UserInfoDto BuildInfo(User user) => new(
|
||||
user.Id, user.Username, user.RealName, user.Phone, user.Role.ToString(),
|
||||
user.OrgId, user.Org?.Name, user.Org?.Type.ToString(), user.IsActive);
|
||||
user.RoleId, user.SysRole?.Name, user.OrgId, user.Org?.Name, user.Org?.Type.ToString(), user.IsActive);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
/// <summary>数据字典</summary>
|
||||
[ApiController]
|
||||
[Route("api/dicts")]
|
||||
[Authorize]
|
||||
public class DictsController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>字典类型列表</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<DictDto>>> List(string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
var query = db.SysDicts.AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(d => d.Name.Contains(keyword) || d.Code.Contains(keyword));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query.OrderBy(d => d.Sort).ThenByDescending(d => d.Id)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(d => new DictDto(d.Id, d.Name, d.Code, d.Remark, d.IsSystem, d.Sort,
|
||||
d.Items.Count))
|
||||
.ToListAsync();
|
||||
return Ok(new PagedResult<DictDto>(items, total));
|
||||
}
|
||||
|
||||
/// <summary>字典全部(含项,供下拉缓存使用)</summary>
|
||||
[HttpGet("all")]
|
||||
public async Task<ActionResult> All()
|
||||
{
|
||||
var items = await db.SysDicts.AsNoTracking()
|
||||
.Include(d => d.Items.Where(i => i.Enabled))
|
||||
.OrderBy(d => d.Sort)
|
||||
.Select(d => new { d.Code, Items = d.Items.OrderBy(i => i.Sort)
|
||||
.Select(i => new { i.Label, i.Value, i.Ext, i.IsDefault }) })
|
||||
.ToListAsync();
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
/// <summary>字典详情(含字典项)</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<ActionResult<DictDetailDto>> Get(int id)
|
||||
{
|
||||
var dict = await db.SysDicts.Include(d => d.Items).FirstOrDefaultAsync(d => d.Id == id);
|
||||
if (dict is null) return NotFound();
|
||||
|
||||
var dto = new DictDto(dict.Id, dict.Name, dict.Code, dict.Remark, dict.IsSystem, dict.Sort, dict.Items.Count);
|
||||
var items = dict.Items.OrderBy(i => i.Sort)
|
||||
.Select(i => new DictItemDto(i.Id, i.DictId, i.Label, i.Value, i.Ext, i.IsDefault, i.Enabled, i.Sort))
|
||||
.ToList();
|
||||
return Ok(new DictDetailDto(dto, items));
|
||||
}
|
||||
|
||||
/// <summary>新增字典类型</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<DictDto>> Create(DictSaveRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Code))
|
||||
return BadRequest(new { message = "字典编码不能为空" });
|
||||
if (await db.SysDicts.AnyAsync(d => d.Code == req.Code.Trim()))
|
||||
return BadRequest(new { message = "字典编码已存在" });
|
||||
|
||||
var dict = new SysDict
|
||||
{
|
||||
Name = req.Name, Code = req.Code.Trim(), Remark = req.Remark, Sort = req.Sort, CreatedAt = DateTime.Now
|
||||
};
|
||||
db.SysDicts.Add(dict);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new DictDto(dict.Id, dict.Name, dict.Code, dict.Remark, dict.IsSystem, dict.Sort, 0));
|
||||
}
|
||||
|
||||
/// <summary>修改字典类型</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult> Update(int id, DictSaveRequest req)
|
||||
{
|
||||
var dict = await db.SysDicts.FindAsync(id);
|
||||
if (dict is null) return NotFound();
|
||||
if (dict.Code != req.Code.Trim() && await db.SysDicts.AnyAsync(d => d.Code == req.Code.Trim()))
|
||||
return BadRequest(new { message = "字典编码已存在" });
|
||||
|
||||
dict.Name = req.Name; dict.Code = req.Code.Trim(); dict.Remark = req.Remark; dict.Sort = req.Sort;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "保存成功" });
|
||||
}
|
||||
|
||||
/// <summary>删除字典类型(系统内置不可删除)</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var dict = await db.SysDicts.FindAsync(id);
|
||||
if (dict is null) return NotFound();
|
||||
if (dict.IsSystem) return BadRequest(new { message = "系统内置字典不可删除" });
|
||||
db.SysDicts.Remove(dict);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
|
||||
// ---------- 字典项 ----------
|
||||
|
||||
/// <summary>新增字典项</summary>
|
||||
[HttpPost("{dictId:int}/items")]
|
||||
public async Task<ActionResult<DictItemDto>> CreateItem(int dictId, DictItemSaveRequest req)
|
||||
{
|
||||
var dict = await db.SysDicts.FindAsync(dictId);
|
||||
if (dict is null) return NotFound();
|
||||
if (await db.SysDictItems.AnyAsync(i => i.DictId == dictId && i.Value == req.Value))
|
||||
return BadRequest(new { message = "字典项值已存在" });
|
||||
|
||||
var item = new SysDictItem
|
||||
{
|
||||
DictId = dictId, Label = req.Label, Value = req.Value, Ext = req.Ext,
|
||||
IsDefault = req.IsDefault, Enabled = req.Enabled, Sort = req.Sort
|
||||
};
|
||||
db.SysDictItems.Add(item);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new DictItemDto(item.Id, item.DictId, item.Label, item.Value, item.Ext, item.IsDefault, item.Enabled, item.Sort));
|
||||
}
|
||||
|
||||
/// <summary>修改字典项</summary>
|
||||
[HttpPut("items/{id:int}")]
|
||||
public async Task<ActionResult> UpdateItem(int id, DictItemSaveRequest req)
|
||||
{
|
||||
var item = await db.SysDictItems.FindAsync(id);
|
||||
if (item is null) return NotFound();
|
||||
|
||||
item.Label = req.Label; item.Value = req.Value; item.Ext = req.Ext;
|
||||
item.IsDefault = req.IsDefault; item.Enabled = req.Enabled; item.Sort = req.Sort;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "保存成功" });
|
||||
}
|
||||
|
||||
/// <summary>删除字典项</summary>
|
||||
[HttpDelete("items/{id:int}")]
|
||||
public async Task<IActionResult> DeleteItem(int id)
|
||||
{
|
||||
var item = await db.SysDictItems.FindAsync(id);
|
||||
if (item is null) return NotFound();
|
||||
db.SysDictItems.Remove(item);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public class FarmersController(AppDbContext db) : ControllerBase
|
||||
query = query.Where(f => f.Village == village);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(f => f.Name.Contains(keyword)
|
||||
|| f.PinyinInitials.Contains(keyword.ToUpperInvariant())
|
||||
|| f.Phone.Contains(keyword)
|
||||
|| f.IdCard.Contains(keyword)
|
||||
|| f.Village.Contains(keyword)
|
||||
@@ -52,9 +53,12 @@ public class FarmersController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
var query = db.Farmers.Where(f => f.Status == FarmerStatus.Active).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(f => f.Name.Contains(keyword) || f.Phone.Contains(keyword) || f.IdCard.Contains(keyword));
|
||||
query = query.Where(f => f.Name.Contains(keyword)
|
||||
|| f.PinyinInitials.Contains(keyword.ToUpperInvariant())
|
||||
|| f.Phone.Contains(keyword)
|
||||
|| f.IdCard.Contains(keyword));
|
||||
var items = await query.OrderByDescending(f => f.Id).Take(limit)
|
||||
.Select(f => new { f.Id, f.Name, f.Phone, f.Province, f.County, f.Township, f.Village, f.GroupName, f.IdCard }).ToListAsync();
|
||||
.Select(f => new { f.Id, f.Name, f.Phone, f.Province, f.County, f.Township, f.Village, f.GroupName, f.IdCard, f.PinyinInitials }).ToListAsync();
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
@@ -88,7 +92,8 @@ public class FarmersController(AppDbContext db) : ControllerBase
|
||||
|
||||
var farmer = new Farmer
|
||||
{
|
||||
Name = req.Name, IdCard = req.IdCard, Gender = req.Gender, FarmerType = req.FarmerType,
|
||||
Name = req.Name, PinyinInitials = req.PinyinInitials ?? string.Empty,
|
||||
IdCard = req.IdCard, Gender = req.Gender, FarmerType = req.FarmerType,
|
||||
Phone = req.Phone,
|
||||
Province = req.Province, County = req.County, Township = req.Township,
|
||||
Village = req.Village, GroupName = req.GroupName,
|
||||
@@ -112,7 +117,8 @@ public class FarmersController(AppDbContext db) : ControllerBase
|
||||
if (req.IdCard != farmer.IdCard && await db.Farmers.AnyAsync(f => f.IdCard == req.IdCard))
|
||||
return BadRequest(new { message = "该身份证号已存在" });
|
||||
|
||||
farmer.Name = req.Name; farmer.IdCard = req.IdCard; farmer.Gender = req.Gender;
|
||||
farmer.Name = req.Name; farmer.PinyinInitials = req.PinyinInitials ?? string.Empty;
|
||||
farmer.IdCard = req.IdCard; farmer.Gender = req.Gender;
|
||||
farmer.FarmerType = req.FarmerType;
|
||||
farmer.Phone = req.Phone;
|
||||
farmer.Province = req.Province; farmer.County = req.County;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 附件读取代理:OSS 模式(私有桶)下前端通过 /api/files/{key} 访问图片,
|
||||
/// 后端实时从 OSS 拉取并回传,避免将 Bucket 设为公共读而泄露身份证等敏感信息。
|
||||
/// 本地存储模式不经过此接口(前端直接走静态文件 /uploads)。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/files")]
|
||||
[AllowAnonymous]
|
||||
public class FilesController(IFileStorage storage) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 读取附件。key 形如 uploads/avatar/202608/xxx.png(必须限定 uploads/ 前缀,防止越权读取)。
|
||||
/// 通过 FileStreamResult 边读边传,减少大图内存占用。
|
||||
/// </summary>
|
||||
[HttpGet("{**key}")]
|
||||
[ResponseCache(Duration = 300)] // 5 分钟缓存,减少重复拉取
|
||||
public async Task<IActionResult> Get(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || !key.StartsWith("uploads/"))
|
||||
return BadRequest(new { message = "无效的文件路径" });
|
||||
|
||||
var file = await storage.OpenReadAsync(key);
|
||||
if (file is null)
|
||||
return NotFound(new { message = "文件不存在或已被删除" });
|
||||
|
||||
var (stream, contentType) = file.Value;
|
||||
return File(stream, contentType, enableRangeProcessing: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Security.Claims;
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
/// <summary>菜单/权限管理</summary>
|
||||
[ApiController]
|
||||
[Route("api/menus")]
|
||||
[Authorize]
|
||||
public class MenusController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>菜单树(全部)</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> Tree()
|
||||
{
|
||||
var menus = await db.SysMenus.AsNoTracking().OrderBy(m => m.Sort).ThenBy(m => m.Id).ToListAsync();
|
||||
return Ok(BuildTree(menus, null));
|
||||
}
|
||||
|
||||
/// <summary>当前登录用户的菜单与权限</summary>
|
||||
[HttpGet("my")]
|
||||
public async Task<ActionResult> My()
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userId is null) return Unauthorized();
|
||||
|
||||
// 用户角色 -> 菜单
|
||||
var user = await db.Users.Include(u => u.SysRole).FirstOrDefaultAsync(u => u.Id == int.Parse(userId));
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
int[] menuIds;
|
||||
if (user.SysRole?.Code == "super_admin" || user.Role == UserRole.SuperAdmin)
|
||||
{
|
||||
menuIds = await db.SysMenus.Select(m => m.Id).ToArrayAsync();
|
||||
}
|
||||
else if (user.RoleId is int rid)
|
||||
{
|
||||
menuIds = await db.SysRoleMenus.Where(rm => rm.RoleId == rid).Select(rm => rm.MenuId).ToArrayAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
menuIds = Array.Empty<int>();
|
||||
}
|
||||
|
||||
var menus = await db.SysMenus.AsNoTracking()
|
||||
.Where(m => menuIds.Contains(m.Id))
|
||||
.OrderBy(m => m.Sort).ThenBy(m => m.Id)
|
||||
.ToListAsync();
|
||||
var tree = BuildTree(menus, null);
|
||||
var permissions = menus.Where(m => !string.IsNullOrWhiteSpace(m.Permission)).Select(m => m.Permission).Distinct().ToArray();
|
||||
return Ok(new { menus = tree, permissions });
|
||||
}
|
||||
|
||||
private static List<MenuDto> BuildTree(List<SysMenu> all, int? parentId)
|
||||
{
|
||||
return all.Where(m => m.ParentId == parentId)
|
||||
.Select(m => new MenuDto(
|
||||
m.Id, m.ParentId, m.Name, m.Path, m.Component, m.Icon, m.Type,
|
||||
m.Permission, m.Visible, m.Sort, BuildTree(all, m.Id)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>新增菜单</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> Create(MenuSaveRequest req)
|
||||
{
|
||||
var menu = new SysMenu
|
||||
{
|
||||
ParentId = req.ParentId, Name = req.Name, Path = req.Path, Component = req.Component,
|
||||
Icon = req.Icon, Type = req.Type, Permission = req.Permission, Visible = req.Visible,
|
||||
Sort = req.Sort, CreatedAt = DateTime.Now
|
||||
};
|
||||
db.SysMenus.Add(menu);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "保存成功", id = menu.Id });
|
||||
}
|
||||
|
||||
/// <summary>修改菜单</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult> Update(int id, MenuSaveRequest req)
|
||||
{
|
||||
var menu = await db.SysMenus.FindAsync(id);
|
||||
if (menu is null) return NotFound();
|
||||
if (req.ParentId == id) return BadRequest(new { message = "父级不能选择自身" });
|
||||
|
||||
menu.ParentId = req.ParentId; menu.Name = req.Name; menu.Path = req.Path;
|
||||
menu.Component = req.Component; menu.Icon = req.Icon; menu.Type = req.Type;
|
||||
menu.Permission = req.Permission; menu.Visible = req.Visible; menu.Sort = req.Sort;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "保存成功" });
|
||||
}
|
||||
|
||||
/// <summary>删除菜单(有子菜单或已被角色引用时禁止删除)</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
if (await db.SysMenus.AnyAsync(m => m.ParentId == id))
|
||||
return BadRequest(new { message = "请先删除子菜单" });
|
||||
if (await db.SysRoleMenus.AnyAsync(rm => rm.MenuId == id))
|
||||
return BadRequest(new { message = "该菜单已被角色引用,请先解除关联" });
|
||||
var menu = await db.SysMenus.FindAsync(id);
|
||||
if (menu is null) return NotFound();
|
||||
db.SysMenus.Remove(menu);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public class PurchasesController(
|
||||
var query = db.PurchaseOrders
|
||||
.Include(p => p.Farmer).Include(p => p.Product)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.Include(p => p.Weighs)
|
||||
.AsQueryable();
|
||||
|
||||
if (visible is not null)
|
||||
@@ -73,11 +74,42 @@ public class PurchasesController(
|
||||
var order = await db.PurchaseOrders
|
||||
.Include(p => p.Farmer).Include(p => p.Product)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.Include(p => p.Weighs)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (order is null) return NotFound();
|
||||
return Ok(order.ToDto());
|
||||
}
|
||||
|
||||
/// <summary>收购单统计(单数、重量合计、均价、金额合计)</summary>
|
||||
[HttpGet("summary")]
|
||||
public async Task<ActionResult<PurchaseSummaryDto>> Summary(
|
||||
int? farmerId, int? productId, int? orgId, DateTime? from, DateTime? to)
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PurchaseOrders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed)
|
||||
.AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(p => visible.Contains(p.PurchaserOrgId));
|
||||
if (farmerId.HasValue) query = query.Where(p => p.FarmerId == farmerId);
|
||||
if (productId.HasValue) query = query.Where(p => p.ProductId == productId);
|
||||
if (orgId.HasValue) query = query.Where(p => p.PurchaserOrgId == orgId);
|
||||
if (from.HasValue) query = query.Where(p => p.CreatedAt >= from);
|
||||
if (to.HasValue) query = query.Where(p => p.CreatedAt < to.Value.Date.AddDays(1));
|
||||
|
||||
var orderCount = await query.CountAsync();
|
||||
var totalNet = await query.SumAsync(p => (decimal?)p.NetWeight) ?? 0;
|
||||
var totalAmount = await query.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
var farmerCount = await query.Select(p => p.FarmerId).Distinct().CountAsync();
|
||||
var todayStart = DateTime.Today;
|
||||
var todayOrderCount = await db.PurchaseOrders
|
||||
.CountAsync(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= todayStart);
|
||||
var avgPrice = totalNet > 0 ? Math.Round(totalAmount / totalNet, 2) : 0;
|
||||
|
||||
return Ok(new PurchaseSummaryDto(orderCount, Math.Round(totalNet, 2), avgPrice,
|
||||
Math.Round(totalAmount, 2), farmerCount, todayOrderCount));
|
||||
}
|
||||
|
||||
/// <summary>新建收购单(第一次过磅:记录毛重)</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<PurchaseDto>> Create(PurchaseCreateRequest req)
|
||||
@@ -162,6 +194,138 @@ public class PurchasesController(
|
||||
.FirstAsync(p => p.Id == order.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>多磅收购单:一次性保存(一单多磅、多等级),完成后直接打印</summary>
|
||||
[HttpPost("multi")]
|
||||
public async Task<ActionResult<PurchaseDto>> CreateMulti(PurchaseMultiCreateRequest req)
|
||||
{
|
||||
if (req.Weighs is null || req.Weighs.Count == 0)
|
||||
return BadRequest(new { message = "请至少录入一磅数据" });
|
||||
if (!await db.Farmers.AnyAsync(f => f.Id == req.FarmerId))
|
||||
return BadRequest(new { message = "农户不存在" });
|
||||
if (!await db.Products.AnyAsync(p => p.Id == req.ProductId))
|
||||
return BadRequest(new { message = "品种不存在" });
|
||||
if (!await db.Organizations.AnyAsync(o => o.Id == req.PurchaserOrgId))
|
||||
return BadRequest(new { message = "收购方组织不存在" });
|
||||
|
||||
var cu = currentUser.Get()!;
|
||||
var orderNo = await numberGen.NextAsync("CG");
|
||||
var order = new PurchaseOrder
|
||||
{
|
||||
OrderNo = orderNo,
|
||||
FarmerId = req.FarmerId,
|
||||
ProductId = req.ProductId,
|
||||
PurchaserOrgId = req.PurchaserOrgId,
|
||||
Unit = string.IsNullOrWhiteSpace(req.Unit) ? "公斤" : req.Unit,
|
||||
WeighCount = req.Weighs.Count,
|
||||
Status = PurchaseStatus.Completed,
|
||||
OperatorId = cu.Id,
|
||||
Notes = req.Notes ?? "",
|
||||
WeighInAt = DateTime.Now,
|
||||
WeighOutAt = DateTime.Now,
|
||||
CreatedAt = DateTime.Now,
|
||||
UpdatedAt = DateTime.Now
|
||||
};
|
||||
|
||||
var sortNo = 1;
|
||||
foreach (var w in req.Weighs)
|
||||
{
|
||||
var net = w.NetWeight ?? Math.Round(w.GrossWeight - w.TareWeight, 2);
|
||||
var amount = Math.Round(net * w.UnitPrice, 2);
|
||||
order.Weighs.Add(new PurchaseWeigh
|
||||
{
|
||||
SortNo = sortNo++,
|
||||
Grade = w.Grade,
|
||||
PriceType = w.PriceType,
|
||||
UnitPrice = w.UnitPrice,
|
||||
GrossWeight = w.GrossWeight,
|
||||
TareWeight = w.TareWeight,
|
||||
NetWeight = net,
|
||||
BoxCount = w.BoxCount,
|
||||
Amount = amount,
|
||||
CreatedAt = DateTime.Now
|
||||
});
|
||||
order.GrossWeight += w.GrossWeight;
|
||||
order.TareWeight += w.TareWeight;
|
||||
order.NetWeight += net;
|
||||
order.Amount += amount;
|
||||
order.BoxCount += w.BoxCount;
|
||||
}
|
||||
order.NetWeight = Math.Round(order.NetWeight, 2);
|
||||
order.Amount = Math.Round(order.Amount, 2);
|
||||
|
||||
// 主字段取第一磅
|
||||
var first = order.Weighs.First();
|
||||
order.Grade = first.Grade;
|
||||
order.UnitPrice = first.UnitPrice;
|
||||
|
||||
db.PurchaseOrders.Add(order);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok((await db.PurchaseOrders
|
||||
.Include(p => p.Farmer).Include(p => p.Product)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.Include(p => p.Weighs)
|
||||
.FirstAsync(p => p.Id == order.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>补打票据(记录补打日志到 Notes)</summary>
|
||||
[HttpPost("{id:int}/reprint")]
|
||||
public async Task<ActionResult> Reprint(int id)
|
||||
{
|
||||
var order = await db.PurchaseOrders.FindAsync(id);
|
||||
if (order is null) return NotFound();
|
||||
var cu = currentUser.Get()!;
|
||||
var log = $"补打票据 {DateTime.Now:yyyy-MM-dd HH:mm} by {cu.RealName}";
|
||||
order.Notes = string.IsNullOrWhiteSpace(order.Notes) ? log : order.Notes + ";" + log;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "已记录补打" });
|
||||
}
|
||||
|
||||
/// <summary>票据过户(生成过户单号并记录)</summary>
|
||||
[HttpPost("{id:int}/transfer")]
|
||||
public async Task<ActionResult> Transfer(int id, PurchaseTransferRequest req)
|
||||
{
|
||||
var order = await db.PurchaseOrders.Include(p => p.Farmer).FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (order is null) return NotFound();
|
||||
if (string.IsNullOrWhiteSpace(req.ToFarmer))
|
||||
return BadRequest(new { message = "请填写过户到农户" });
|
||||
if (order.Status != PurchaseStatus.Completed)
|
||||
return BadRequest(new { message = "仅已完成单据可过户" });
|
||||
|
||||
var cu = currentUser.Get()!;
|
||||
var transfer = new PurchaseTransfer
|
||||
{
|
||||
PurchaseOrderId = order.Id,
|
||||
TransferNo = await numberGen.NextAsync("GH"),
|
||||
FromFarmer = order.Farmer?.Name ?? "",
|
||||
ToFarmer = req.ToFarmer,
|
||||
ToIdCard = req.ToIdCard ?? "",
|
||||
Reason = req.Reason ?? "",
|
||||
OperatorId = cu.Id,
|
||||
CreatedAt = DateTime.Now
|
||||
};
|
||||
db.PurchaseTransfers.Add(transfer);
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "过户成功", transferNo = transfer.TransferNo });
|
||||
}
|
||||
|
||||
/// <summary>收购单过户记录</summary>
|
||||
[HttpGet("{id:int}/transfers")]
|
||||
public async Task<ActionResult> Transfers(int id)
|
||||
{
|
||||
var items = await db.PurchaseTransfers.AsNoTracking()
|
||||
.Include(t => t.Operator)
|
||||
.Where(t => t.PurchaseOrderId == id)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.Select(t => new PurchaseTransferDto(
|
||||
t.Id, t.TransferNo, t.FromFarmer, t.ToFarmer, t.ToIdCard, t.Reason,
|
||||
t.Operator != null ? t.Operator.RealName : "", t.CreatedAt))
|
||||
.ToListAsync();
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
/// <summary>作废收购单</summary>
|
||||
[HttpPut("{id:int}/cancel")]
|
||||
public async Task<IActionResult> Cancel(int id)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
/// <summary>角色管理(RBAC)</summary>
|
||||
[ApiController]
|
||||
[Route("api/roles")]
|
||||
[Authorize]
|
||||
public class RolesController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>角色列表</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<RoleDto>>> List(string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
{
|
||||
var query = db.SysRoles.AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(r => r.Name.Contains(keyword) || r.Code.Contains(keyword));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query.OrderBy(r => r.Id)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(r => new RoleDto(
|
||||
r.Id, r.Name, r.Code, r.Description, r.IsSystem,
|
||||
db.SysRoleMenus.Where(rm => rm.RoleId == r.Id).Select(rm => rm.MenuId).ToArray()))
|
||||
.ToListAsync();
|
||||
return Ok(new PagedResult<RoleDto>(items, total));
|
||||
}
|
||||
|
||||
/// <summary>全部角色(下拉选择用)</summary>
|
||||
[HttpGet("all")]
|
||||
public async Task<ActionResult> All()
|
||||
{
|
||||
var items = await db.SysRoles.AsNoTracking().OrderBy(r => r.Id)
|
||||
.Select(r => new { r.Id, r.Name, r.Code }).ToListAsync();
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
/// <summary>新增角色</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> Create(RoleSaveRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Code))
|
||||
return BadRequest(new { message = "角色编码不能为空" });
|
||||
if (await db.SysRoles.AnyAsync(r => r.Code == req.Code.Trim()))
|
||||
return BadRequest(new { message = "角色编码已存在" });
|
||||
|
||||
var role = new SysRole
|
||||
{
|
||||
Name = req.Name, Code = req.Code.Trim(), Description = req.Description, CreatedAt = DateTime.Now
|
||||
};
|
||||
db.SysRoles.Add(role);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (req.MenuIds.Length > 0)
|
||||
{
|
||||
db.SysRoleMenus.AddRange(req.MenuIds.Distinct().Select(mid => new SysRoleMenu { RoleId = role.Id, MenuId = mid }));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(new { message = "保存成功", id = role.Id });
|
||||
}
|
||||
|
||||
/// <summary>修改角色</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult> Update(int id, RoleSaveRequest req)
|
||||
{
|
||||
var role = await db.SysRoles.FindAsync(id);
|
||||
if (role is null) return NotFound();
|
||||
|
||||
role.Name = req.Name; role.Description = req.Description;
|
||||
// 系统内置角色编码不可改
|
||||
if (!role.IsSystem) role.Code = req.Code.Trim();
|
||||
|
||||
var old = await db.SysRoleMenus.Where(rm => rm.RoleId == id).Select(rm => rm.MenuId).ToListAsync();
|
||||
var newMenuIds = req.MenuIds.Distinct().ToArray();
|
||||
var toRemove = old.Where(m => !newMenuIds.Contains(m)).ToList();
|
||||
var toAdd = newMenuIds.Where(m => !old.Contains(m)).ToList();
|
||||
if (toRemove.Count > 0)
|
||||
db.SysRoleMenus.RemoveRange(toRemove.Select(m => new SysRoleMenu { RoleId = id, MenuId = m }));
|
||||
if (toAdd.Count > 0)
|
||||
db.SysRoleMenus.AddRange(toAdd.Select(m => new SysRoleMenu { RoleId = id, MenuId = m }));
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "保存成功" });
|
||||
}
|
||||
|
||||
/// <summary>删除角色(系统内置或有用户引用时禁止删除)</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var role = await db.SysRoles.FindAsync(id);
|
||||
if (role is null) return NotFound();
|
||||
if (role.IsSystem) return BadRequest(new { message = "系统内置角色不可删除" });
|
||||
if (await db.Users.AnyAsync(u => u.RoleId == id))
|
||||
return BadRequest(new { message = "该角色已被用户使用,请先解除关联" });
|
||||
|
||||
db.SysRoleMenus.RemoveRange(db.SysRoleMenus.Where(rm => rm.RoleId == id));
|
||||
db.SysRoles.Remove(role);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,10 @@ public class UploadsController(
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
|
||||
var result = await ocr.RecognizeAsync(ms, side, file.FileName);
|
||||
// 反面仅存档至 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 });
|
||||
}
|
||||
|
||||
@@ -120,7 +123,7 @@ public class UploadsController(
|
||||
try
|
||||
{
|
||||
session.Front = await storage.SaveAsync("idcard", ms, front.FileName, front.ContentType);
|
||||
session.FrontOcr = await ocr.RecognizeAsync(ms, "front", front.FileName);
|
||||
session.FrontOcr = await ocr.RecognizeAsync(ms, "front", front.FileName, session.Front.Key);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
@@ -135,8 +138,9 @@ public class UploadsController(
|
||||
ms.Position = 0;
|
||||
try
|
||||
{
|
||||
// 反面仅存档,不调用 OCR
|
||||
session.Back = await storage.SaveAsync("idcard", ms, back.FileName, back.ContentType);
|
||||
session.BackOcr = await ocr.RecognizeAsync(ms, "back", back.FileName);
|
||||
session.BackOcr = null;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
@@ -159,7 +163,7 @@ public class UploadsController(
|
||||
if (!session.Done)
|
||||
return Ok(new { done = false });
|
||||
|
||||
var ocr = MergeOcr(session.FrontOcr, session.BackOcr);
|
||||
// 反面不再识别,仅返回正面 OCR 结果(可能为 null)
|
||||
return Ok(new
|
||||
{
|
||||
done = true,
|
||||
@@ -167,20 +171,10 @@ public class UploadsController(
|
||||
frontKey = session.Front?.Key,
|
||||
backUrl = session.Back?.Url,
|
||||
backKey = session.Back?.Key,
|
||||
ocr
|
||||
ocr = session.FrontOcr
|
||||
});
|
||||
}
|
||||
|
||||
/// <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 等虚拟网卡),
|
||||
|
||||
@@ -20,7 +20,7 @@ public class UsersController(AppDbContext db, DataScopeService scope, CurrentUse
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null)
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.Users.Include(u => u.Org).AsQueryable();
|
||||
var query = db.Users.Include(u => u.Org).Include(u => u.SysRole).AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(u => u.OrgId != null && visible.Contains(u.OrgId.Value));
|
||||
if (!string.IsNullOrWhiteSpace(role) && Enum.TryParse<UserRole>(role, out var userRole))
|
||||
@@ -54,12 +54,13 @@ public class UsersController(AppDbContext db, DataScopeService scope, CurrentUse
|
||||
RealName = req.RealName,
|
||||
Phone = req.Phone,
|
||||
Role = role,
|
||||
RoleId = req.RoleId,
|
||||
OrgId = req.OrgId,
|
||||
IsActive = req.IsActive
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok((await db.Users.Include(u => u.Org).FirstAsync(u => u.Id == user.Id)).ToDto());
|
||||
return Ok((await db.Users.Include(u => u.Org).Include(u => u.SysRole).FirstAsync(u => u.Id == user.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>修改用户</summary>
|
||||
@@ -75,12 +76,13 @@ public class UsersController(AppDbContext db, DataScopeService scope, CurrentUse
|
||||
user.RealName = req.RealName;
|
||||
user.Phone = req.Phone;
|
||||
if (Enum.TryParse<UserRole>(req.Role, out var role)) user.Role = role;
|
||||
user.RoleId = req.RoleId;
|
||||
user.OrgId = req.OrgId;
|
||||
user.IsActive = req.IsActive;
|
||||
if (!string.IsNullOrWhiteSpace(req.Password))
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok((await db.Users.Include(u => u.Org).FirstAsync(u => u.Id == user.Id)).ToDto());
|
||||
return Ok((await db.Users.Include(u => u.Org).Include(u => u.SysRole).FirstAsync(u => u.Id == user.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>重置密码</summary>
|
||||
|
||||
@@ -12,9 +12,22 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
public DbSet<Region> Regions => Set<Region>();
|
||||
public DbSet<Product> Products => Set<Product>();
|
||||
public DbSet<PurchaseOrder> PurchaseOrders => Set<PurchaseOrder>();
|
||||
public DbSet<PurchaseWeigh> PurchaseWeighs => Set<PurchaseWeigh>();
|
||||
public DbSet<PurchaseTransfer> PurchaseTransfers => Set<PurchaseTransfer>();
|
||||
public DbSet<PaymentRecord> PaymentRecords => Set<PaymentRecord>();
|
||||
public DbSet<Invoice> Invoices => Set<Invoice>();
|
||||
|
||||
// 系统管理(RBAC)
|
||||
public DbSet<SysDict> SysDicts => Set<SysDict>();
|
||||
public DbSet<SysDictItem> SysDictItems => Set<SysDictItem>();
|
||||
public DbSet<SysMenu> SysMenus => Set<SysMenu>();
|
||||
public DbSet<SysRole> SysRoles => Set<SysRole>();
|
||||
public DbSet<SysRoleMenu> SysRoleMenus => Set<SysRoleMenu>();
|
||||
|
||||
// 通知公告
|
||||
public DbSet<Announcement> Announcements => Set<Announcement>();
|
||||
public DbSet<AnnouncementRead> AnnouncementReads => Set<AnnouncementRead>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -41,6 +54,9 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
modelBuilder.Entity<User>()
|
||||
.HasOne(u => u.Org).WithMany().HasForeignKey(u => u.OrgId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
modelBuilder.Entity<User>()
|
||||
.HasOne(u => u.SysRole).WithMany().HasForeignKey(u => u.RoleId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// 组织
|
||||
modelBuilder.Entity<Organization>()
|
||||
@@ -119,6 +135,70 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
.HasOne(p => p.Operator).WithMany().HasForeignKey(p => p.OperatorId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// 收购单-磅次
|
||||
modelBuilder.Entity<PurchaseWeigh>()
|
||||
.HasOne(w => w.PurchaseOrder).WithMany(p => p.Weighs)
|
||||
.HasForeignKey(w => w.PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// 票据过户
|
||||
modelBuilder.Entity<PurchaseTransfer>()
|
||||
.HasOne(t => t.PurchaseOrder).WithMany(p => p.Transfers)
|
||||
.HasForeignKey(t => t.PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<PurchaseTransfer>()
|
||||
.HasOne(t => t.Operator).WithMany().HasForeignKey(t => t.OperatorId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// 数据字典
|
||||
modelBuilder.Entity<SysDict>()
|
||||
.Property(d => d.Code).HasMaxLength(64);
|
||||
modelBuilder.Entity<SysDict>()
|
||||
.HasIndex(d => d.Code).IsUnique();
|
||||
modelBuilder.Entity<SysDict>()
|
||||
.Property(d => d.Name).HasMaxLength(100);
|
||||
modelBuilder.Entity<SysDictItem>()
|
||||
.Property(i => i.Label).HasMaxLength(100);
|
||||
modelBuilder.Entity<SysDictItem>()
|
||||
.Property(i => i.Value).HasMaxLength(64);
|
||||
modelBuilder.Entity<SysDictItem>()
|
||||
.HasIndex(i => new { i.DictId, i.Value }).IsUnique();
|
||||
modelBuilder.Entity<SysDict>()
|
||||
.HasMany(d => d.Items).WithOne().HasForeignKey(i => i.DictId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// 菜单(自引用父子关系显式指定外键,避免 EF 推断额外列)
|
||||
modelBuilder.Entity<SysMenu>()
|
||||
.Property(m => m.Name).HasMaxLength(100);
|
||||
modelBuilder.Entity<SysMenu>()
|
||||
.Property(m => m.Path).HasMaxLength(200);
|
||||
modelBuilder.Entity<SysMenu>()
|
||||
.Property(m => m.Permission).HasMaxLength(100);
|
||||
modelBuilder.Entity<SysMenu>()
|
||||
.HasIndex(m => m.Permission);
|
||||
modelBuilder.Entity<SysMenu>()
|
||||
.HasMany(m => m.Children).WithOne().HasForeignKey(m => m.ParentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// 角色
|
||||
modelBuilder.Entity<SysRole>()
|
||||
.Property(r => r.Name).HasMaxLength(100);
|
||||
modelBuilder.Entity<SysRole>()
|
||||
.HasIndex(r => r.Code).IsUnique();
|
||||
modelBuilder.Entity<SysRoleMenu>()
|
||||
.HasKey(rm => new { rm.RoleId, rm.MenuId });
|
||||
|
||||
// 公告
|
||||
modelBuilder.Entity<Announcement>()
|
||||
.Property(a => a.Title).HasMaxLength(200);
|
||||
modelBuilder.Entity<Announcement>()
|
||||
.HasIndex(a => new { a.Type, a.PublishedAt });
|
||||
modelBuilder.Entity<Announcement>()
|
||||
.HasOne(a => a.Publisher).WithMany().HasForeignKey(a => a.PublisherId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
modelBuilder.Entity<AnnouncementRead>()
|
||||
.HasIndex(r => new { r.AnnouncementId, r.UserId }).IsUnique();
|
||||
|
||||
// 支付
|
||||
modelBuilder.Entity<PaymentRecord>()
|
||||
.Property(p => p.PayNo).HasMaxLength(32);
|
||||
|
||||
@@ -57,14 +57,87 @@ public static class DbSeeder
|
||||
stationWest.ParentId = company.Id;
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 角色(RBAC) ----------
|
||||
var roleAdmin = new SysRole { Name = "超级管理员", Code = "super_admin", Description = "系统内置:拥有全部权限", IsSystem = true, CreatedAt = now };
|
||||
var roleCompany = new SysRole { Name = "公司管理员", Code = "company_admin", Description = "管理本公司及下属收购站业务", IsSystem = true, CreatedAt = now };
|
||||
var roleStation = new SysRole { Name = "收购站员工", Code = "station_staff", Description = "负责过磅称重、收购单录入", IsSystem = true, CreatedAt = now };
|
||||
var roleIndividual = new SysRole { Name = "收购个体", Code = "individual", Description = "个体收购户", IsSystem = true, CreatedAt = now };
|
||||
db.SysRoles.AddRange(roleAdmin, roleCompany, roleStation, roleIndividual);
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 菜单/权限(RBAC 资源,Path 与前端路由一致) ----------
|
||||
var menus = new List<SysMenu>
|
||||
{
|
||||
// 首页
|
||||
new() { Name = "首页", Path = "/home", Component = "views/Home.vue", Icon = "HomeFilled", Type = "menu", Permission = "home:view", Sort = 1, CreatedAt = now },
|
||||
// 收购业务
|
||||
new() { Name = "收购业务", Path = "/weighing", Icon = "Van", Type = "directory", Sort = 2, CreatedAt = now },
|
||||
new() { Name = "过磅称重", Path = "/weighing", Component = "views/weighing/WeighingList.vue", Icon = "Odometer", Type = "menu", Permission = "weighing:list", Sort = 1, CreatedAt = now },
|
||||
new() { Name = "触摸屏过磅", Path = "/weighing/touch", Component = "views/weighing/WeighingTouch.vue", Icon = "Iphone", Type = "menu", Permission = "weighing:touch", Sort = 2, CreatedAt = now },
|
||||
new() { Name = "电子支付", Path = "/payments", Component = "views/payments/PaymentList.vue", Icon = "Wallet", Type = "menu", Permission = "payment:list", Sort = 3, CreatedAt = now },
|
||||
new() { Name = "反向开票", Path = "/invoices", Component = "views/invoices/InvoiceList.vue", Icon = "Tickets", Type = "menu", Permission = "invoice:list", Sort = 4, CreatedAt = now },
|
||||
// 基础资料
|
||||
new() { Name = "基础资料", Path = "/base", Icon = "FolderOpened", Type = "directory", Sort = 3, CreatedAt = now },
|
||||
new() { Name = "农户管理", Path = "/farmers", Component = "views/farmers/FarmerList.vue", Icon = "User", Type = "menu", Permission = "farmer:list", Sort = 1, CreatedAt = now },
|
||||
new() { Name = "品种管理", Path = "/products", Component = "views/products/ProductList.vue", Icon = "Goods", Type = "menu", Permission = "product:list", Sort = 2, CreatedAt = now },
|
||||
// 统计报表
|
||||
new() { Name = "统计报表", Path = "/reports", Icon = "DataAnalysis", Type = "directory", Sort = 4, CreatedAt = now },
|
||||
new() { Name = "收购报表", Path = "/reports/purchase", Component = "views/reports/ReportPurchase.vue", Icon = "TrendCharts", Type = "menu", Permission = "report:purchase", Sort = 1, CreatedAt = now },
|
||||
new() { Name = "付款报表", Path = "/reports/payment", Component = "views/reports/ReportPayment.vue", Icon = "Money", Type = "menu", Permission = "report:payment", Sort = 2, CreatedAt = now },
|
||||
new() { Name = "开票报表", Path = "/reports/invoice", Component = "views/reports/ReportInvoice.vue", Icon = "Document", Type = "menu", Permission = "report:invoice", Sort = 3, CreatedAt = now },
|
||||
// 系统管理
|
||||
new() { Name = "系统管理", Path = "/system", Icon = "Setting", Type = "directory", Sort = 5, CreatedAt = now },
|
||||
new() { Name = "组织管理", Path = "/orgs", Component = "views/orgs/OrgList.vue", Icon = "OfficeBuilding", Type = "menu", Permission = "system:org:list", Sort = 1, CreatedAt = now },
|
||||
new() { Name = "用户管理", Path = "/users", Component = "views/users/UserList.vue", Icon = "Avatar", Type = "menu", Permission = "system:user:list", Sort = 2, CreatedAt = now },
|
||||
new() { Name = "角色权限", Path = "/system/roles", Component = "views/system/RoleList.vue", Icon = "UserFilled", Type = "menu", Permission = "system:role:list", Sort = 3, CreatedAt = now },
|
||||
new() { Name = "菜单管理", Path = "/system/menus", Component = "views/system/MenuList.vue", Icon = "Menu", Type = "menu", Permission = "system:menu:list", Sort = 4, CreatedAt = now },
|
||||
new() { Name = "数据字典", Path = "/system/dicts", Component = "views/system/DictList.vue", Icon = "Notebook", Type = "menu", Permission = "system:dict:list", Sort = 5, CreatedAt = now },
|
||||
// 通知公告
|
||||
new() { Name = "通知公告", Path = "/notify", Icon = "Bell", Type = "directory", Sort = 6, CreatedAt = now },
|
||||
new() { Name = "公告管理", Path = "/notify/announcements", Component = "views/notify/AnnouncementList.vue", Icon = "Bell", Type = "menu", Permission = "announcement:list", Sort = 1, CreatedAt = now },
|
||||
// 关于
|
||||
new() { Name = "关于系统", Path = "/about", Component = "views/about/About.vue", Icon = "InfoFilled", Type = "menu", Permission = "about:view", Sort = 7, CreatedAt = now },
|
||||
};
|
||||
db.SysMenus.AddRange(menus);
|
||||
db.SaveChanges();
|
||||
|
||||
// 目录菜单的父级关系(按路径匹配;目录路径与子菜单路径相同时以 Type 区分)
|
||||
SetChild(menus, "/weighing", "/weighing");
|
||||
SetChild(menus, "/weighing/touch", "/weighing");
|
||||
SetChild(menus, "/payments", "/weighing");
|
||||
SetChild(menus, "/invoices", "/weighing");
|
||||
SetChild(menus, "/farmers", "/base");
|
||||
SetChild(menus, "/products", "/base");
|
||||
SetChild(menus, "/reports/purchase", "/reports");
|
||||
SetChild(menus, "/reports/payment", "/reports");
|
||||
SetChild(menus, "/reports/invoice", "/reports");
|
||||
SetChild(menus, "/orgs", "/system");
|
||||
SetChild(menus, "/users", "/system");
|
||||
SetChild(menus, "/system/roles", "/system");
|
||||
SetChild(menus, "/system/menus", "/system");
|
||||
SetChild(menus, "/system/dicts", "/system");
|
||||
SetChild(menus, "/notify/announcements", "/notify");
|
||||
db.SaveChanges();
|
||||
|
||||
// 给超级管理员分配全部菜单;收购站员工分配常用菜单
|
||||
var allMenuIds = menus.Select(m => m.Id).ToArray();
|
||||
db.SysRoleMenus.AddRange(allMenuIds.Select(mid => new SysRoleMenu { RoleId = roleAdmin.Id, MenuId = mid }));
|
||||
var stationMenuIds = menus.Where(m => m.Type == "menu" && m.Permission is "home:view" or "weighing:list" or "weighing:touch" or "farmer:list" or "about:view").Select(m => m.Id);
|
||||
db.SysRoleMenus.AddRange(stationMenuIds.Select(mid => new SysRoleMenu { RoleId = roleStation.Id, MenuId = mid }));
|
||||
|
||||
// 公司管理员:除系统管理外的全部业务菜单
|
||||
var companyMenuIds = menus.Where(m => m.Type == "menu" && !m.Permission.StartsWith("system:")).Select(m => m.Id);
|
||||
db.SysRoleMenus.AddRange(companyMenuIds.Select(mid => new SysRoleMenu { RoleId = roleCompany.Id, MenuId = mid }));
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 用户(默认密码均为 123456) ----------
|
||||
var users = new List<User>
|
||||
{
|
||||
new() { Username = "admin", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "系统管理员", Phone = "13800000000", Role = UserRole.SuperAdmin, CreatedAt = now },
|
||||
new() { Username = "company", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "王建国", Phone = "13800000001", Role = UserRole.CompanyAdmin, OrgId = company.Id, CreatedAt = now },
|
||||
new() { Username = "station1", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "李强", Phone = "13800000002", Role = UserRole.StationStaff, OrgId = stationEast.Id, CreatedAt = now },
|
||||
new() { Username = "station2", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "赵敏", Phone = "13800000003", Role = UserRole.StationStaff, OrgId = stationWest.Id, CreatedAt = now },
|
||||
new() { Username = "individual", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "张伟", Phone = "13800000004", Role = UserRole.Individual, OrgId = individual.Id, CreatedAt = now }
|
||||
new() { Username = "admin", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "系统管理员", Phone = "13800000000", Role = UserRole.SuperAdmin, RoleId = roleAdmin.Id, CreatedAt = now },
|
||||
new() { Username = "company", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "王建国", Phone = "13800000001", Role = UserRole.CompanyAdmin, RoleId = roleCompany.Id, OrgId = company.Id, CreatedAt = now },
|
||||
new() { Username = "station1", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "李强", Phone = "13800000002", Role = UserRole.StationStaff, RoleId = roleStation.Id, OrgId = stationEast.Id, CreatedAt = now },
|
||||
new() { Username = "station2", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "赵敏", Phone = "13800000003", Role = UserRole.StationStaff, RoleId = roleStation.Id, OrgId = stationWest.Id, CreatedAt = now },
|
||||
new() { Username = "individual", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "张伟", Phone = "13800000004", Role = UserRole.Individual, RoleId = roleIndividual.Id, OrgId = individual.Id, CreatedAt = now }
|
||||
};
|
||||
db.Users.AddRange(users);
|
||||
db.SaveChanges();
|
||||
@@ -146,6 +219,75 @@ public static class DbSeeder
|
||||
db.PurchaseOrders.AddRange(orders);
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 数据字典(收购等级 / 价格类型 / 票据格式 / 公告类型) ----------
|
||||
var dicts = new List<SysDict>
|
||||
{
|
||||
new() { Name = "收购等级", Code = "purchase_grade", Remark = "收购单磅次等级", IsSystem = true, Sort = 1, CreatedAt = now,
|
||||
Items =
|
||||
{
|
||||
new() { Label = "一等", Value = "一等", IsDefault = true, Sort = 1 },
|
||||
new() { Label = "二等", Value = "二等", Sort = 2 },
|
||||
new() { Label = "三等", Value = "三等", Sort = 3 },
|
||||
new() { Label = "级外", Value = "级外", Sort = 4 },
|
||||
}
|
||||
},
|
||||
new() { Name = "价格类型", Code = "price_type", Remark = "过磅计价方式", IsSystem = true, Sort = 2, CreatedAt = now,
|
||||
Items =
|
||||
{
|
||||
new() { Label = "固定价格", Value = "Fixed", Ext = "按产品默认价", IsDefault = true, Sort = 1 },
|
||||
new() { Label = "现场议价", Value = "Manual", Ext = "与农户协商价", Sort = 2 },
|
||||
new() { Label = "价格区间", Value = "Range", Ext = "区间内浮动价", Sort = 3 },
|
||||
}
|
||||
},
|
||||
new() { Name = "票据格式", Code = "receipt_format", Remark = "小票/票据打印格式", IsSystem = true, Sort = 3, CreatedAt = now,
|
||||
Items =
|
||||
{
|
||||
new() { Label = "58mm 热敏小票", Value = "thermal_58", Ext = "58mm", IsDefault = true, Sort = 1 },
|
||||
new() { Label = "80mm 热敏小票", Value = "thermal_80", Ext = "80mm", Sort = 2 },
|
||||
new() { Label = "三等分针式票据", Value = "dotmatrix_3part", Ext = "241mm×139.7mm/联", Sort = 3 },
|
||||
new() { Label = "A5 激光打印", Value = "laser_a5", Ext = "A5", Sort = 4 },
|
||||
}
|
||||
},
|
||||
new() { Name = "公告类型", Code = "announcement_type", Remark = "通知公告类型", IsSystem = true, Sort = 4, CreatedAt = now,
|
||||
Items =
|
||||
{
|
||||
new() { Label = "通知", Value = "notice", Sort = 1 },
|
||||
new() { Label = "公告", Value = "announce", Sort = 2 },
|
||||
new() { Label = "价格行情", Value = "price", Sort = 3 },
|
||||
new() { Label = "政策法规", Value = "policy", Sort = 4 },
|
||||
}
|
||||
},
|
||||
new() { Name = "系统图标", Code = "system_logo", Remark = "主标题系统图标,可选大部分农产品图标", IsSystem = true, Sort = 5, CreatedAt = now,
|
||||
Items =
|
||||
{
|
||||
new() { Label = "苹果", Value = "Apple", IsDefault = true, Sort = 1 },
|
||||
new() { Label = "葡萄", Value = "Grape", Sort = 2 },
|
||||
new() { Label = "西瓜", Value = "Watermelon", Sort = 3 },
|
||||
new() { Label = "樱桃", Value = "Cherry", Sort = 4 },
|
||||
new() { Label = "梨", Value = "Pear", Sort = 5 },
|
||||
new() { Label = "桃", Value = "Peach", Sort = 6 },
|
||||
new() { Label = "橙子", Value = "Orange", Sort = 7 },
|
||||
new() { Label = "农作物", Value = "Food", Sort = 8 },
|
||||
new() { Label = "时蔬", Value = "Dish", Sort = 9 },
|
||||
new() { Label = "牛奶", Value = "Milk", Sort = 10 },
|
||||
new() { Label = "冰淇淋", Value = "IceCream", Sort = 11 },
|
||||
new() { Label = "生鲜礼盒", Value = "TakeawayBox", Sort = 12 },
|
||||
}
|
||||
},
|
||||
};
|
||||
db.SysDicts.AddRange(dicts);
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 通知公告(演示数据) ----------
|
||||
var announcements = new List<Announcement>
|
||||
{
|
||||
new() { Title = "欢迎使用农易富农产品收购交易平台", Content = "系统已完成升级,新增数据字典、角色权限、通知公告等功能。默认账号 admin / 123456。", Type = "announce", IsPinned = true, PublisherId = users[0].Id, PublishedAt = now, CreatedAt = now },
|
||||
new() { Title = "关于2026年夏季收购价格调整的通知", Content = "自8月15日起,一等品收购价调整为每公斤3.6元,请各收购站及时更新价格配置。", Type = "price", PublisherId = users[0].Id, PublishedAt = now.AddHours(-3), CreatedAt = now.AddHours(-3) },
|
||||
new() { Title = "电子秤串口连接使用说明", Content = "请在收购单新建界面点击电子秤区域连接串口,支持标准ASCII输出协议(如 ST,GS,+00123.45kg)。无串口设备时可使用手动输入模式。", Type = "notice", PublisherId = users[0].Id, PublishedAt = now.AddDays(-1), CreatedAt = now.AddDays(-1) },
|
||||
};
|
||||
db.Announcements.AddRange(announcements);
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 历史付款(对应部分订单) ----------
|
||||
var payOrders = orders.Where(o => o.Status == PurchaseStatus.Completed)
|
||||
.OrderBy(o => o.CreatedAt).Take(120).ToList();
|
||||
@@ -192,4 +334,12 @@ public static class DbSeeder
|
||||
db.Invoices.AddRange(invoices);
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static void SetChild(List<SysMenu> menus, string childPath, string parentPath)
|
||||
{
|
||||
var parent = menus.FirstOrDefault(m => m.Type == "directory" && m.Path == parentPath);
|
||||
if (parent is null) return;
|
||||
foreach (var child in menus.Where(m => m.Type != "directory" && m.Path == childPath && m.ParentId == null))
|
||||
child.ParentId = parent.Id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Data;
|
||||
@@ -27,6 +28,7 @@ public static class SchemaMigrator
|
||||
("IdCardFrontUrl", "IdCardFrontUrl NVARCHAR(500) NULL"),
|
||||
("IdCardBackUrl", "IdCardBackUrl NVARCHAR(500) NULL"),
|
||||
("AvatarUrl", "AvatarUrl NVARCHAR(500) NULL"),
|
||||
("PinyinInitials", "PinyinInitials VARCHAR(50) NULL"),
|
||||
];
|
||||
|
||||
foreach (var col in columns)
|
||||
@@ -41,7 +43,366 @@ public static class SchemaMigrator
|
||||
"Township = COALESCE(Township,''), GroupName = COALESCE(GroupName,''), " +
|
||||
"FarmerType = COALESCE(FarmerType,'农户'), " +
|
||||
"IdCardFrontUrl = COALESCE(IdCardFrontUrl,''), IdCardBackUrl = COALESCE(IdCardBackUrl,''), " +
|
||||
"AvatarUrl = COALESCE(AvatarUrl,'')");
|
||||
"AvatarUrl = COALESCE(AvatarUrl,''), PinyinInitials = COALESCE(PinyinInitials,'')");
|
||||
|
||||
// Users 表补充 RoleId 列(RBAC 角色外键)
|
||||
var userCols = new HashSet<string>(
|
||||
await db.Database
|
||||
.SqlQuery<string>($"SELECT COLUMN_NAME AS Value FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'Users'")
|
||||
.ToListAsync(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
if (!userCols.Contains("RoleId"))
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Users ADD COLUMN RoleId INT NULL");
|
||||
|
||||
// PurchaseOrders 表补充 BoxCount 列(容器/框数合计)
|
||||
var poCols = new HashSet<string>(
|
||||
await db.Database
|
||||
.SqlQuery<string>($"SELECT COLUMN_NAME AS Value FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'PurchaseOrders'")
|
||||
.ToListAsync(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
if (!poCols.Contains("BoxCount"))
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE PurchaseOrders ADD COLUMN BoxCount INT NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保 RBAC/数据字典/多磅/公告等新增表存在(兼容旧库 EnsureCreated 不会补建新表)。
|
||||
/// </summary>
|
||||
public static async Task EnsureSystemTablesAsync(AppDbContext db)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS SysDicts (
|
||||
Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(100) NOT NULL,
|
||||
Code VARCHAR(64) NOT NULL,
|
||||
Remark VARCHAR(500) NULL,
|
||||
IsSystem TINYINT(1) NOT NULL DEFAULT 0,
|
||||
Sort INT NOT NULL DEFAULT 0,
|
||||
CreatedAt DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY uk_dicts_code (Code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS SysDictItems (
|
||||
Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
DictId INT NOT NULL,
|
||||
Label VARCHAR(100) NOT NULL,
|
||||
Value VARCHAR(64) NOT NULL,
|
||||
Ext VARCHAR(255) NULL,
|
||||
IsDefault TINYINT(1) NOT NULL DEFAULT 0,
|
||||
Enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
Sort INT NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY uk_dictitems (DictId, Value),
|
||||
KEY idx_dictitems_dict (DictId)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS SysMenus (
|
||||
Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
ParentId INT NULL,
|
||||
Name VARCHAR(100) NOT NULL,
|
||||
Path VARCHAR(200) NOT NULL DEFAULT '',
|
||||
Component VARCHAR(200) NOT NULL DEFAULT '',
|
||||
Icon VARCHAR(50) NOT NULL DEFAULT '',
|
||||
Type VARCHAR(20) NOT NULL DEFAULT 'menu',
|
||||
Permission VARCHAR(100) NOT NULL DEFAULT '',
|
||||
Visible TINYINT(1) NOT NULL DEFAULT 1,
|
||||
Sort INT NOT NULL DEFAULT 0,
|
||||
CreatedAt DATETIME(6) NOT NULL,
|
||||
KEY idx_menus_permission (Permission)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS SysRoles (
|
||||
Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(100) NOT NULL,
|
||||
Code VARCHAR(100) NOT NULL,
|
||||
Description VARCHAR(500) NULL,
|
||||
IsSystem TINYINT(1) NOT NULL DEFAULT 0,
|
||||
CreatedAt DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY uk_roles_code (Code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS SysRoleMenus (
|
||||
RoleId INT NOT NULL,
|
||||
MenuId INT NOT NULL,
|
||||
PRIMARY KEY (RoleId, MenuId)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS PurchaseWeighs (
|
||||
Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
PurchaseOrderId INT NOT NULL,
|
||||
SortNo INT NOT NULL DEFAULT 1,
|
||||
Grade VARCHAR(20) NOT NULL DEFAULT '',
|
||||
PriceType VARCHAR(20) NOT NULL DEFAULT 'Manual',
|
||||
UnitPrice DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
GrossWeight DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
TareWeight DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
NetWeight DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
BoxCount INT NOT NULL DEFAULT 0,
|
||||
Amount DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
CreatedAt DATETIME(6) NOT NULL,
|
||||
KEY idx_weighs_order (PurchaseOrderId)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS PurchaseTransfers (
|
||||
Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
PurchaseOrderId INT NOT NULL,
|
||||
TransferNo VARCHAR(32) NOT NULL,
|
||||
FromFarmer VARCHAR(100) NOT NULL DEFAULT '',
|
||||
ToFarmer VARCHAR(100) NOT NULL DEFAULT '',
|
||||
ToIdCard VARCHAR(20) NOT NULL DEFAULT '',
|
||||
Reason VARCHAR(500) NOT NULL DEFAULT '',
|
||||
OperatorId INT NULL,
|
||||
CreatedAt DATETIME(6) NOT NULL,
|
||||
KEY idx_transfers_order (PurchaseOrderId)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS Announcements (
|
||||
Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
Title VARCHAR(200) NOT NULL,
|
||||
Content LONGTEXT NOT NULL,
|
||||
Type VARCHAR(20) NOT NULL DEFAULT 'notice',
|
||||
ScopeOrgIds VARCHAR(500) NOT NULL DEFAULT '',
|
||||
IsPinned TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PublisherId INT NULL,
|
||||
PublishedAt DATETIME(6) NOT NULL,
|
||||
CreatedAt DATETIME(6) NOT NULL,
|
||||
KEY idx_announcements_type (Type, PublishedAt)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS AnnouncementReads (
|
||||
Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
AnnouncementId INT NOT NULL,
|
||||
UserId INT NOT NULL,
|
||||
ReadAt DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY uk_ann_read (AnnouncementId, UserId)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
""");
|
||||
|
||||
// 幂等系统数据种子(兼容旧库:DbSeeder 仅在空库执行,老库需要补齐角色/菜单/字典/公告)
|
||||
await EnsureSystemSeedAsync(db);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为已有数据的旧库补齐 RBAC/数据字典/公告等系统种子数据(幂等,按表是否为空判断)。
|
||||
/// </summary>
|
||||
public static async Task EnsureSystemSeedAsync(AppDbContext db)
|
||||
{
|
||||
// 全新库由 DbSeeder 负责完整种子,这里仅补齐旧库(已有用户数据)缺失的系统数据
|
||||
if (!await db.Users.AnyAsync()) return;
|
||||
|
||||
var now = DateTime.Now;
|
||||
|
||||
// ---------- 角色 ----------
|
||||
var roles = new List<SysRole>();
|
||||
if (!await db.SysRoles.AnyAsync())
|
||||
{
|
||||
roles =
|
||||
[
|
||||
new() { Name = "超级管理员", Code = "super_admin", Description = "系统内置:拥有全部权限", IsSystem = true, CreatedAt = now },
|
||||
new() { Name = "公司管理员", Code = "company_admin", Description = "管理本公司及下属收购站业务", IsSystem = true, CreatedAt = now },
|
||||
new() { Name = "收购站员工", Code = "station_staff", Description = "负责过磅称重、收购单录入", IsSystem = true, CreatedAt = now },
|
||||
new() { Name = "收购个体", Code = "individual", Description = "个体收购户", IsSystem = true, CreatedAt = now },
|
||||
];
|
||||
db.SysRoles.AddRange(roles);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
roles = await db.SysRoles.ToListAsync();
|
||||
}
|
||||
|
||||
// ---------- 菜单 / 权限 ----------
|
||||
if (!await db.SysMenus.AnyAsync())
|
||||
{
|
||||
var menus = new List<SysMenu>
|
||||
{
|
||||
new() { Name = "首页", Path = "/home", Component = "views/Home.vue", Icon = "HomeFilled", Type = "menu", Permission = "home:view", Sort = 1, CreatedAt = now },
|
||||
new() { Name = "收购业务", Path = "/weighing", Icon = "Van", Type = "directory", Sort = 2, CreatedAt = now },
|
||||
new() { Name = "过磅称重", Path = "/weighing", Component = "views/weighing/WeighingList.vue", Icon = "Odometer", Type = "menu", Permission = "weighing:list", Sort = 1, CreatedAt = now },
|
||||
new() { Name = "触摸屏过磅", Path = "/weighing/touch", Component = "views/weighing/WeighingTouch.vue", Icon = "Iphone", Type = "menu", Permission = "weighing:touch", Sort = 2, CreatedAt = now },
|
||||
new() { Name = "电子支付", Path = "/payments", Component = "views/payments/PaymentList.vue", Icon = "Wallet", Type = "menu", Permission = "payment:list", Sort = 3, CreatedAt = now },
|
||||
new() { Name = "反向开票", Path = "/invoices", Component = "views/invoices/InvoiceList.vue", Icon = "Tickets", Type = "menu", Permission = "invoice:list", Sort = 4, CreatedAt = now },
|
||||
new() { Name = "基础资料", Path = "/base", Icon = "FolderOpened", Type = "directory", Sort = 3, CreatedAt = now },
|
||||
new() { Name = "农户管理", Path = "/farmers", Component = "views/farmers/FarmerList.vue", Icon = "User", Type = "menu", Permission = "farmer:list", Sort = 1, CreatedAt = now },
|
||||
new() { Name = "品种管理", Path = "/products", Component = "views/products/ProductList.vue", Icon = "Goods", Type = "menu", Permission = "product:list", Sort = 2, CreatedAt = now },
|
||||
new() { Name = "统计报表", Path = "/reports", Icon = "DataAnalysis", Type = "directory", Sort = 4, CreatedAt = now },
|
||||
new() { Name = "收购报表", Path = "/reports/purchase", Component = "views/reports/ReportPurchase.vue", Icon = "TrendCharts", Type = "menu", Permission = "report:purchase", Sort = 1, CreatedAt = now },
|
||||
new() { Name = "付款报表", Path = "/reports/payment", Component = "views/reports/ReportPayment.vue", Icon = "Money", Type = "menu", Permission = "report:payment", Sort = 2, CreatedAt = now },
|
||||
new() { Name = "开票报表", Path = "/reports/invoice", Component = "views/reports/ReportInvoice.vue", Icon = "Document", Type = "menu", Permission = "report:invoice", Sort = 3, CreatedAt = now },
|
||||
new() { Name = "系统管理", Path = "/system", Icon = "Setting", Type = "directory", Sort = 5, CreatedAt = now },
|
||||
new() { Name = "组织管理", Path = "/orgs", Component = "views/orgs/OrgList.vue", Icon = "OfficeBuilding", Type = "menu", Permission = "system:org:list", Sort = 1, CreatedAt = now },
|
||||
new() { Name = "用户管理", Path = "/users", Component = "views/users/UserList.vue", Icon = "Avatar", Type = "menu", Permission = "system:user:list", Sort = 2, CreatedAt = now },
|
||||
new() { Name = "角色权限", Path = "/system/roles", Component = "views/system/RoleList.vue", Icon = "UserFilled", Type = "menu", Permission = "system:role:list", Sort = 3, CreatedAt = now },
|
||||
new() { Name = "菜单管理", Path = "/system/menus", Component = "views/system/MenuList.vue", Icon = "Menu", Type = "menu", Permission = "system:menu:list", Sort = 4, CreatedAt = now },
|
||||
new() { Name = "数据字典", Path = "/system/dicts", Component = "views/system/DictList.vue", Icon = "Notebook", Type = "menu", Permission = "system:dict:list", Sort = 5, CreatedAt = now },
|
||||
new() { Name = "通知公告", Path = "/notify", Icon = "Bell", Type = "directory", Sort = 6, CreatedAt = now },
|
||||
new() { Name = "公告管理", Path = "/notify/announcements", Component = "views/notify/AnnouncementList.vue", Icon = "Bell", Type = "menu", Permission = "announcement:list", Sort = 1, CreatedAt = now },
|
||||
new() { Name = "关于系统", Path = "/about", Component = "views/about/About.vue", Icon = "InfoFilled", Type = "menu", Permission = "about:view", Sort = 7, CreatedAt = now },
|
||||
};
|
||||
db.SysMenus.AddRange(menus);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
SetChild(menus, "/weighing", "/weighing");
|
||||
SetChild(menus, "/weighing/touch", "/weighing");
|
||||
SetChild(menus, "/payments", "/weighing");
|
||||
SetChild(menus, "/invoices", "/weighing");
|
||||
SetChild(menus, "/farmers", "/base");
|
||||
SetChild(menus, "/products", "/base");
|
||||
SetChild(menus, "/reports/purchase", "/reports");
|
||||
SetChild(menus, "/reports/payment", "/reports");
|
||||
SetChild(menus, "/reports/invoice", "/reports");
|
||||
SetChild(menus, "/orgs", "/system");
|
||||
SetChild(menus, "/users", "/system");
|
||||
SetChild(menus, "/system/roles", "/system");
|
||||
SetChild(menus, "/system/menus", "/system");
|
||||
SetChild(menus, "/system/dicts", "/system");
|
||||
SetChild(menus, "/notify/announcements", "/notify");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// 角色-菜单分配:超级管理员全部;公司管理员除系统管理外全部;收购站员工常用菜单
|
||||
var allMenuIds = menus.Select(m => m.Id).ToArray();
|
||||
var admin = roles.First(r => r.Code == "super_admin");
|
||||
var company = roles.First(r => r.Code == "company_admin");
|
||||
var station = roles.First(r => r.Code == "station_staff");
|
||||
db.SysRoleMenus.AddRange(allMenuIds.Select(mid => new SysRoleMenu { RoleId = admin.Id, MenuId = mid }));
|
||||
db.SysRoleMenus.AddRange(menus.Where(m => m.Type == "menu" && !m.Permission.StartsWith("system:"))
|
||||
.Select(m => new SysRoleMenu { RoleId = company.Id, MenuId = m.Id }));
|
||||
db.SysRoleMenus.AddRange(menus.Where(m => m.Type == "menu" && m.Permission is "home:view" or "weighing:list" or "weighing:touch" or "farmer:list" or "about:view")
|
||||
.Select(m => new SysRoleMenu { RoleId = station.Id, MenuId = m.Id }));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---------- 菜单层级修复:过磅称重/触摸屏过磅 归入 收购业务 目录(幂等,兼容已有数据的库) ----------
|
||||
var weighingDir = await db.SysMenus.FirstOrDefaultAsync(m => m.Type == "directory" && m.Path == "/weighing");
|
||||
if (weighingDir != null)
|
||||
{
|
||||
var moveTargets = await db.SysMenus
|
||||
.Where(m => m.Type == "menu" && (m.Path == "/weighing" || m.Path == "/weighing/touch"))
|
||||
.ToListAsync();
|
||||
var menuChanged = false;
|
||||
foreach (var m in moveTargets)
|
||||
{
|
||||
if (m.ParentId != weighingDir.Id)
|
||||
{
|
||||
m.ParentId = weighingDir.Id;
|
||||
menuChanged = true;
|
||||
}
|
||||
}
|
||||
if (menuChanged) await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---------- 已有用户按用户名绑定角色 ----------
|
||||
var roleByUser = new Dictionary<string, string>
|
||||
{
|
||||
["admin"] = "super_admin",
|
||||
["company"] = "company_admin",
|
||||
["station1"] = "station_staff",
|
||||
["station2"] = "station_staff",
|
||||
["individual"] = "individual",
|
||||
};
|
||||
var users = await db.Users.Where(u => u.RoleId == null).ToListAsync();
|
||||
foreach (var u in users)
|
||||
{
|
||||
if (roleByUser.TryGetValue(u.Username, out var code))
|
||||
{
|
||||
var role = roles.FirstOrDefault(r => r.Code == code);
|
||||
if (role != null) u.RoleId = role.Id;
|
||||
}
|
||||
}
|
||||
if (users.Count > 0) await db.SaveChangesAsync();
|
||||
|
||||
// ---------- 数据字典 ----------
|
||||
if (!await db.SysDicts.AnyAsync())
|
||||
{
|
||||
var dicts = new List<SysDict>
|
||||
{
|
||||
new() { Name = "收购等级", Code = "purchase_grade", Remark = "收购单磅次等级", IsSystem = true, Sort = 1, CreatedAt = now,
|
||||
Items =
|
||||
{
|
||||
new() { Label = "一等", Value = "一等", IsDefault = true, Sort = 1 },
|
||||
new() { Label = "二等", Value = "二等", Sort = 2 },
|
||||
new() { Label = "三等", Value = "三等", Sort = 3 },
|
||||
new() { Label = "级外", Value = "级外", Sort = 4 },
|
||||
} },
|
||||
new() { Name = "价格类型", Code = "price_type", Remark = "过磅计价方式", IsSystem = true, Sort = 2, CreatedAt = now,
|
||||
Items =
|
||||
{
|
||||
new() { Label = "固定价格", Value = "Fixed", Ext = "按产品默认价", IsDefault = true, Sort = 1 },
|
||||
new() { Label = "现场议价", Value = "Manual", Ext = "与农户协商价", Sort = 2 },
|
||||
new() { Label = "价格区间", Value = "Range", Ext = "区间内浮动价", Sort = 3 },
|
||||
} },
|
||||
new() { Name = "票据格式", Code = "receipt_format", Remark = "小票/票据打印格式", IsSystem = true, Sort = 3, CreatedAt = now,
|
||||
Items =
|
||||
{
|
||||
new() { Label = "58mm 热敏小票", Value = "thermal_58", Ext = "58mm", IsDefault = true, Sort = 1 },
|
||||
new() { Label = "80mm 热敏小票", Value = "thermal_80", Ext = "80mm", Sort = 2 },
|
||||
new() { Label = "三等分针式票据", Value = "dotmatrix_3part", Ext = "241mm×139.7mm/联", Sort = 3 },
|
||||
new() { Label = "A5 激光打印", Value = "laser_a5", Ext = "A5", Sort = 4 },
|
||||
} },
|
||||
new() { Name = "公告类型", Code = "announcement_type", Remark = "通知公告类型", IsSystem = true, Sort = 4, CreatedAt = now,
|
||||
Items =
|
||||
{
|
||||
new() { Label = "通知", Value = "notice", Sort = 1 },
|
||||
new() { Label = "公告", Value = "announce", Sort = 2 },
|
||||
new() { Label = "价格行情", Value = "price", Sort = 3 },
|
||||
new() { Label = "政策法规", Value = "policy", Sort = 4 },
|
||||
} },
|
||||
};
|
||||
db.SysDicts.AddRange(dicts);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---------- 系统图标字典(幂等补齐,兼容已有数据的库) ----------
|
||||
if (!await db.SysDicts.AnyAsync(d => d.Code == "system_logo"))
|
||||
{
|
||||
db.SysDicts.Add(new SysDict
|
||||
{
|
||||
Name = "系统图标", Code = "system_logo", Remark = "主标题系统图标,可选大部分农产品图标", IsSystem = true, Sort = 5, CreatedAt = now,
|
||||
Items =
|
||||
{
|
||||
new() { Label = "苹果", Value = "Apple", IsDefault = true, Sort = 1 },
|
||||
new() { Label = "葡萄", Value = "Grape", Sort = 2 },
|
||||
new() { Label = "西瓜", Value = "Watermelon", Sort = 3 },
|
||||
new() { Label = "樱桃", Value = "Cherry", Sort = 4 },
|
||||
new() { Label = "梨", Value = "Pear", Sort = 5 },
|
||||
new() { Label = "桃", Value = "Peach", Sort = 6 },
|
||||
new() { Label = "橙子", Value = "Orange", Sort = 7 },
|
||||
new() { Label = "农作物", Value = "Food", Sort = 8 },
|
||||
new() { Label = "时蔬", Value = "Dish", Sort = 9 },
|
||||
new() { Label = "牛奶", Value = "Milk", Sort = 10 },
|
||||
new() { Label = "冰淇淋", Value = "IceCream", Sort = 11 },
|
||||
new() { Label = "生鲜礼盒", Value = "TakeawayBox", Sort = 12 },
|
||||
}
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---------- 通知公告(演示数据) ----------
|
||||
if (!await db.Announcements.AnyAsync())
|
||||
{
|
||||
var publisher = await db.Users.FirstOrDefaultAsync(u => u.Username == "admin");
|
||||
var adminRole = roles.FirstOrDefault(r => r.Code == "super_admin");
|
||||
db.Announcements.AddRange(
|
||||
new Announcement { Title = "欢迎使用农易富农产品收购交易平台", Content = "系统已完成升级,新增数据字典、角色权限、通知公告等功能。默认账号 admin / 123456。", Type = "announce", IsPinned = true, PublisherId = publisher?.Id ?? (adminRole != null ? db.Users.FirstOrDefault()?.Id : null), PublishedAt = now, CreatedAt = now },
|
||||
new Announcement { Title = "关于2026年夏季收购价格调整的通知", Content = "自8月15日起,一等品收购价调整为每公斤3.6元,请各收购站及时更新价格配置。", Type = "price", PublisherId = publisher?.Id, PublishedAt = now.AddHours(-3), CreatedAt = now.AddHours(-3) },
|
||||
new Announcement { Title = "电子秤串口连接使用说明", Content = "请在收购单新建界面点击电子秤区域连接串口,支持标准ASCII输出协议(如 ST,GS,+00123.45kg)。无串口设备时可使用手动输入模式。", Type = "notice", PublisherId = publisher?.Id, PublishedAt = now.AddDays(-1), CreatedAt = now.AddDays(-1) }
|
||||
);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetChild(List<SysMenu> menus, string childPath, string parentPath)
|
||||
{
|
||||
var parent = menus.FirstOrDefault(m => m.Type == "directory" && m.Path == parentPath);
|
||||
if (parent is null) return;
|
||||
foreach (var child in menus.Where(m => m.Type != "directory" && m.Path == childPath && m.ParentId == null))
|
||||
child.ParentId = parent.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,7 +7,7 @@ public record LoginResult(string Token, UserInfoDto User);
|
||||
|
||||
public record UserInfoDto(
|
||||
int Id, string Username, string RealName, string Phone, string Role,
|
||||
int? OrgId, string? OrgName, string? OrgType, bool IsActive);
|
||||
int? RoleId, string? RoleName, int? OrgId, string? OrgName, string? OrgType, bool IsActive);
|
||||
|
||||
public record ChangePasswordRequest(string OldPassword, string NewPassword);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using AgriculturalPlatform.Api.Models;
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
public record FarmerDto(
|
||||
int Id, string Name, string IdCard, string Gender, string FarmerType, string Phone,
|
||||
int Id, string Name, string PinyinInitials, string IdCard, string Gender, string FarmerType, string Phone,
|
||||
string Province, string County, string Township, string Village, string GroupName,
|
||||
string Address, string BankName, string BankAccount, int CreditScore,
|
||||
string IdCardFrontUrl, string IdCardBackUrl, string AvatarUrl,
|
||||
@@ -15,12 +15,12 @@ public record FarmerSaveRequest(
|
||||
string Address, string BankName, string BankAccount, int CreditScore = 80,
|
||||
string FarmerType = "农户",
|
||||
string IdCardFrontUrl = "", string IdCardBackUrl = "", string AvatarUrl = "",
|
||||
string Status = "Active", string Notes = "");
|
||||
string Status = "Active", string Notes = "", string PinyinInitials = "");
|
||||
|
||||
public static class FarmerMappers
|
||||
{
|
||||
public static FarmerDto ToDto(this Farmer f) => new(
|
||||
f.Id, f.Name, f.IdCard, f.Gender, f.FarmerType, f.Phone,
|
||||
f.Id, f.Name, f.PinyinInitials, f.IdCard, f.Gender, f.FarmerType, f.Phone,
|
||||
f.Province, f.County, f.Township, f.Village, f.GroupName,
|
||||
f.Address, f.BankName, f.BankAccount, f.CreditScore,
|
||||
f.IdCardFrontUrl, f.IdCardBackUrl, f.AvatarUrl,
|
||||
|
||||
@@ -2,23 +2,53 @@ using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
public record PurchaseWeighDto(
|
||||
int Id, int SortNo, string Grade, string PriceType, decimal UnitPrice,
|
||||
decimal GrossWeight, decimal TareWeight, decimal NetWeight, int BoxCount, decimal Amount, DateTime CreatedAt);
|
||||
|
||||
public record PurchaseTransferDto(
|
||||
int Id, string TransferNo, string FromFarmer, string ToFarmer, string ToIdCard,
|
||||
string Reason, string? OperatorName, DateTime CreatedAt);
|
||||
|
||||
public record PurchaseDto(
|
||||
int Id, string OrderNo, int FarmerId, string FarmerName, string? FarmerPhone,
|
||||
int ProductId, string ProductName, string Category, int PurchaserOrgId, string PurchaserOrgName,
|
||||
string Grade, string Unit, decimal UnitPrice, decimal GrossWeight, decimal TareWeight,
|
||||
decimal NetWeight, decimal Amount, int WeighCount, string Status,
|
||||
string? OperatorName, DateTime WeighInAt, DateTime? WeighOutAt, string Notes, DateTime CreatedAt);
|
||||
string? OperatorName, DateTime WeighInAt, DateTime? WeighOutAt, string Notes, DateTime CreatedAt,
|
||||
List<PurchaseWeighDto> Weighs, int BoxCount);
|
||||
|
||||
/// <summary>新建收购单(第一次过磅:称毛重)</summary>
|
||||
public record PurchaseCreateRequest(
|
||||
int FarmerId, int ProductId, int PurchaserOrgId, string Grade,
|
||||
decimal GrossWeight, string Unit = "公斤", decimal UnitPrice = 0, string Notes = "");
|
||||
|
||||
/// <summary>多磅收购单保存请求(一单多磅、多等级)</summary>
|
||||
public record PurchaseMultiCreateRequest(
|
||||
int FarmerId, int ProductId, int PurchaserOrgId, string Unit,
|
||||
string? Notes, List<PurchaseWeighSaveRequest> Weighs);
|
||||
|
||||
public record PurchaseWeighSaveRequest(
|
||||
string Grade, string PriceType, decimal UnitPrice, decimal GrossWeight,
|
||||
decimal TareWeight, int BoxCount, decimal? NetWeight);
|
||||
|
||||
/// <summary>回皮完成(第二次过磅:称皮重,计算净重与金额)</summary>
|
||||
public record PurchaseTareRequest(decimal TareWeight);
|
||||
|
||||
/// <summary>票据过户请求</summary>
|
||||
public record PurchaseTransferRequest(string ToFarmer, string ToIdCard, string Reason);
|
||||
|
||||
/// <summary>收购单统计(多维汇总)</summary>
|
||||
public record PurchaseSummaryDto(
|
||||
int OrderCount, decimal TotalNetWeight, decimal AvgPrice, decimal TotalAmount,
|
||||
int FarmerCount, int TodayOrderCount);
|
||||
|
||||
public static class PurchaseMappers
|
||||
{
|
||||
public static PurchaseWeighDto ToDto(this PurchaseWeigh w) => new(
|
||||
w.Id, w.SortNo, w.Grade, w.PriceType, w.UnitPrice,
|
||||
w.GrossWeight, w.TareWeight, w.NetWeight, w.BoxCount, w.Amount, w.CreatedAt);
|
||||
|
||||
public static PurchaseDto ToDto(this PurchaseOrder p) => new(
|
||||
p.Id, p.OrderNo,
|
||||
p.FarmerId, p.Farmer?.Name ?? "", p.Farmer?.Phone ?? "",
|
||||
@@ -26,7 +56,9 @@ public static class PurchaseMappers
|
||||
p.PurchaserOrgId, p.PurchaserOrg?.Name ?? "",
|
||||
p.Grade, p.Unit, p.UnitPrice, p.GrossWeight, p.TareWeight,
|
||||
p.NetWeight, p.Amount, p.WeighCount, p.Status.ToString(),
|
||||
p.Operator?.RealName ?? "", p.WeighInAt, p.WeighOutAt, p.Notes, p.CreatedAt);
|
||||
p.Operator?.RealName ?? "", p.WeighInAt, p.WeighOutAt, p.Notes, p.CreatedAt,
|
||||
p.Weighs?.OrderBy(w => w.SortNo).Select(w => w.ToDto()).ToList() ?? new List<PurchaseWeighDto>(),
|
||||
p.BoxCount);
|
||||
|
||||
public static PurchaseDto[] ToDtos(this IEnumerable<PurchaseOrder> items) => items.Select(ToDto).ToArray();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
// ---------- 数据字典 ----------
|
||||
public record DictDto(int Id, string Name, string Code, string Remark, bool IsSystem, int Sort, int ItemCount);
|
||||
public record DictSaveRequest(string Name, string Code, string Remark, int Sort);
|
||||
|
||||
public record DictItemDto(int Id, int DictId, string Label, string Value, string Ext, bool IsDefault, bool Enabled, int Sort);
|
||||
public record DictItemSaveRequest(string Label, string Value, string Ext, bool IsDefault, bool Enabled, int Sort);
|
||||
public record DictDetailDto(DictDto Dict, List<DictItemDto> Items);
|
||||
|
||||
// ---------- 菜单/权限 ----------
|
||||
public record MenuDto(
|
||||
int Id, int? ParentId, string Name, string Path, string Component, string Icon,
|
||||
string Type, string Permission, bool Visible, int Sort, List<MenuDto> Children);
|
||||
public record MenuSaveRequest(
|
||||
int? ParentId, string Name, string Path, string Component, string Icon,
|
||||
string Type, string Permission, bool Visible, int Sort);
|
||||
|
||||
// ---------- 角色 ----------
|
||||
public record RoleDto(int Id, string Name, string Code, string Description, bool IsSystem, int[] MenuIds);
|
||||
public record RoleSaveRequest(string Name, string Code, string Description, int[] MenuIds);
|
||||
|
||||
// ---------- 通知公告 ----------
|
||||
public record AnnouncementDto(
|
||||
int Id, string Title, string Content, string Type, string ScopeOrgIds, bool IsPinned,
|
||||
string PublisherName, DateTime PublishedAt, DateTime CreatedAt, bool IsRead);
|
||||
public record AnnouncementSaveRequest(string Title, string Content, string Type, string ScopeOrgIds, bool IsPinned);
|
||||
|
||||
// ---------- 关于 ----------
|
||||
public record AboutDto(
|
||||
string AppName, string Version, string Copyright, string Company, string License,
|
||||
string Description, DateTime BuiltAt);
|
||||
@@ -4,17 +4,17 @@ namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
public record UserDto(
|
||||
int Id, string Username, string RealName, string Phone, string Role,
|
||||
int? OrgId, string? OrgName, bool IsActive, DateTime? LastLoginAt, DateTime CreatedAt);
|
||||
int? RoleId, string? RoleName, int? OrgId, string? OrgName, bool IsActive, DateTime? LastLoginAt, DateTime CreatedAt);
|
||||
|
||||
public record UserSaveRequest(
|
||||
string Username, string RealName, string Phone, string Role,
|
||||
int? OrgId, bool IsActive = true, string? Password = null);
|
||||
int? OrgId, int? RoleId = null, bool IsActive = true, string? Password = null);
|
||||
|
||||
public static class UserMappers
|
||||
{
|
||||
public static UserDto ToDto(this User u) => new(
|
||||
u.Id, u.Username, u.RealName, u.Phone, u.Role.ToString(),
|
||||
u.OrgId, u.Org?.Name, u.IsActive, u.LastLoginAt, u.CreatedAt);
|
||||
u.RoleId, u.SysRole?.Name, u.OrgId, u.Org?.Name, u.IsActive, u.LastLoginAt, u.CreatedAt);
|
||||
|
||||
public static UserDto[] ToDtos(this IEnumerable<User> items) => items.Select(ToDto).ToArray();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>通知公告</summary>
|
||||
public class Announcement
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>内容(富文本/纯文本)</summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>类型(数据字典 announcement_type:notice=通知 announce=公告)</summary>
|
||||
public string Type { get; set; } = "notice";
|
||||
|
||||
/// <summary>发布范围组织 Id(逗号分隔;空=全部可见)</summary>
|
||||
public string ScopeOrgIds { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>是否置顶</summary>
|
||||
public bool IsPinned { get; set; }
|
||||
|
||||
/// <summary>发布人</summary>
|
||||
public int? PublisherId { get; set; }
|
||||
|
||||
public User? Publisher { get; set; }
|
||||
|
||||
public DateTime PublishedAt { get; set; } = DateTime.Now;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>公告已读记录</summary>
|
||||
public class AnnouncementRead
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int AnnouncementId { get; set; }
|
||||
|
||||
public int UserId { get; set; }
|
||||
|
||||
public DateTime ReadAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
@@ -7,6 +7,9 @@ public class Farmer
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>姓名拼音首字母(如:张三 → ZS,用于首字母检索)</summary>
|
||||
public string PinyinInitials { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>身份证号(唯一)</summary>
|
||||
public string IdCard { get; set; } = string.Empty;
|
||||
|
||||
|
||||
@@ -23,26 +23,35 @@ public class PurchaseOrder
|
||||
|
||||
public Organization? PurchaserOrg { get; set; }
|
||||
|
||||
/// <summary>等级(一等/二等/三等)</summary>
|
||||
/// <summary>等级(主等级,多等级时为首磅等级;完整多等级见 Weighs)</summary>
|
||||
public string Grade { get; set; } = string.Empty;
|
||||
|
||||
public string Unit { get; set; } = "公斤";
|
||||
|
||||
/// <summary>单价(元/单位)</summary>
|
||||
/// <summary>单价(元/单位,多磅时为首磅单价)</summary>
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>毛重(kg)</summary>
|
||||
/// <summary>毛重(kg,合计)</summary>
|
||||
public decimal GrossWeight { get; set; }
|
||||
|
||||
/// <summary>皮重(kg)</summary>
|
||||
/// <summary>皮重(kg,合计)</summary>
|
||||
public decimal TareWeight { get; set; }
|
||||
|
||||
/// <summary>净重(kg)= 毛重 - 皮重</summary>
|
||||
/// <summary>净重(kg,合计)= 毛重 - 皮重</summary>
|
||||
public decimal NetWeight { get; set; }
|
||||
|
||||
/// <summary>金额(元)= 净重 × 单价</summary>
|
||||
/// <summary>金额(元,合计)= Σ 净重 × 单价</summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>容器/框数合计</summary>
|
||||
public int BoxCount { get; set; }
|
||||
|
||||
/// <summary>磅次(多磅单每磅记录)</summary>
|
||||
public ICollection<PurchaseWeigh> Weighs { get; set; } = new List<PurchaseWeigh>();
|
||||
|
||||
/// <summary>过户记录</summary>
|
||||
public ICollection<PurchaseTransfer> Transfers { get; set; } = new List<PurchaseTransfer>();
|
||||
|
||||
/// <summary>过磅次数:1=已称毛重,2=已回皮完成</summary>
|
||||
public int WeighCount { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>收购票据过户记录</summary>
|
||||
public class PurchaseTransfer
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int PurchaseOrderId { get; set; }
|
||||
|
||||
public PurchaseOrder? PurchaseOrder { get; set; }
|
||||
|
||||
/// <summary>过户单号(如 GH20260812-0001)</summary>
|
||||
public string TransferNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>原农户(快照)</summary>
|
||||
public string FromFarmer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>过户到农户(名称快照)</summary>
|
||||
public string ToFarmer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>过户到农户身份证</summary>
|
||||
public string ToIdCard { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>过户原因</summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>经办人</summary>
|
||||
public int? OperatorId { get; set; }
|
||||
|
||||
public User? Operator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>收购单-单磅记录(一张收购单支持多磅、多等级)</summary>
|
||||
public class PurchaseWeigh
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int PurchaseOrderId { get; set; }
|
||||
|
||||
public PurchaseOrder? PurchaseOrder { get; set; }
|
||||
|
||||
/// <summary>磅次序号(第几磅)</summary>
|
||||
public int SortNo { get; set; }
|
||||
|
||||
/// <summary>等级(数据字典 purchase_grade)</summary>
|
||||
public string Grade { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>价格类型:Fixed=固定价 Manual=现场议价 Range=价格区间(数据字典 price_type)</summary>
|
||||
public string PriceType { get; set; } = "Manual";
|
||||
|
||||
/// <summary>单价(元/公斤)</summary>
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>毛重(kg)</summary>
|
||||
public decimal GrossWeight { get; set; }
|
||||
|
||||
/// <summary>皮重(kg)</summary>
|
||||
public decimal TareWeight { get; set; }
|
||||
|
||||
/// <summary>净重(kg)= 毛重 - 皮重</summary>
|
||||
public decimal NetWeight { get; set; }
|
||||
|
||||
/// <summary>容器/框数</summary>
|
||||
public int BoxCount { get; set; }
|
||||
|
||||
/// <summary>金额(元)= 净重 × 单价</summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>数据字典类型</summary>
|
||||
public class SysDict
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>字典名称(如:收购等级)</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>字典编码(如:purchase_grade / price_type / receipt_format / announcement_type)</summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
public string Remark { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>系统内置字典不可删除</summary>
|
||||
public bool IsSystem { get; set; }
|
||||
|
||||
public int Sort { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
|
||||
public ICollection<SysDictItem> Items { get; set; } = new List<SysDictItem>();
|
||||
}
|
||||
|
||||
/// <summary>数据字典项</summary>
|
||||
public class SysDictItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int DictId { get; set; }
|
||||
|
||||
/// <summary>显示名称(如:一等)</summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>值(如:1)</summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>扩展属性(如等级对应基准价、票据格式的纸张描述等)</summary>
|
||||
public string Ext { get; set; } = string.Empty;
|
||||
|
||||
public bool IsDefault { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>菜单/权限表(目录、菜单、按钮)</summary>
|
||||
public class SysMenu
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int? ParentId { get; set; }
|
||||
|
||||
/// <summary>菜单名称</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>路由路径(前端)</summary>
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>前端组件路径</summary>
|
||||
public string Component { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>图标</summary>
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>类型:directory=目录 menu=菜单 button=按钮</summary>
|
||||
public string Type { get; set; } = "menu";
|
||||
|
||||
/// <summary>权限标识(如 purchase:create、purchase:reprint)</summary>
|
||||
public string Permission { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>是否可见(不可见的仅作权限标识)</summary>
|
||||
public bool Visible { get; set; } = true;
|
||||
|
||||
public int Sort { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
|
||||
public ICollection<SysMenu> Children { get; set; } = new List<SysMenu>();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>角色</summary>
|
||||
public class SysRole
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>角色编码(如 super_admin / company_admin / station_staff / viewer)</summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>系统内置角色不可删除</summary>
|
||||
public bool IsSystem { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>角色-菜单关联</summary>
|
||||
public class SysRoleMenu
|
||||
{
|
||||
public int RoleId { get; set; }
|
||||
|
||||
public int MenuId { get; set; }
|
||||
}
|
||||
@@ -13,8 +13,14 @@ public class User
|
||||
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>兼容旧版角色枚举(新体系以 RoleId 为准)</summary>
|
||||
public UserRole Role { get; set; }
|
||||
|
||||
/// <summary>关联角色(RBAC 权限体系)</summary>
|
||||
public int? RoleId { get; set; }
|
||||
|
||||
public SysRole? SysRole { get; set; }
|
||||
|
||||
/// <summary>所属组织(公司管理员→公司;站员工→站;个体→个体组织)</summary>
|
||||
public int? OrgId { get; set; }
|
||||
|
||||
|
||||
@@ -81,8 +81,15 @@ builder.Services.AddSingleton<IFileStorage>(sp =>
|
||||
? sp.GetRequiredService<OssFileStorage>()
|
||||
: sp.GetRequiredService<LocalFileStorage>());
|
||||
|
||||
// ---------- OCR 识别(默认占位,接入阿里云/百度 OCR 时替换实现) ----------
|
||||
builder.Services.AddSingleton<IOcrService, DisabledOcrService>();
|
||||
// ---------- OCR 识别(阿里云市场身份证识别:Ocr:Provider=aliyun 时启用,否则占位提示) ----------
|
||||
builder.Services.AddHttpClient("ocr", c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||
builder.Services.AddSingleton<IOcrService>(sp =>
|
||||
{
|
||||
var cfg = sp.GetRequiredService<IConfiguration>();
|
||||
return string.Equals(cfg["Ocr:Provider"], "aliyun", StringComparison.OrdinalIgnoreCase)
|
||||
? new AliyunOcrService(cfg, sp.GetRequiredService<IHttpClientFactory>(), sp.GetRequiredService<IFileStorage>())
|
||||
: new DisabledOcrService(cfg);
|
||||
});
|
||||
|
||||
// ---------- 手机传图上传会话 ----------
|
||||
builder.Services.AddSingleton<MobileUploadSessionStore>();
|
||||
@@ -107,6 +114,7 @@ try
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
await SchemaMigrator.EnsureColumnsAsync(db);
|
||||
await SchemaMigrator.EnsureSystemTablesAsync(db);
|
||||
await SchemaMigrator.EnsureRegionsTableAsync(db);
|
||||
DbSeeder.Seed(db);
|
||||
app.Logger.LogInformation("数据库初始化完成");
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云市场身份证 OCR 识别服务。
|
||||
/// 接口:POST https://swcardpack.market.alicloudapi.com/ocr/idcard
|
||||
/// 认证:请求头 Authorization: APPCODE <AppCode>(无需 AppKey/AppSecret)
|
||||
/// 传输:优先 image_url(OSS 私有桶 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 AppCode(appsettings.json 的 Ocr:AppCode)");
|
||||
|
||||
var client = httpFactory.CreateClient("ocr");
|
||||
|
||||
// 方式一:image_url(OSS 签名 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] + "...";
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Net;
|
||||
using Aliyun.OSS;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>已保存文件的存储 key 与访问地址</summary>
|
||||
@@ -16,6 +19,12 @@ public interface IFileStorage
|
||||
|
||||
/// <summary>按 key 删除文件(本地/OSS 通用)</summary>
|
||||
Task DeleteAsync(string key);
|
||||
|
||||
/// <summary>打开文件流供读取(OSS 模式下用于私有桶代理访问),不存在返回 null</summary>
|
||||
Task<(Stream Stream, string ContentType)?> OpenReadAsync(string key);
|
||||
|
||||
/// <summary>生成临时访问 URL(仅 OSS 模式支持),本地存储返回 null</summary>
|
||||
string? GeneratePresignedUrl(string key);
|
||||
}
|
||||
|
||||
/// <summary>本地磁盘存储(wwwroot/uploads),URL 即相对路径,由静态文件中间件直接访问</summary>
|
||||
@@ -57,23 +66,123 @@ public sealed class LocalFileStorage(IWebHostEnvironment env) : IFileStorage
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(Stream Stream, string ContentType)?> OpenReadAsync(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || !key.StartsWith("/uploads/"))
|
||||
return Task.FromResult<(Stream, string)?>(null);
|
||||
var full = Path.Combine(_root, key.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(full)) return Task.FromResult<(Stream, string)?>(null);
|
||||
var contentType = _contentTypes[Path.GetExtension(full)] ?? "application/octet-stream";
|
||||
return Task.FromResult<(Stream, string)?>((File.OpenRead(full), contentType));
|
||||
}
|
||||
|
||||
/// <summary>本地存储无公开 URL,返回 null(OCR 会回退 base64 方式)</summary>
|
||||
public string? GeneratePresignedUrl(string key) => null;
|
||||
|
||||
private static readonly Dictionary<string, string> _contentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png",
|
||||
[".gif"] = "image/gif", [".webp"] = "image/webp", [".bmp"] = "image/bmp",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云 OSS 存储。授权验证(AccessKeyId/Secret、Endpoint、Bucket)后期配置提供;
|
||||
/// 未配置前调用会抛出 InvalidOperationException,由接口统一转为友好提示。
|
||||
/// 阿里云 OSS 存储。配置见 appsettings.json 的 Oss 节点(AccessKeyId / AccessKeySecret / 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"]);
|
||||
private readonly OssClient? _client = CreateClient(cfg);
|
||||
private readonly string? _bucket = cfg["Oss:Bucket"];
|
||||
|
||||
/// <summary>配置完整(AccessKeyId / AccessKeySecret / Endpoint / Bucket 均非空)时才能创建客户端</summary>
|
||||
private static OssClient? CreateClient(IConfiguration cfg)
|
||||
{
|
||||
var id = cfg["Oss:AccessKeyId"];
|
||||
var secret = cfg["Oss:AccessKeySecret"];
|
||||
var endpoint = cfg["Oss:Endpoint"];
|
||||
var bucket = cfg["Oss:Bucket"];
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(secret)
|
||||
|| string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(bucket))
|
||||
return null;
|
||||
return new OssClient(endpoint, id, secret);
|
||||
}
|
||||
|
||||
public bool IsConfigured => _client != null;
|
||||
|
||||
/// <summary>
|
||||
/// 上传到 OSS,返回 StoredFile。
|
||||
/// Key 使用 "uploads/{subDir}/{yyyyMM}/{guid}{ext}"(OSS 对象键不带前导斜杠)。
|
||||
/// URL 返回后端代理相对路径 /api/files/{key},由 FilesController 实时从私有桶拉流返回,
|
||||
/// 保证 Bucket 可保持私有(身份证等敏感信息不暴露公网)。
|
||||
/// </summary>
|
||||
public Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType)
|
||||
=> throw new InvalidOperationException(
|
||||
"阿里云 OSS 尚未配置授权验证,请在 appsettings.json 的 Oss 节点填写 AccessKeyId / AccessKeySecret / Endpoint / Bucket 后重启服务");
|
||||
{
|
||||
if (_client is null || string.IsNullOrWhiteSpace(_bucket))
|
||||
throw new InvalidOperationException(
|
||||
"阿里云 OSS 尚未配置授权验证,请在 appsettings.json 的 Oss 节点填写 AccessKeyId / AccessKeySecret / Endpoint / Bucket 后重启服务");
|
||||
|
||||
var dateDir = DateTime.Now.ToString("yyyyMM");
|
||||
var ext = Path.GetExtension(fileName);
|
||||
if (string.IsNullOrWhiteSpace(ext) || ext.Length > 10) ext = ".jpg";
|
||||
var key = $"uploads/{subDir}/{dateDir}/{Guid.NewGuid():N}{ext}";
|
||||
|
||||
var metadata = new ObjectMetadata { ContentType = contentType };
|
||||
_client.PutObject(_bucket, key, stream, metadata);
|
||||
|
||||
return Task.FromResult(new StoredFile(key, $"/api/files/{key}"));
|
||||
}
|
||||
|
||||
/// <summary>按 key 删除 OSS 对象(兼容本地存储带前导斜杠的 key 格式)</summary>
|
||||
public Task DeleteAsync(string key)
|
||||
=> throw new InvalidOperationException("阿里云 OSS 尚未配置授权验证,暂不支持删除");
|
||||
{
|
||||
if (_client is null || string.IsNullOrWhiteSpace(_bucket)) return Task.CompletedTask;
|
||||
if (string.IsNullOrWhiteSpace(key)) return Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
var objectKey = key.StartsWith('/') ? key.TrimStart('/') : key;
|
||||
if (objectKey.StartsWith("uploads/"))
|
||||
_client.DeleteObject(_bucket, objectKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>从私有桶拉取对象流(供代理接口使用),不存在返回 null</summary>
|
||||
public Task<(Stream Stream, string ContentType)?> OpenReadAsync(string key)
|
||||
{
|
||||
if (_client is null || string.IsNullOrWhiteSpace(_bucket)) return Task.FromResult<(Stream, string)?>(null);
|
||||
var objectKey = key.StartsWith('/') ? key.TrimStart('/') : key;
|
||||
if (!objectKey.StartsWith("uploads/")) return Task.FromResult<(Stream, string)?>(null);
|
||||
try
|
||||
{
|
||||
var obj = _client.GetObject(_bucket, objectKey);
|
||||
var contentType = obj.Metadata.ContentType ?? "application/octet-stream";
|
||||
return Task.FromResult<(Stream, string)?>((obj.Content, contentType));
|
||||
}
|
||||
catch (Exception ex) when (ex is Aliyun.OSS.Common.OssException or WebException or System.Net.Http.HttpRequestException)
|
||||
{
|
||||
return Task.FromResult<(Stream, string)?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>生成 10 分钟有效的私有桶临时访问 URL(供 OCR 等第三方服务拉取图片),失败返回 null</summary>
|
||||
public string? GeneratePresignedUrl(string key)
|
||||
{
|
||||
if (_client is null || string.IsNullOrWhiteSpace(_bucket)) return null;
|
||||
var objectKey = key.StartsWith('/') ? key.TrimStart('/') : key;
|
||||
if (!objectKey.StartsWith("uploads/")) return null;
|
||||
try
|
||||
{
|
||||
return _client.GeneratePresignedUri(_bucket, objectKey, DateTime.Now.AddMinutes(10)).ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public class JwtService(IConfiguration config)
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new("uid", user.Id.ToString()),
|
||||
new(ClaimTypes.Name, user.Username),
|
||||
new(ClaimTypes.Role, user.Role.ToString()),
|
||||
new("realName", user.RealName),
|
||||
|
||||
@@ -23,6 +23,7 @@ public class NumberGenerator(AppDbContext db)
|
||||
"PK" => await db.Invoices
|
||||
.Where(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd && x.BatchNo != "")
|
||||
.Select(x => x.BatchNo).Distinct().CountAsync(),
|
||||
"GH" => await db.PurchaseTransfers.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
|
||||
_ => 0
|
||||
};
|
||||
seq = count + 1;
|
||||
|
||||
@@ -17,14 +17,17 @@ public record IdCardOcrResult(
|
||||
/// </summary>
|
||||
public interface IOcrService
|
||||
{
|
||||
/// <summary>识别身份证图片,side: front(正面)/ back(反面)</summary>
|
||||
Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg");
|
||||
/// <summary>
|
||||
/// 识别身份证图片,side: front(正面)/ back(反面)。
|
||||
/// storageKey 为已保存文件的存储 key(OSS 模式下可据此生成临时访问 URL 传给识别服务)。
|
||||
/// </summary>
|
||||
Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg", string? storageKey = null);
|
||||
}
|
||||
|
||||
/// <summary>默认 OCR 实现:未配置凭证时给出友好提示,图片仍会正常保存为附件</summary>
|
||||
public sealed class DisabledOcrService(IConfiguration cfg) : IOcrService
|
||||
{
|
||||
public Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg")
|
||||
public Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg", string? storageKey = null)
|
||||
{
|
||||
var message = cfg["Ocr:DisabledMessage"]
|
||||
?? "未配置 OCR 识别服务(待接入阿里云/百度 OCR 凭证),图片已保存为附件,识别字段请手动填写";
|
||||
|
||||
@@ -24,17 +24,21 @@
|
||||
"LanPort": 5246
|
||||
},
|
||||
"Storage": {
|
||||
"UseOss": false
|
||||
"UseOss": true
|
||||
},
|
||||
"Oss": {
|
||||
"AccessKeyId": "",
|
||||
"AccessKeySecret": "",
|
||||
"Endpoint": "oss-cn-hangzhou.aliyuncs.com",
|
||||
"Bucket": "",
|
||||
"AccessKeyId": "LTAI5tSnK8HCPruXm3ZCRZDQ",
|
||||
"AccessKeySecret": "lHcL75fV809SJYZ3IJtMWlPM7qYUXK",
|
||||
"Endpoint": "oss-cn-chengdu.aliyuncs.com",
|
||||
"StsEndpoint": "sts.cn-chengdu.aliyuncs.com",
|
||||
"Bucket": "bbit-f8-web",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
"Ocr": {
|
||||
"Provider": "none",
|
||||
"Provider": "aliyun",
|
||||
"Url": "https://swcardpack.market.alicloudapi.com/ocr/idcard",
|
||||
"AppCode": "e3bdae1a73d2415492e7fdc024f07d08",
|
||||
"MaxBytes": 4194304,
|
||||
"DisabledMessage": "未配置 OCR 识别服务(待接入阿里云/百度 OCR 凭证),图片已保存为附件,识别字段请手动填写"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user