first commit

This commit is contained in:
yanpeng200212
2026-10-08 10:43:59 +08:00
commit 8430ba0452
288 changed files with 29595 additions and 0 deletions
@@ -0,0 +1,573 @@
using System.Text.Json;
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Attendance;
using F9WebAiTest.Application.Services.Notice;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities;
using F9WebAiTest.Domain.Entities.Attendance;
using F9WebAiTest.Domain.Entities.Notice;
using RBAC = F9WebAiTest.Domain.Entities.SYS.RBAC;
using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments;
using MediatR;
namespace F9WebAiTest.Application.Services.Attendance;
public class AttendanceService : IAttendanceService
{
private readonly IRepository<AttendanceRecord> _attRepo;
private readonly IRepository<LeaveRequest> _leaveRepo;
private readonly IRepository<LeaveApprover> _approverRepo;
private readonly IRepository<RBAC.User> _empRepo;
private readonly IRepository<SysDept.Department> _deptRepo;
private readonly IRepository<RBAC.Role> _roleRepo;
private readonly IRepository<RBAC.UserRole> _empRoleRepo;
private readonly IRepository<NotificationMessage> _msgRepo;
private readonly IRepository<NotificationReceiver> _receiverRepo;
private readonly IRepository<TodoItem> _todoRepo;
private readonly IRepository<NotificationType> _typeRepo;
private readonly IMediator _mediator;
// 上下班时间(可配置,这里简化固定)
private static readonly TimeSpan WorkStart = new(9, 0, 0);
private static readonly TimeSpan WorkEnd = new(18, 0, 0);
public AttendanceService(
IRepository<AttendanceRecord> attRepo,
IRepository<LeaveRequest> leaveRepo,
IRepository<LeaveApprover> approverRepo,
IRepository<RBAC.User> empRepo,
IRepository<SysDept.Department> deptRepo,
IRepository<RBAC.Role> roleRepo,
IRepository<RBAC.UserRole> empRoleRepo,
IRepository<NotificationMessage> msgRepo,
IRepository<NotificationReceiver> receiverRepo,
IRepository<TodoItem> todoRepo,
IRepository<NotificationType> typeRepo,
IMediator mediator)
{
_attRepo = attRepo;
_leaveRepo = leaveRepo;
_approverRepo = approverRepo;
_empRepo = empRepo;
_deptRepo = deptRepo;
_roleRepo = roleRepo;
_empRoleRepo = empRoleRepo;
_msgRepo = msgRepo;
_receiverRepo = receiverRepo;
_todoRepo = todoRepo;
_typeRepo = typeRepo;
_mediator = mediator;
}
public async Task<PunchResultDto> PunchAsync(long employeeId)
{
var now = DateTime.Now;
var today = now.Date;
var record = await _attRepo.FirstOrDefaultAsync(a => a.EmployeeId == employeeId && a.WorkDate == today);
// 签到
if (record == null)
{
var status = now.TimeOfDay <= WorkStart.Add(TimeSpan.FromMinutes(10)) ? "正常" : "迟到";
var newRecord = new AttendanceRecord
{
EmployeeId = employeeId,
WorkDate = today,
SignInTime = now,
SignInStatus = status,
SignOutStatus = "未签退"
};
await _attRepo.AddAsync(newRecord);
await _attRepo.SaveChangesAsync();
return new PunchResultDto
{
Type = "signIn",
Time = now,
Status = status,
Message = status == "正常" ? "签到成功,辛苦了!" : "签到成功(已迟到)"
};
}
// 已签到,进行签退
if (record.SignOutTime == null)
{
var status = now.TimeOfDay >= WorkEnd ? "正常" : "早退";
record.SignOutTime = now;
record.SignOutStatus = status;
await _attRepo.UpdateAsync(record);
await _attRepo.SaveChangesAsync();
return new PunchResultDto
{
Type = "signOut",
Time = now,
Status = status,
Message = status == "正常" ? "签退成功,明天见!" : "签退成功(早退)"
};
}
// 已签退
return new PunchResultDto
{
Type = "done",
Time = now,
Status = "已完成",
Message = "今日已签到和签退,无需重复打卡"
};
}
public async Task<PageResult<AttendanceDto>> GetMyRecordsAsync(long employeeId, int pageIndex, int pageSize)
{
var records = (await _attRepo.FindAsync(a => a.EmployeeId == employeeId))
.OrderByDescending(a => a.WorkDate)
.ToList();
var total = records.Count;
var items = records.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
var emp = await _empRepo.GetByIdAsync(employeeId);
var dept = emp != null ? await _deptRepo.GetByIdAsync(emp.DepartmentId) : null;
return new PageResult<AttendanceDto>
{
Items = items.Select(a => new AttendanceDto
{
Id = a.Id,
EmployeeId = a.EmployeeId,
EmployeeName = emp?.UserName ?? "",
DepartmentName = dept?.Name ?? "",
WorkDate = a.WorkDate,
SignInTime = a.SignInTime,
SignOutTime = a.SignOutTime,
SignInStatus = a.SignInStatus,
SignOutStatus = a.SignOutStatus,
Remark = a.Remark
}).ToList(),
Total = total,
PageIndex = pageIndex,
PageSize = pageSize
};
}
public async Task<AttendanceSummaryDto> GetMySummaryAsync(long employeeId, string month)
{
// month 格式 yyyy-MM,默认当前月
if (string.IsNullOrWhiteSpace(month))
{
month = DateTime.Now.ToString("yyyy-MM");
}
var start = DateTime.Parse(month + "-01");
var end = start.AddMonths(1);
var records = await _attRepo.FindAsync(a =>
a.EmployeeId == employeeId && a.WorkDate >= start && a.WorkDate < end);
var summary = new AttendanceSummaryDto();
foreach (var r in records)
{
summary.TotalDays++;
if (r.SignInStatus == "迟到") summary.LateDays++;
if (r.SignOutStatus == "早退") summary.EarlyLeaveDays++;
if (r.SignInTime == null) summary.AbsentDays++;
}
summary.NormalDays = summary.TotalDays - summary.LateDays - summary.EarlyLeaveDays - summary.AbsentDays;
// 请假天数
var leaves = await _leaveRepo.FindAsync(l =>
l.EmployeeId == employeeId && l.Status == "已批准" && l.StartTime < end && l.EndTime >= start);
summary.LeaveDays = leaves.Sum(l => (l.EndTime - l.StartTime).Days + 1);
return summary;
}
public async Task<PageResult<AttendanceDto>> GetAllRecordsAsync(AttendanceQueryDto query)
{
var records = await _attRepo.GetAllAsync();
var filtered = records.AsQueryable();
if (!string.IsNullOrWhiteSpace(query.Date))
{
var date = DateTime.Parse(query.Date).Date;
filtered = filtered.Where(a => a.WorkDate == date);
}
var emps = await _empRepo.GetAllAsync();
var depts = await _deptRepo.GetAllAsync();
var empDict = emps.ToDictionary(e => e.Id, e => e);
var deptDict = depts.ToDictionary(d => d.Id, d => d.Name);
if (query.DepartmentId.HasValue)
{
var deptEmpIds = emps.Where(e => e.DepartmentId == query.DepartmentId.Value).Select(e => e.Id).ToHashSet();
filtered = filtered.Where(a => deptEmpIds.Contains(a.EmployeeId));
}
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
var kwEmpIds = emps.Where(e => e.UserName.Contains(query.Keyword) || e.LoginName.Contains(query.Keyword))
.Select(e => e.Id).ToHashSet();
filtered = filtered.Where(a => kwEmpIds.Contains(a.EmployeeId));
}
var total = filtered.LongCount();
var items = filtered.OrderByDescending(a => a.WorkDate).ThenByDescending(a => a.SignInTime)
.Skip((query.PageIndex - 1) * query.PageSize).Take(query.PageSize).ToList();
return new PageResult<AttendanceDto>
{
Items = items.Select(a =>
{
var emp = empDict.GetValueOrDefault(a.EmployeeId);
return new AttendanceDto
{
Id = a.Id,
EmployeeId = a.EmployeeId,
EmployeeName = emp?.UserName ?? "",
DepartmentName = emp != null ? deptDict.GetValueOrDefault(emp.DepartmentId, "") : "",
WorkDate = a.WorkDate,
SignInTime = a.SignInTime,
SignOutTime = a.SignOutTime,
SignInStatus = a.SignInStatus,
SignOutStatus = a.SignOutStatus,
Remark = a.Remark
};
}).ToList(),
Total = total,
PageIndex = query.PageIndex,
PageSize = query.PageSize
};
}
public async Task<LeaveRequestDto> ApplyLeaveAsync(long employeeId, CreateLeaveDto dto)
{
if (dto.EndTime < dto.StartTime)
throw new ServiceException("结束时间不能早于开始时间");
// 确定审批人:优先使用前端选择的审批人,为空则回退到管理员
var approverIds = (dto.ApproverIds ?? new List<long>()).Where(id => id > 0).Distinct().ToList();
if (approverIds.Count == 0)
approverIds = await GetApproverIdsAsync();
if (approverIds.Count == 0)
throw new ServiceException("没有可用的审批人,请联系管理员");
var firstApprover = await _empRepo.GetByIdAsync(approverIds[0]);
var leave = new LeaveRequest
{
EmployeeId = employeeId,
Type = dto.Type,
StartTime = dto.StartTime,
EndTime = dto.EndTime,
Reason = dto.Reason ?? "",
CurrentOrderNo = 1,
ApproverId = approverIds[0],
ApproverName = firstApprover?.UserName ?? ""
};
await _leaveRepo.AddAsync(leave);
await _leaveRepo.SaveChangesAsync();
// 创建审批节点(序号 = 审批顺序)
for (var i = 0; i < approverIds.Count; i++)
{
var emp = await _empRepo.GetByIdAsync(approverIds[i]);
await _approverRepo.AddAsync(new LeaveApprover
{
LeaveId = leave.Id,
ApproverId = approverIds[i],
ApproverName = emp?.UserName ?? "",
OrderNo = i + 1
});
}
await _approverRepo.SaveChangesAsync();
// 只给第一个审批人发待办(顺序审批,后续审批人待前一节点通过后才收到)
await SendLeaveApprovalAsync(leave, approverIds[0], 1, approverIds.Count);
// 业务单据已创建,推送事件让各端刷新(多端同步)
await _mediator.Publish(new BusinessChangedEvent("LeaveApproval", leave.Id.ToString(), "Created"));
return await ToLeaveDto(leave);
}
public async Task<List<LeaveRequestDto>> GetMyLeavesAsync(long employeeId)
{
var leaves = await _leaveRepo.FindAsync(l => l.EmployeeId == employeeId);
var result = new List<LeaveRequestDto>();
foreach (var l in leaves.OrderByDescending(l => l.CreatedAt))
{
result.Add(await ToLeaveDto(l));
}
return result;
}
public async Task<List<LeaveRequestDto>> GetAllLeavesAsync(string status)
{
var leaves = await _leaveRepo.GetAllAsync();
if (!string.IsNullOrWhiteSpace(status) && status != "全部")
{
leaves = leaves.Where(l => l.Status == status).ToList();
}
var result = new List<LeaveRequestDto>();
foreach (var l in leaves.OrderByDescending(l => l.CreatedAt))
{
result.Add(await ToLeaveDto(l));
}
return result;
}
public async Task<LeaveRequestDto> ApproveLeaveAsync(long leaveId, long approverId, string approverName, ApproveLeaveDto dto)
{
var leave = await _leaveRepo.GetByIdAsync(leaveId);
if (leave == null) throw new ServiceException("请假申请不存在", 404);
if (leave.Status != "待审批") throw new ServiceException("该申请已处理");
var approvers = (await _approverRepo.FindAsync(a => a.LeaveId == leaveId))
.OrderBy(a => a.OrderNo).ToList();
// 校验当前审批节点
var current = approvers.FirstOrDefault(a => a.OrderNo == leave.CurrentOrderNo);
if (current == null) throw new ServiceException("审批节点不存在", 404);
if (current.ApproverId != approverId)
throw new ServiceException($"当前应由「{current.ApproverName}」审批,您无权操作", 403);
if (current.Status != "待审批") throw new ServiceException("该节点已处理");
// 更新当前节点
var handleResult = dto.Approved ? "已批准" : "已驳回";
current.Status = handleResult;
current.ApproveRemark = dto.Remark ?? "";
current.ApprovedAt = DateTime.Now;
await _approverRepo.UpdateAsync(current);
if (!dto.Approved)
{
// 驳回:整体驳回,结束流程
leave.Status = "已驳回";
leave.ApproverId = approverId;
leave.ApproverName = approverName;
leave.ApproveRemark = dto.Remark ?? "";
leave.ApprovedAt = DateTime.Now;
await _leaveRepo.UpdateAsync(leave);
await _leaveRepo.SaveChangesAsync();
await _approverRepo.SaveChangesAsync();
// 流程终结:完成该单所有待办 + 标记消息已读,避免残留未读红点
await FinalizeLeaveApprovalAsync(leaveId, handleResult);
await NotifyLeaveResultAsync(leave, false, dto.Remark);
await _mediator.Publish(new BusinessChangedEvent("LeaveApproval", leaveId.ToString(), "StatusChanged"));
return await ToLeaveDto(leave);
}
// 批准:判断是否还有下一级审批人
var next = approvers.FirstOrDefault(a => a.OrderNo == leave.CurrentOrderNo + 1);
if (next != null)
{
// 流转到下一级审批人
leave.CurrentOrderNo = next.OrderNo;
leave.ApproverId = next.ApproverId;
leave.ApproverName = next.ApproverName;
await _leaveRepo.UpdateAsync(leave);
await _leaveRepo.SaveChangesAsync();
await _approverRepo.SaveChangesAsync();
// 完成当前审批人待办并标记其消息已读,再把同一条消息流转给下一审批人
await CompleteCurrentApproverAsync(leaveId, approverId, handleResult);
await SendLeaveApprovalAsync(leave, next.ApproverId, next.OrderNo, approvers.Count);
}
else
{
// 最后一级审批人批准:整体批准
leave.Status = "已批准";
leave.ApproverId = approverId;
leave.ApproverName = approverName;
leave.ApproveRemark = dto.Remark ?? "";
leave.ApprovedAt = DateTime.Now;
await _leaveRepo.UpdateAsync(leave);
await _leaveRepo.SaveChangesAsync();
await _approverRepo.SaveChangesAsync();
// 流程终结
await FinalizeLeaveApprovalAsync(leaveId, handleResult);
await NotifyLeaveResultAsync(leave, true, dto.Remark);
}
// 业务状态已变更,推送事件让各端刷新(多端同步)
await _mediator.Publish(new BusinessChangedEvent("LeaveApproval", leaveId.ToString(), "StatusChanged"));
return await ToLeaveDto(leave);
}
/// <summary>查询审批人(admin 角色用户)ID 列表(未指定审批人时的兜底)</summary>
private async Task<List<long>> GetApproverIdsAsync()
{
var adminRole = await _roleRepo.FirstOrDefaultAsync(r => r.Code == "admin");
if (adminRole == null) return new List<long>();
var relations = await _empRoleRepo.FindAsync(ur => ur.RoleId == adminRole.Id);
return relations.Select(ur => ur.UserId).Distinct().ToList();
}
/// <summary>
/// 请假审批待办消息:一张请假单仅保留一条,随审批流转复用。
/// 首次创建,流转时更新内容与当前审批人并重置为待投递(投递服务幂等,只推新审批人)。
/// </summary>
private async Task SendLeaveApprovalAsync(LeaveRequest leave, long targetApproverId, int currentLevel, int totalLevel)
{
var businessType = NotificationTypeEnum.LeaveApproval.ToString();
var businessId = leave.Id.ToString();
var content = $"类型:{leave.Type},时间:{leave.StartTime:MM-dd HH:mm} ~ {leave.EndTime:MM-dd HH:mm},事由:{leave.Reason}(第 {currentLevel}/{totalLevel} 级审批)";
var msg = await _msgRepo.FirstOrDefaultAsync(m =>
m.BusinessType == businessType && m.BusinessId == businessId);
if (msg == null)
{
var type = await _typeRepo.FirstOrDefaultAsync(t => t.Code == businessType);
var applicant = await _empRepo.GetByIdAsync(leave.EmployeeId);
msg = new NotificationMessage
{
NotificationTypeId = type?.Id ?? 0,
MessageTypeCode = businessType,
BusinessType = businessType,
BusinessId = businessId,
Title = $"{applicant?.UserName ?? "员工"} 提交了请假申请",
Content = content,
Priority = NotificationPriority.Important,
Status = NotificationStatus.Ready,
ForceReceivers = JsonSerializer.Serialize(new List<long> { targetApproverId }),
CreatorId = leave.EmployeeId,
CreatorName = applicant?.UserName ?? "",
Category = NotificationCategory.Todo,
RoutePath = !string.IsNullOrWhiteSpace(type?.RouteTemplate)
? type.RouteTemplate.Replace("{businessId}", businessId)
: $"/attendance/approve?leaveId={businessId}"
};
await _msgRepo.AddAsync(msg);
await _msgRepo.SaveChangesAsync();
}
else
{
// 复用:更新内容与当前审批人,重置为待投递,投递服务幂等只会推送给新审批人
msg.Content = content;
msg.ForceReceivers = JsonSerializer.Serialize(new List<long> { targetApproverId });
msg.Status = NotificationStatus.Ready;
await _msgRepo.UpdateAsync(msg);
await _msgRepo.SaveChangesAsync();
}
// 当前审批人的待办(幂等:已存在则跳过)
var existTodo = await _todoRepo.FirstOrDefaultAsync(t => t.MessageId == msg.Id && t.AssigneeId == targetApproverId);
if (existTodo == null)
{
await _todoRepo.AddAsync(new TodoItem
{
MessageId = msg.Id,
AssigneeId = targetApproverId,
TodoStatus = TodoItemStatus.Pending
});
await _todoRepo.SaveChangesAsync();
}
}
/// <summary>完成当前审批人的待办,并标记其消息已读(流转时调用,避免已处理人残留未读红点)</summary>
private async Task CompleteCurrentApproverAsync(long leaveId, long approverId, string handleResult)
{
var msgs = await _msgRepo.FindAsync(m =>
m.BusinessType == NotificationTypeEnum.LeaveApproval.ToString() && m.BusinessId == leaveId.ToString());
var msgIds = msgs.Select(m => m.Id).ToList();
if (msgIds.Count == 0) return;
var todos = await _todoRepo.FindAsync(t =>
msgIds.Contains(t.MessageId) && t.AssigneeId == approverId && t.TodoStatus == TodoItemStatus.Pending);
foreach (var t in todos)
{
t.TodoStatus = TodoItemStatus.Completed;
t.CompletedTime = DateTime.Now;
t.HandleResult = handleResult;
await _todoRepo.UpdateAsync(t);
}
var receivers = await _receiverRepo.FindAsync(r =>
msgIds.Contains(r.MessageId) && r.UserId == approverId && r.ReadStatus == NotificationReadStatus.Unread);
foreach (var r in receivers)
{
r.ReadStatus = NotificationReadStatus.Read;
r.ReadTime = DateTime.Now;
await _receiverRepo.UpdateAsync(r);
}
}
/// <summary>流程终结:完成该单所有待处理待办,并标记所有未读消息为已读</summary>
private async Task FinalizeLeaveApprovalAsync(long leaveId, string handleResult)
{
var msgs = await _msgRepo.FindAsync(m =>
m.BusinessType == NotificationTypeEnum.LeaveApproval.ToString() && m.BusinessId == leaveId.ToString());
var msgIds = msgs.Select(m => m.Id).ToList();
if (msgIds.Count == 0) return;
var todos = await _todoRepo.FindAsync(t => msgIds.Contains(t.MessageId) && t.TodoStatus == TodoItemStatus.Pending);
foreach (var t in todos)
{
t.TodoStatus = TodoItemStatus.Completed;
t.CompletedTime = DateTime.Now;
t.HandleResult = handleResult;
await _todoRepo.UpdateAsync(t);
}
var receivers = await _receiverRepo.FindAsync(r => msgIds.Contains(r.MessageId) && r.ReadStatus == NotificationReadStatus.Unread);
foreach (var r in receivers)
{
r.ReadStatus = NotificationReadStatus.Read;
r.ReadTime = DateTime.Now;
await _receiverRepo.UpdateAsync(r);
}
}
/// <summary>通知申请人审批结果</summary>
private async Task NotifyLeaveResultAsync(LeaveRequest leave, bool approved, string remark)
{
await _mediator.Send(new SendNotificationCommand(
NotificationTypeEnum.LeaveResult,
BusinessId: leave.Id.ToString(),
Title: approved ? "请假申请已批准" : "请假申请已驳回",
Content: $"您的请假({leave.Type},{leave.StartTime:MM-dd HH:mm} ~ {leave.EndTime:MM-dd HH:mm})已{(approved ? "批准" : "驳回")}。审批意见:{remark}",
ForceReceivers: new List<long> { leave.EmployeeId }
));
}
private async Task<LeaveRequestDto> ToLeaveDto(LeaveRequest l)
{
var emp = await _empRepo.GetByIdAsync(l.EmployeeId);
var dept = emp != null ? await _deptRepo.GetByIdAsync(emp.DepartmentId) : null;
var approvers = (await _approverRepo.FindAsync(a => a.LeaveId == l.Id))
.OrderBy(a => a.OrderNo)
.Select(a => new LeaveApproverDto
{
Id = a.Id,
ApproverId = a.ApproverId,
ApproverName = a.ApproverName,
OrderNo = a.OrderNo,
Status = a.Status,
ApproveRemark = a.ApproveRemark,
ApprovedAt = a.ApprovedAt
})
.ToList();
return new LeaveRequestDto
{
Id = l.Id,
EmployeeId = l.EmployeeId,
EmployeeName = emp?.UserName ?? "",
DepartmentName = dept?.Name ?? "",
Type = l.Type,
StartTime = l.StartTime,
EndTime = l.EndTime,
Reason = l.Reason,
Status = l.Status,
CurrentOrderNo = l.CurrentOrderNo,
ApproverName = l.ApproverName,
ApproveRemark = l.ApproveRemark,
ApprovedAt = l.ApprovedAt,
CreatedAt = l.CreatedAt,
Approvers = approvers
};
}
}
@@ -0,0 +1,26 @@
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Attendance;
namespace F9WebAiTest.Application.Services.Attendance;
public interface IAttendanceService
{
// 打卡
Task<PunchResultDto> PunchAsync(long employeeId);
// 我的考勤记录(分页)
Task<PageResult<AttendanceDto>> GetMyRecordsAsync(long employeeId, int pageIndex, int pageSize);
// 我的考勤统计
Task<AttendanceSummaryDto> GetMySummaryAsync(long employeeId, string month);
// 全部考勤记录(管理员)
Task<PageResult<AttendanceDto>> GetAllRecordsAsync(AttendanceQueryDto query);
// 请假
Task<LeaveRequestDto> ApplyLeaveAsync(long employeeId, CreateLeaveDto dto);
Task<List<LeaveRequestDto>> GetMyLeavesAsync(long employeeId);
Task<List<LeaveRequestDto>> GetAllLeavesAsync(string status);
Task<LeaveRequestDto> ApproveLeaveAsync(long leaveId, long approverId, string approverName, ApproveLeaveDto dto);
}
@@ -0,0 +1,300 @@
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Auth;
using F9WebAiTest.Application.Interfaces.Security;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities.SYS.Departments;
using F9WebAiTest.Domain.Entities.SYS.RBAC;
using F9WebAiTest.Domain.Enums.SYS.RBAC;
using Microsoft.Extensions.Configuration;
using RefreshToken = F9WebAiTest.Domain.Entities.RefreshToken;
namespace F9WebAiTest.Application.Services.Auth;
public class AuthService : IAuthService
{
private readonly IRepository<User> _userRepo;
private readonly IRepository<UserRole> _userRoleRepo;
private readonly IRepository<RolePermission> _rolePermissionRepo;
private readonly IRepository<Permission> _permissionRepo;
private readonly IRepository<Menu> _menuRepo;
private readonly IRepository<Role> _roleRepo;
private readonly IRepository<Department> _deptRepo;
private readonly IRepository<RefreshToken> _refreshTokenRepo;
private readonly IJwtService _jwt;
private readonly IConfiguration _config;
private readonly IPasswordHasher _passwordHasher;
public AuthService(
IRepository<User> userRepo,
IRepository<UserRole> userRoleRepo,
IRepository<RolePermission> rolePermissionRepo,
IRepository<Permission> permissionRepo,
IRepository<Menu> menuRepo,
IRepository<Role> roleRepo,
IRepository<Department> deptRepo,
IRepository<RefreshToken> refreshTokenRepo,
IJwtService jwt,
IConfiguration config,
IPasswordHasher passwordHasher)
{
_userRepo = userRepo;
_userRoleRepo = userRoleRepo;
_rolePermissionRepo = rolePermissionRepo;
_permissionRepo = permissionRepo;
_menuRepo = menuRepo;
_roleRepo = roleRepo;
_deptRepo = deptRepo;
_refreshTokenRepo = refreshTokenRepo;
_jwt = jwt;
_config = config;
_passwordHasher = passwordHasher;
}
public async Task<LoginResultDto> LoginAsync(LoginDto dto)
{
var user = await _userRepo.FirstOrDefaultAsync(u => u.LoginName == dto.Username);
if (user == null || !VerifyPassword(dto.Password, user.PasswordHash))
throw new ServiceException("用户名或密码错误", 401);
if (!user.IsActive)
throw new ServiceException("账号已被禁用,请联系管理员", 403);
return await IssueTokensAsync(user);
}
public async Task<LoginResultDto> RefreshTokenAsync(string refreshToken)
{
var stored = await _refreshTokenRepo.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
if (stored == null || stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
throw new ServiceException("刷新令牌无效或已过期", 401);
var user = await _userRepo.GetByIdAsync(stored.EmployeeId);
if (user == null || !user.IsActive)
throw new ServiceException("用户不存在或已禁用", 401);
// 撤销旧 token,签发新 token(旋转)
stored.IsRevoked = true;
await _refreshTokenRepo.UpdateAsync(stored);
return await IssueTokensAsync(user);
}
public async Task RevokeRefreshTokenAsync(string refreshToken)
{
var stored = await _refreshTokenRepo.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
if (stored != null)
{
stored.IsRevoked = true;
await _refreshTokenRepo.UpdateAsync(stored);
await _refreshTokenRepo.SaveChangesAsync();
}
}
public async Task<EmployeeInfoDto> GetCurrentUserAsync(long userId)
{
var user = await _userRepo.GetByIdAsync(userId);
if (user == null) throw new ServiceException("用户不存在", 404);
return await BuildUserInfoAsync(user);
}
public async Task<List<MenuDto>> GetUserMenusAsync(long userId)
{
var roleIds = (await _userRoleRepo.FindAsync(ur => ur.UserId == userId))
.Select(ur => ur.RoleId).ToList();
// 超级管理员返回全部菜单
var isAdmin = await IsAdminAsync(userId);
List<Menu> menus;
if (isAdmin)
{
menus = await _menuRepo.FindAsync(m => m.IsActive);
}
else
{
// 通过 Permission 表关联菜单:RolePermission → Permission(ResourceType=菜单) → ResourceId(=Menu.Id)
var permissionIds = (await _rolePermissionRepo.FindAsync(rp => roleIds.Contains(rp.RoleId)))
.Select(rp => rp.PermissionId).Distinct().ToList();
var permittedMenuIds = (await _permissionRepo.FindAsync(p =>
permissionIds.Contains(p.Id) && p.ResourceType == Resource_Type_Enum.菜单))
.Select(p => p.ResourceId).Distinct().ToHashSet();
// 全部活跃菜单
var allMenus = await _menuRepo.FindAsync(m => m.IsActive);
var menuDict = allMenus.ToDictionary(m => m.Id);
// 有权限的菜单 + 它们的祖先目录(父/祖目录自动包含,保证菜单树完整,非管理员也能看到一级菜单)
var resultIds = new HashSet<long>();
foreach (var id in permittedMenuIds)
{
var currentId = id;
while (currentId != 0 && menuDict.ContainsKey(currentId) && resultIds.Add(currentId))
{
currentId = menuDict[currentId].ParentId;
}
}
menus = allMenus.Where(m => resultIds.Contains(m.Id)).ToList();
}
// 只返回目录和菜单(分组/页面),按排序
menus = menus.Where(m => m.MenuType != MenuType_Enum.默认)
.OrderBy(m => m.SortOrder).ToList();
return BuildMenuTree(menus);
}
public async Task<List<string>> GetUserPermissionsAsync(long userId)
{
var isAdmin = await IsAdminAsync(userId);
if (isAdmin)
{
// 超级管理员:返回所有权限单元 Code(含菜单权限 + 资源权限)
return (await _permissionRepo.FindAsync(p => !string.IsNullOrEmpty(p.Code)))
.Select(p => p.Code).Distinct().ToList();
}
// 普通用户:角色 → RolePermission → Permission → Code(统一权限模型)
var roleIds = (await _userRoleRepo.FindAsync(ur => ur.UserId == userId))
.Select(ur => ur.RoleId).ToList();
var permissionIds = (await _rolePermissionRepo.FindAsync(rp => roleIds.Contains(rp.RoleId)))
.Select(rp => rp.PermissionId).Distinct().ToList();
return (await _permissionRepo.FindAsync(p => permissionIds.Contains(p.Id) && !string.IsNullOrEmpty(p.Code)))
.Select(p => p.Code).Distinct().ToList();
}
public async Task ChangePasswordAsync(long userId, ChangePasswordDto dto)
{
var user = await _userRepo.GetByIdAsync(userId);
if (user == null) throw new ServiceException("用户不存在", 404);
if (!VerifyPassword(dto.OldPassword, user.PasswordHash))
throw new ServiceException("原密码错误");
if ((dto.NewPassword ?? "").Length < 6)
throw new ServiceException("新密码至少6位");
// 新密码与创建用户一致:BCrypt(MD5(明文))
user.SetPassword(_passwordHasher.HashPassword(_passwordHasher.GetMd5Hash(dto.NewPassword)));
await _userRepo.UpdateAsync(user);
await _userRepo.SaveChangesAsync();
}
public async Task UpdateProfileAsync(long userId, UpdateProfileDto dto)
{
var user = await _userRepo.GetByIdAsync(userId);
if (user == null) throw new ServiceException("用户不存在", 404);
user.UpdateProfile(dto.Nickname, dto.Avatar, dto.Email, dto.Phone, null);
await _userRepo.UpdateAsync(user);
await _userRepo.SaveChangesAsync();
}
// ---- 私有方法 ----
/// <summary>校验密码,兼容旧格式 BCrypt(明文) 与新格式 BCrypt(MD5(明文))</summary>
private bool VerifyPassword(string password, string passwordHash)
{
if (string.IsNullOrEmpty(passwordHash)) return false;
if (_passwordHasher.VerifyPassword(password, passwordHash)) return true;
return _passwordHasher.VerifyPassword(_passwordHasher.GetMd5Hash(password), passwordHash);
}
private async Task<LoginResultDto> IssueTokensAsync(User user)
{
var roles = await GetRoleCodesAsync(user.Id);
var accessToken = _jwt.GenerateAccessToken(user, roles);
var refreshToken = _jwt.GenerateRefreshToken();
var expireMinutes = int.Parse(_config["Jwt:AccessTokenExpireMinutes"] ?? "120");
// 存储 refresh token
var expireDays = int.Parse(_config["Jwt:RefreshTokenExpireDays"] ?? "7");
await _refreshTokenRepo.AddAsync(new RefreshToken
{
EmployeeId = user.Id,
Token = refreshToken,
ExpiresAt = DateTime.UtcNow.AddDays(expireDays)
});
await _refreshTokenRepo.SaveChangesAsync();
var userInfo = await BuildUserInfoAsync(user);
return new LoginResultDto
{
AccessToken = accessToken,
RefreshToken = refreshToken,
AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(expireMinutes),
User = userInfo
};
}
private async Task<List<string>> GetRoleCodesAsync(long userId)
{
var roleIds = (await _userRoleRepo.FindAsync(ur => ur.UserId == userId))
.Select(ur => ur.RoleId).ToList();
var roles = await _roleRepo.FindAsync(r => roleIds.Contains(r.Id));
return roles.Select(r => r.Code).ToList();
}
private async Task<bool> IsAdminAsync(long userId)
{
var roleIds = (await _userRoleRepo.FindAsync(ur => ur.UserId == userId))
.Select(ur => ur.RoleId).ToList();
var roles = await _roleRepo.FindAsync(r => roleIds.Contains(r.Id));
return roles.Any(r => r.Code == "admin");
}
private async Task<EmployeeInfoDto> BuildUserInfoAsync(User user)
{
var dept = await _deptRepo.GetByIdAsync(user.DepartmentId);
var roleIds = (await _userRoleRepo.FindAsync(ur => ur.UserId == user.Id))
.Select(ur => ur.RoleId).ToList();
var roles = await _roleRepo.FindAsync(r => roleIds.Contains(r.Id));
return new EmployeeInfoDto
{
Id = user.Id,
Username = user.LoginName,
RealName = user.UserName,
Nickname = user.Nickname,
Avatar = user.Avatar,
Phone = user.PhoneNumber,
Email = user.Email,
DepartmentId = user.DepartmentId,
DepartmentName = dept?.Name ?? "",
Status = user.IsActive ? 1 : 0,
Roles = roles.Select(r => r.Code).ToList(),
Permissions = await GetUserPermissionsAsync(user.Id)
};
}
private static List<MenuDto> BuildMenuTree(List<Menu> menus)
{
var dict = menus.ToDictionary(m => m.Id, m => new MenuDto
{
Id = m.Id,
ParentId = m.ParentId == 0 ? null : m.ParentId,
Name = m.Name,
Type = m.MenuType == MenuType_Enum.分组 ? "directory" : "menu",
Path = m.Path,
Component = m.Component,
Icon = m.IconSvg,
Permission = m.Permission,
Sort = m.SortOrder,
Status = m.IsActive ? 1 : 0
});
var roots = new List<MenuDto>();
foreach (var menu in menus)
{
var dto = dict[menu.Id];
if (menu.ParentId == 0 || !dict.ContainsKey(menu.ParentId))
{
roots.Add(dto);
}
else
{
dict[menu.ParentId].Children.Add(dto);
}
}
return roots.OrderBy(m => m.Sort).ToList();
}
}
@@ -0,0 +1,16 @@
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Auth;
namespace F9WebAiTest.Application.Services.Auth;
public interface IAuthService
{
Task<LoginResultDto> LoginAsync(LoginDto dto);
Task<LoginResultDto> RefreshTokenAsync(string refreshToken);
Task RevokeRefreshTokenAsync(string refreshToken);
Task<EmployeeInfoDto> GetCurrentUserAsync(long employeeId);
Task<List<MenuDto>> GetUserMenusAsync(long employeeId);
Task<List<string>> GetUserPermissionsAsync(long employeeId);
Task ChangePasswordAsync(long employeeId, ChangePasswordDto dto);
Task UpdateProfileAsync(long employeeId, UpdateProfileDto dto);
}
@@ -0,0 +1,11 @@
using F9WebAiTest.Domain.Entities.SYS.RBAC;
namespace F9WebAiTest.Application.Services.Auth;
public interface IJwtService
{
string GenerateAccessToken(User user, IEnumerable<string> roles);
string GenerateRefreshToken();
(long id, string username) ValidateAccessToken(string token);
long? ValidateRefreshToken(string token);
}
@@ -0,0 +1,102 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using F9WebAiTest.Domain.Entities.SYS.RBAC;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace F9WebAiTest.Application.Services.Auth;
public class JwtService : IJwtService
{
private readonly IConfiguration _config;
public JwtService(IConfiguration config)
{
_config = config;
}
// 生成 access token(短期,如 2 小时)
public string GenerateAccessToken(User user, IEnumerable<string> roles)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new("id", user.Id.ToString()),
new(ClaimTypes.Name, user.LoginName),
new("realName", user.UserName)
};
foreach (var r in roles)
{
claims.Add(new Claim(ClaimTypes.Role, r));
}
var expireMinutes = int.Parse(_config["Jwt:AccessTokenExpireMinutes"] ?? "120");
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expireMinutes),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
// 生成 refresh token(随机字符串,长期,如 7 天)
public string GenerateRefreshToken()
{
var randomNumber = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
public (long id, string username) ValidateAccessToken(string token)
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateIssuer = true,
ValidIssuer = _config["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = _config["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
var principal = handler.ValidateToken(token, validationParams, out _);
var id = long.Parse(principal.FindFirst("id")!.Value);
var username = principal.Identity!.Name;
return (id, username);
}
// 校验 refresh token 的签名(refresh token 也是 JWT,但只含 id claim)
public long? ValidateRefreshToken(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = false
};
var principal = handler.ValidateToken(token, validationParams, out _);
return long.Parse(principal.FindFirst("id")!.Value);
}
catch
{
return null;
}
}
}
@@ -0,0 +1,328 @@
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Base;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities;
using F9WebAiTest.Domain.Entities.Base;
using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments;
namespace F9WebAiTest.Application.Services.Base;
// ==================== 工艺庄口 ====================
public interface ICraftZhuangKouService
{
Task<PageResult<CraftZhuangKouDto>> GetPagedAsync(int pageIndex, int pageSize, string keyword, int? year);
Task<CraftZhuangKouDto> CreateAsync(CraftZhuangKouCreateDto dto);
Task<CraftZhuangKouDto> UpdateAsync(long id, CraftZhuangKouCreateDto dto);
Task DeleteAsync(long id);
}
public class CraftZhuangKouService : ICraftZhuangKouService
{
private readonly IRepository<CraftZhuangKou> _repo;
public CraftZhuangKouService(IRepository<CraftZhuangKou> repo) => _repo = repo;
private static string BendiWaidiName(int v) => v switch { 1 => "本地", 2 => "外地", _ => "" };
public async Task<PageResult<CraftZhuangKouDto>> GetPagedAsync(int pageIndex, int pageSize, string keyword, int? year)
{
var all = await _repo.GetAllAsync();
var q = all.AsQueryable();
if (!string.IsNullOrWhiteSpace(keyword))
q = q.Where(x => x.ZhuangKouName.Contains(keyword) || x.ZhuangKouCode.Contains(keyword) || x.ProductionDisplayName.Contains(keyword));
if (year.HasValue)
q = q.Where(x => x.BelongingYear == year.Value);
var total = q.LongCount();
var items = q.OrderBy(x => x.Sort).ThenBy(x => x.Id)
.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
return new PageResult<CraftZhuangKouDto>
{
Items = items.Select(ToDto).ToList(),
Total = total, PageIndex = pageIndex, PageSize = pageSize
};
}
public async Task<CraftZhuangKouDto> CreateAsync(CraftZhuangKouCreateDto dto)
{
if (string.IsNullOrWhiteSpace(dto.ZhuangKouName)) throw new ServiceException("庄口名称不能为空");
var e = new CraftZhuangKou
{
BelongingYear = dto.BelongingYear, BendiWaidi = dto.BendiWaidi, JidiWaigou = dto.JidiWaigou,
ZhuangKouCode = dto.ZhuangKouCode, ZhuangKouName = dto.ZhuangKouName,
ProductionDisplayName = dto.ProductionDisplayName, Chandi = dto.Chandi,
Sort = dto.Sort, Memo = dto.Memo, IsJiezhuang = dto.IsJiezhuang
};
await _repo.AddAsync(e);
await _repo.SaveChangesAsync();
return ToDto(e);
}
public async Task<CraftZhuangKouDto> UpdateAsync(long id, CraftZhuangKouCreateDto dto)
{
var e = await _repo.GetByIdAsync(id);
if (e == null) throw new ServiceException("记录不存在", 404);
e.BelongingYear = dto.BelongingYear; e.BendiWaidi = dto.BendiWaidi; e.JidiWaigou = dto.JidiWaigou;
e.ZhuangKouCode = dto.ZhuangKouCode; e.ZhuangKouName = dto.ZhuangKouName;
e.ProductionDisplayName = dto.ProductionDisplayName; e.Chandi = dto.Chandi;
e.Sort = dto.Sort; e.Memo = dto.Memo; e.IsJiezhuang = dto.IsJiezhuang;
await _repo.UpdateAsync(e);
await _repo.SaveChangesAsync();
return ToDto(e);
}
public async Task DeleteAsync(long id)
{
var e = await _repo.GetByIdAsync(id);
if (e == null) throw new ServiceException("记录不存在", 404);
await _repo.DeleteAsync(e);
await _repo.SaveChangesAsync();
}
private static CraftZhuangKouDto ToDto(CraftZhuangKou e) => new()
{
Id = e.Id, BelongingYear = e.BelongingYear, BendiWaidi = e.BendiWaidi, BendiWaidiName = BendiWaidiName(e.BendiWaidi),
JidiWaigou = e.JidiWaigou, ZhuangKouCode = e.ZhuangKouCode, ZhuangKouName = e.ZhuangKouName,
ProductionDisplayName = e.ProductionDisplayName, Chandi = e.Chandi, Sort = e.Sort, Memo = e.Memo, IsJiezhuang = e.IsJiezhuang
};
}
// ==================== 生丝规格 ====================
public interface IShengsiGuigeService
{
Task<PageResult<ShengsiGuigeDto>> GetPagedAsync(int pageIndex, int pageSize, string keyword);
Task<List<string>> GetAllGuigeAsync();
Task<ShengsiGuigeDto> CreateAsync(ShengsiGuigeDto dto);
Task<ShengsiGuigeDto> UpdateAsync(long id, ShengsiGuigeDto dto);
Task DeleteAsync(long id);
}
public class ShengsiGuigeService : IShengsiGuigeService
{
private readonly IRepository<ShengsiGuige> _repo;
public ShengsiGuigeService(IRepository<ShengsiGuige> repo) => _repo = repo;
public async Task<PageResult<ShengsiGuigeDto>> GetPagedAsync(int pageIndex, int pageSize, string keyword)
{
var all = await _repo.GetAllAsync();
var q = all.AsQueryable();
if (!string.IsNullOrWhiteSpace(keyword)) q = q.Where(x => x.Guige.Contains(keyword));
var total = q.LongCount();
var items = q.OrderBy(x => x.Sort).ThenBy(x => x.Id).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
return new PageResult<ShengsiGuigeDto> { Items = items.Select(ToDto).ToList(), Total = total, PageIndex = pageIndex, PageSize = pageSize };
}
public async Task<List<string>> GetAllGuigeAsync()
=> (await _repo.GetAllAsync()).OrderBy(x => x.Sort).Select(x => x.Guige).Distinct().ToList();
public async Task<ShengsiGuigeDto> CreateAsync(ShengsiGuigeDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Guige)) throw new ServiceException("规格不能为空");
var e = Map(new ShengsiGuige(), dto);
await _repo.AddAsync(e);
await _repo.SaveChangesAsync();
return ToDto(e);
}
public async Task<ShengsiGuigeDto> UpdateAsync(long id, ShengsiGuigeDto dto)
{
var e = await _repo.GetByIdAsync(id);
if (e == null) throw new ServiceException("记录不存在", 404);
Map(e, dto);
await _repo.UpdateAsync(e);
await _repo.SaveChangesAsync();
return ToDto(e);
}
public async Task DeleteAsync(long id)
{
var e = await _repo.GetByIdAsync(id);
if (e == null) throw new ServiceException("记录不存在", 404);
await _repo.DeleteAsync(e);
await _repo.SaveChangesAsync();
}
private static ShengsiGuige Map(ShengsiGuige e, ShengsiGuigeDto d)
{
e.Guige = d.Guige; e.CenterrangeLolimit = d.CenterrangeLolimit; e.CenterrangeUplimit = d.CenterrangeUplimit;
e.CenterXiandu = d.CenterXiandu; e.ValidLolimit = d.ValidLolimit; e.ValidUplimit = d.ValidUplimit;
e.XiaoyeLolimit = d.XiaoyeLolimit; e.XiaoyeUplimit = d.XiaoyeUplimit;
e.DayeLolimit = d.DayeLolimit; e.DayeUplimit = d.DayeUplimit;
e.TeyeLolimit = d.TeyeLolimit; e.TeyeUplimit = d.TeyeUplimit;
e.YexianNkLolimitLeft = d.YexianNkLolimitLeft; e.YexianNkLolimitRight = d.YexianNkLolimitRight;
e.YexianNkUplimitLeft = d.YexianNkUplimitLeft; e.YexianNkUplimitRight = d.YexianNkUplimitRight;
e.LaodongJingsaiYexianLolimit = d.LaodongJingsaiYexianLolimit; e.LaodongJingsaiYexianUplimit = d.LaodongJingsaiYexianUplimit;
e.Sort = d.Sort; e.Memo = d.Memo;
return e;
}
private static ShengsiGuigeDto ToDto(ShengsiGuige e) => new()
{
Id = e.Id, Guige = e.Guige, CenterrangeLolimit = e.CenterrangeLolimit, CenterrangeUplimit = e.CenterrangeUplimit,
CenterXiandu = e.CenterXiandu, ValidLolimit = e.ValidLolimit, ValidUplimit = e.ValidUplimit,
XiaoyeLolimit = e.XiaoyeLolimit, XiaoyeUplimit = e.XiaoyeUplimit,
DayeLolimit = e.DayeLolimit, DayeUplimit = e.DayeUplimit,
TeyeLolimit = e.TeyeLolimit, TeyeUplimit = e.TeyeUplimit,
YexianNkLolimitLeft = e.YexianNkLolimitLeft, YexianNkLolimitRight = e.YexianNkLolimitRight,
YexianNkUplimitLeft = e.YexianNkUplimitLeft, YexianNkUplimitRight = e.YexianNkUplimitRight,
LaodongJingsaiYexianLolimit = e.LaodongJingsaiYexianLolimit, LaodongJingsaiYexianUplimit = e.LaodongJingsaiYexianUplimit,
Sort = e.Sort, Memo = e.Memo
};
}
// ==================== 生丝定级指标 ====================
public interface IShengsiDingjizhibiaoService
{
Task<PageResult<ShengsiDingjizhibiaoDto>> GetPagedAsync(int pageIndex, int pageSize, string keyword, int? standard, string guige);
Task<ShengsiDingjizhibiaoDto> CreateAsync(ShengsiDingjizhibiaoDto dto);
Task<ShengsiDingjizhibiaoDto> UpdateAsync(long id, ShengsiDingjizhibiaoDto dto);
Task DeleteAsync(long id);
}
public class ShengsiDingjizhibiaoService : IShengsiDingjizhibiaoService
{
private readonly IRepository<ShengsiDingjizhibiao> _repo;
public ShengsiDingjizhibiaoService(IRepository<ShengsiDingjizhibiao> repo) => _repo = repo;
private static string StandardName(int v) => v switch { 0 => "厂标", 1 => "国标", 2 => "个人", _ => "" };
public async Task<PageResult<ShengsiDingjizhibiaoDto>> GetPagedAsync(int pageIndex, int pageSize, string keyword, int? standard, string guige)
{
var all = await _repo.GetAllAsync();
var q = all.AsQueryable();
if (!string.IsNullOrWhiteSpace(keyword)) q = q.Where(x => x.Grade.Contains(keyword));
if (standard.HasValue) q = q.Where(x => x.Standard == standard.Value);
if (!string.IsNullOrWhiteSpace(guige)) q = q.Where(x => x.Guige == guige);
var total = q.LongCount();
var items = q.OrderBy(x => x.Standard).ThenBy(x => x.Guige).ThenBy(x => x.GradeScore)
.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
return new PageResult<ShengsiDingjizhibiaoDto> { Items = items.Select(ToDto).ToList(), Total = total, PageIndex = pageIndex, PageSize = pageSize };
}
public async Task<ShengsiDingjizhibiaoDto> CreateAsync(ShengsiDingjizhibiaoDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Grade)) throw new ServiceException("等级不能为空");
var e = Map(new ShengsiDingjizhibiao(), dto);
await _repo.AddAsync(e);
await _repo.SaveChangesAsync();
return ToDto(e);
}
public async Task<ShengsiDingjizhibiaoDto> UpdateAsync(long id, ShengsiDingjizhibiaoDto dto)
{
var e = await _repo.GetByIdAsync(id);
if (e == null) throw new ServiceException("记录不存在", 404);
Map(e, dto);
await _repo.UpdateAsync(e);
await _repo.SaveChangesAsync();
return ToDto(e);
}
public async Task DeleteAsync(long id)
{
var e = await _repo.GetByIdAsync(id);
if (e == null) throw new ServiceException("记录不存在", 404);
await _repo.DeleteAsync(e);
await _repo.SaveChangesAsync();
}
private static ShengsiDingjizhibiao Map(ShengsiDingjizhibiao e, ShengsiDingjizhibiaoDto d)
{
e.Standard = d.Standard; e.Guige = d.Guige; e.Grade = d.Grade; e.GradeScore = d.GradeScore;
e.Piancha = d.Piancha; e.Dacha = d.Dacha; e.Erdu = d.Erdu; e.Sandu = d.Sandu;
e.Qingjie = d.Qingjie; e.Jiejing = d.Jiejing;
return e;
}
private static ShengsiDingjizhibiaoDto ToDto(ShengsiDingjizhibiao e) => new()
{
Id = e.Id, Standard = e.Standard, StandardName = StandardName(e.Standard), Guige = e.Guige, Grade = e.Grade,
GradeScore = e.GradeScore, Piancha = e.Piancha, Dacha = e.Dacha, Erdu = e.Erdu, Sandu = e.Sandu,
Qingjie = e.Qingjie, Jiejing = e.Jiejing
};
}
// ==================== 机组车台 ====================
public interface IJizuChetaiService
{
Task<PageResult<JizuChetaiDto>> GetPagedAsync(int pageIndex, int pageSize, string keyword);
Task<JizuChetaiDto> GetByDepIdAndJizuAsync(long deptId, int jizu);
Task<JizuChetaiDto> CreateAsync(JizuChetaiDto dto);
Task<JizuChetaiDto> UpdateAsync(long id, JizuChetaiDto dto);
Task DeleteAsync(long id);
}
public class JizuChetaiService : IJizuChetaiService
{
private readonly IRepository<JizuChetai> _repo;
private readonly IRepository<SysDept.Department> _deptRepo;
public JizuChetaiService(IRepository<JizuChetai> repo, IRepository<SysDept.Department> deptRepo)
{
_repo = repo; _deptRepo = deptRepo;
}
public async Task<PageResult<JizuChetaiDto>> GetPagedAsync(int pageIndex, int pageSize, string keyword)
{
var all = await _repo.GetAllAsync();
var q = all.AsQueryable();
var depts = await _deptRepo.GetAllAsync();
var deptDict = depts.ToDictionary(d => d.Id, d => d.Name);
if (!string.IsNullOrWhiteSpace(keyword))
{
var kwDeptIds = depts.Where(d => d.Name.Contains(keyword)).Select(d => d.Id).ToHashSet();
q = q.Where(x => kwDeptIds.Contains(x.DepartmentId) || x.Jizu.ToString().Contains(keyword));
}
var total = q.LongCount();
var items = q.OrderBy(x => x.DepartmentId).ThenBy(x => x.Jizu).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
return new PageResult<JizuChetaiDto>
{
Items = items.Select(x => new JizuChetaiDto
{
Id = x.Id, DepartmentId = x.DepartmentId, DepartmentName = deptDict.GetValueOrDefault(x.DepartmentId, ""),
Jizu = x.Jizu, ChetaiStart = x.ChetaiStart, ChetaiEnd = x.ChetaiEnd
}).ToList(),
Total = total, PageIndex = pageIndex, PageSize = pageSize
};
}
public async Task<JizuChetaiDto> GetByDepIdAndJizuAsync(long deptId, int jizu)
{
var e = await _repo.FirstOrDefaultAsync(x => x.DepartmentId == deptId && x.Jizu == jizu);
if (e == null) return null;
return await GetDto(e);
}
public async Task<JizuChetaiDto> CreateAsync(JizuChetaiDto dto)
{
var e = new JizuChetai { DepartmentId = dto.DepartmentId, Jizu = dto.Jizu, ChetaiStart = dto.ChetaiStart, ChetaiEnd = dto.ChetaiEnd };
await _repo.AddAsync(e);
await _repo.SaveChangesAsync();
return await GetDto(e);
}
public async Task<JizuChetaiDto> UpdateAsync(long id, JizuChetaiDto dto)
{
var e = await _repo.GetByIdAsync(id);
if (e == null) throw new ServiceException("记录不存在", 404);
e.DepartmentId = dto.DepartmentId; e.Jizu = dto.Jizu; e.ChetaiStart = dto.ChetaiStart; e.ChetaiEnd = dto.ChetaiEnd;
await _repo.UpdateAsync(e);
await _repo.SaveChangesAsync();
return await GetDto(e);
}
public async Task DeleteAsync(long id)
{
var e = await _repo.GetByIdAsync(id);
if (e == null) throw new ServiceException("记录不存在", 404);
await _repo.DeleteAsync(e);
await _repo.SaveChangesAsync();
}
private async Task<JizuChetaiDto> GetDto(JizuChetai e)
{
var dept = await _deptRepo.GetByIdAsync(e.DepartmentId);
return new JizuChetaiDto { Id = e.Id, DepartmentId = e.DepartmentId, DepartmentName = dept?.Name ?? "", Jizu = e.Jizu, ChetaiStart = e.ChetaiStart, ChetaiEnd = e.ChetaiEnd };
}
}
@@ -0,0 +1,14 @@
using MediatR;
namespace F9WebAiTest.Application.Services;
/// <summary>
/// 业务状态变更事件(通用)。
/// 业务单据状态变化时发布,Api 层订阅后通过 SignalR 推送给所有在线客户端,
/// 各端监听后刷新已打开的相关页面,实现多端实时同步。
/// </summary>
public record BusinessChangedEvent(
string BusinessType, // 业务类型,如 "LeaveApproval"
string BusinessId, // 业务单据 ID
string Action // 动作,如 "StatusChanged" / "Created" / "Completed"
) : INotification;
@@ -0,0 +1,26 @@
using F9WebAiTest.Application.Dtos.Notice;
namespace F9WebAiTest.Application.Services.Notice;
/// <summary>
/// 消息实时推送器(抽象)。
/// 定义在应用层、实现在 Api 层(依赖 SignalR 的 IHubContext 与在线登记表),
/// 避免应用层反向依赖表示层,同时让"创建消息即推送"这一动作可以写在命令处理器里。
/// </summary>
public interface INotificationPusher
{
/// <summary>
/// 判断用户当前是否在线。
/// 判定依据:该用户在 SignalR Hub 上是否有活动连接(由 Hub 连接/断开时登记的内存表得出)。
/// </summary>
bool IsOnline(long userId);
/// <summary>
/// 推送新消息给指定用户。
/// 返回 true 表示已推送给其在线连接;返回 false 表示用户离线(未推送,消息已落库,上线后从"我的消息"拉取)。
/// </summary>
Task<bool> PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default);
/// <summary>推送未读统计,用于消息角标实时同步(用户离线时无需推送)。</summary>
Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default);
}
@@ -0,0 +1,276 @@
using System.Text.Json;
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Notice;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities;
using F9WebAiTest.Domain.Entities.Notice;
using MediatR;
namespace F9WebAiTest.Application.Services.Notice;
/// <summary>
/// 业务系统触发消息创建的命令(通用化)。
/// 用法:await _mediator.Send(new CreateNotificationCommand { NotificationTypeCode = "LeaveApproval", ... });
/// </summary>
public class CreateNotificationCommand : IRequest<long>
{
public string NotificationTypeCode { get; set; } = ""; // 消息类型编码,如 "LeaveApproval"
public string BusinessType { get; set; } = ""; // 业务类型标识
public string BusinessId { get; set; } = ""; // 业务单据ID
public string Title { get; set; } = ""; // 标题
public string Content { get; set; } = ""; // 内容
public NotificationPriority Priority { get; set; } = NotificationPriority.Normal;
public string BizContext { get; set; } = ""; // 业务上下文(JSON)
public List<long> ForceReceivers { get; set; } = new(); // 强制接收人(绕过订阅)
public string RoutePath { get; set; } = ""; // 跳转路由(可选,覆盖类型模板)
public List<long> AssigneeIds { get; set; } = new(); // 待办处理人(Category=Todo 时生效)
public DateTime? DueTime { get; set; } // 待办截止时间
public long CreatorId { get; set; } // 创建人ID
public string CreatorName { get; set; } = ""; // 创建人姓名
}
/// <summary>
/// 业务侧发送消息的简洁命令(基于消息类型枚举)。
/// 用法(参照 CQRS 惯例):
/// var ok = await mediator.Send(new SendNotificationCommand(
/// NotificationTypeEnum.LeaveApproval,
/// BusinessId: leaveId.ToString(),
/// ForceReceivers: approverIds,
/// AssigneeIds: approverIds));
/// if (!ok) throw new F9OperationException("请假审批消息发送失败");
/// </summary>
public record SendNotificationCommand(
NotificationTypeEnum Type,
string BusinessId = "",
string Title = "",
string Content = "",
string BizContext = "",
NotificationPriority Priority = NotificationPriority.Normal,
List<long> ForceReceivers = null,
List<long> AssigneeIds = null,
DateTime? DueTime = null
) : IRequest<bool>;
public class SendNotificationCommandHandler : IRequestHandler<SendNotificationCommand, bool>
{
private readonly IMediator _mediator;
public SendNotificationCommandHandler(IMediator mediator) => _mediator = mediator;
public async Task<bool> Handle(SendNotificationCommand command, CancellationToken cancellationToken)
{
// 枚举名即消息类型 Code,复用底层创建命令
var msgId = await _mediator.Send(new CreateNotificationCommand
{
NotificationTypeCode = command.Type.ToString(),
BusinessType = command.Type.ToString(),
BusinessId = command.BusinessId ?? "",
Title = command.Title ?? "",
Content = command.Content ?? "",
BizContext = command.BizContext ?? "",
Priority = command.Priority,
ForceReceivers = command.ForceReceivers ?? new List<long>(),
AssigneeIds = command.AssigneeIds ?? new List<long>(),
DueTime = command.DueTime
}, cancellationToken);
return msgId > 0;
}
}
/// <summary>
/// 创建消息(业务触发的统一入口)。
/// 创建后**立即**确定接收人、写入接收人表并实时推送(已移除后台投递服务,无轮询、无延迟)。
/// 接收人规则:ForceReceivers 非空 → 精确送达;否则 → 按该消息类型的订阅关系广播。
/// 可靠性:接收人幂等(MessageId + UserId 唯一);用户离线时推送无接收者但消息已落库,上线后拉取兜底。
/// </summary>
public class CreateNotificationCommandHandler : IRequestHandler<CreateNotificationCommand, long>
{
private readonly IRepository<NotificationType> _typeRepo;
private readonly IRepository<NotificationMessage> _msgRepo;
private readonly IRepository<TodoItem> _todoRepo;
private readonly IRepository<UserNotificationSubscription> _subRepo;
private readonly IRepository<NotificationReceiver> _receiverRepo;
private readonly INotificationPusher _pusher;
public CreateNotificationCommandHandler(
IRepository<NotificationType> typeRepo,
IRepository<NotificationMessage> msgRepo,
IRepository<TodoItem> todoRepo,
IRepository<UserNotificationSubscription> subRepo,
IRepository<NotificationReceiver> receiverRepo,
INotificationPusher pusher)
{
_typeRepo = typeRepo;
_msgRepo = msgRepo;
_todoRepo = todoRepo;
_subRepo = subRepo;
_receiverRepo = receiverRepo;
_pusher = pusher;
}
public async Task<long> Handle(CreateNotificationCommand cmd, CancellationToken cancellationToken)
{
var type = await _typeRepo.FirstOrDefaultAsync(t => t.Code == cmd.NotificationTypeCode);
if (type == null) throw new ServiceException($"消息类型「{cmd.NotificationTypeCode}」不存在,请先由管理员创建");
if (!type.IsActive) throw new ServiceException($"消息类型「{type.Name}」已关闭订阅");
// 生成跳转路由:命令直接指定 > 类型模板替换业务ID
var routePath = !string.IsNullOrWhiteSpace(cmd.RoutePath)
? cmd.RoutePath
: (!string.IsNullOrWhiteSpace(type.RouteTemplate)
? type.RouteTemplate.Replace("{businessId}", cmd.BusinessId ?? "")
: "");
var msg = new NotificationMessage
{
NotificationTypeId = type.Id,
MessageTypeCode = type.Code,
BusinessType = cmd.BusinessType,
BusinessId = cmd.BusinessId,
Title = cmd.Title,
Content = cmd.Content,
Priority = cmd.Priority,
Status = NotificationStatus.Sent, // 即时送达:无后台队列,创建即投递
BizContext = cmd.BizContext ?? "",
ForceReceivers = cmd.ForceReceivers == null || cmd.ForceReceivers.Count == 0
? ""
: JsonSerializer.Serialize(cmd.ForceReceivers),
CreatorId = cmd.CreatorId,
CreatorName = cmd.CreatorName,
Category = type.Category,
RoutePath = routePath
};
await _msgRepo.AddAsync(msg);
await _msgRepo.SaveChangesAsync();
// 待办消息:为每个处理人生成待办事项
if (type.Category == NotificationCategory.Todo && cmd.AssigneeIds != null && cmd.AssigneeIds.Count > 0)
{
foreach (var assigneeId in cmd.AssigneeIds.Distinct())
{
await _todoRepo.AddAsync(new TodoItem
{
MessageId = msg.Id,
AssigneeId = assigneeId,
TodoStatus = TodoItemStatus.Pending,
DueTime = cmd.DueTime
});
}
await _todoRepo.SaveChangesAsync();
}
// 即时投递 + 实时推送
await DeliverAsync(msg, type, cancellationToken);
return msg.Id;
}
/// <summary>确定接收人 → 写接收人表 → 实时推送(推送失败不影响业务,消息已落库可拉取)</summary>
private async Task DeliverAsync(NotificationMessage msg, NotificationType type, CancellationToken ct)
{
// 1. 确定接收人(精确送达 与 订阅广播 二选一)
var receiverIds = new HashSet<long>();
var hasForceReceivers = false;
if (!string.IsNullOrWhiteSpace(msg.ForceReceivers))
{
try
{
var force = JsonSerializer.Deserialize<List<long>>(msg.ForceReceivers);
if (force != null && force.Count > 0)
{
hasForceReceivers = true;
foreach (var id in force) receiverIds.Add(id);
}
}
catch { /* 忽略非法 JSON */ }
}
if (!hasForceReceivers && type.IsActive)
{
var subs = await _subRepo.FindAsync(s => s.NotificationTypeId == msg.NotificationTypeId && s.IsActive);
foreach (var s in subs) receiverIds.Add(s.UserId);
}
if (receiverIds.Count == 0) return;
// 2. 写接收人(幂等:MessageId + UserId 唯一)
var added = new List<NotificationReceiver>();
foreach (var userId in receiverIds)
{
var exist = await _receiverRepo.FirstOrDefaultAsync(r => r.MessageId == msg.Id && r.UserId == userId);
if (exist != null) continue;
var receiver = new NotificationReceiver
{
MessageId = msg.Id,
UserId = userId,
ReadStatus = NotificationReadStatus.Unread,
SendStatus = NotificationSendStatus.NotSent
};
await _receiverRepo.AddAsync(receiver);
added.Add(receiver);
}
if (added.Count == 0) return;
await _receiverRepo.SaveChangesAsync();
// 3. 实时推送新消息 + 未读统计
// 在线(Hub 有活动连接)→ 推送并标记成功;
// 离线 → 不推送、标记 IsOffline,消息已落库,用户上线后从"我的消息"拉取兜底。
var dto = BuildDto(msg, type.Name);
foreach (var receiver in added)
{
try
{
var delivered = await _pusher.PushNewMessageAsync(receiver.UserId, dto, ct);
if (delivered)
{
receiver.SendStatus = NotificationSendStatus.Success;
receiver.SendTime = DateTime.Now;
receiver.IsOffline = false;
var unread = await _receiverRepo.CountAsync(r =>
r.UserId == receiver.UserId && r.ReadStatus == NotificationReadStatus.Unread);
await _pusher.PushUnreadCountAsync(receiver.UserId, (int)unread, 0, ct);
}
else
{
// 用户离线:不推送,等待上线拉取
receiver.SendStatus = NotificationSendStatus.NotSent;
receiver.IsOffline = true;
}
}
catch (Exception ex)
{
// 推送异常(连接刚好断开等):标记失败与离线,消息已持久化,上线后拉取兜底
receiver.SendStatus = NotificationSendStatus.Failed;
receiver.IsOffline = true;
receiver.ErrorMsg = ex.Message;
}
await _receiverRepo.UpdateAsync(receiver);
}
await _receiverRepo.SaveChangesAsync();
}
private static NotificationMessageDto BuildDto(NotificationMessage msg, string typeName) => new()
{
Id = msg.Id,
NotificationTypeId = msg.NotificationTypeId,
MessageTypeCode = msg.MessageTypeCode,
TypeName = typeName,
Title = msg.Title,
Content = msg.Content,
Priority = (int)msg.Priority,
BusinessType = msg.BusinessType,
BusinessId = msg.BusinessId,
Category = (int)msg.Category,
RoutePath = msg.RoutePath,
CreatedTime = msg.CreatedAt,
ReadStatus = 0
};
}
@@ -0,0 +1,420 @@
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Notice;
using F9WebAiTest.Application.Services.Auth;
using F9WebAiTest.Application.Interfaces.SnowflakeID;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities;
using F9WebAiTest.Domain.Entities.Notice;
using F9WebAiTest.Domain.Enums.SYS.RBAC;
using RBAC = F9WebAiTest.Domain.Entities.SYS.RBAC;
namespace F9WebAiTest.Application.Services.Notice;
public interface INotificationService
{
Task<List<NotificationTypeDto>> GetSubscribeListAsync(long userId);
Task SubscribeAsync(long userId, long typeId, bool subscribe);
Task<PageResult<NotificationMessageDto>> GetMyMessagesAsync(long userId, int pageIndex, int pageSize, int? readStatus, int? category);
Task MarkReadAsync(long userId, long messageId);
Task MarkAllReadAsync(long userId);
Task<int> GetUnreadCountAsync(long userId);
// ---- 待办 ----
Task<List<TodoItemDto>> GetMyTodosAsync(long userId, int? status);
Task CompleteTodoAsync(long userId, long todoId, string handleResult);
// ---- 管理端:消息类型维护 ----
Task<List<NotificationTypeDto>> GetTypeListAsync();
Task<NotificationTypeDto> CreateTypeAsync(NotificationTypeCreateDto dto, long creatorId);
Task<NotificationTypeDto> UpdateTypeAsync(long id, NotificationTypeCreateDto dto);
Task DeleteTypeAsync(long id);
}
public class NotificationService : INotificationService
{
private readonly IRepository<NotificationType> _typeRepo;
private readonly IRepository<UserNotificationSubscription> _subRepo;
private readonly IRepository<NotificationMessage> _msgRepo;
private readonly IRepository<NotificationReceiver> _receiverRepo;
private readonly IRepository<TodoItem> _todoRepo;
private readonly IRepository<RBAC.RolePermission> _roleMenuRepo;
private readonly IRepository<RBAC.Permission> _permissionRepo;
private readonly ISnowflakeIdGenerator _snowflake;
private readonly IAuthService _authService;
public NotificationService(
IRepository<NotificationType> typeRepo,
IRepository<UserNotificationSubscription> subRepo,
IRepository<NotificationMessage> msgRepo,
IRepository<NotificationReceiver> receiverRepo,
IRepository<TodoItem> todoRepo,
IRepository<RBAC.RolePermission> roleMenuRepo,
IRepository<RBAC.Permission> permissionRepo,
ISnowflakeIdGenerator snowflake,
IAuthService authService)
{
_typeRepo = typeRepo;
_subRepo = subRepo;
_msgRepo = msgRepo;
_receiverRepo = receiverRepo;
_todoRepo = todoRepo;
_roleMenuRepo = roleMenuRepo;
_permissionRepo = permissionRepo;
_snowflake = snowflake;
_authService = authService;
}
public async Task<List<NotificationTypeDto>> GetSubscribeListAsync(long userId)
{
var types = (await _typeRepo.FindAsync(t => t.IsActive)).OrderBy(t => t.Sort).ThenBy(t => t.Id).ToList();
var perms = await _authService.GetUserPermissionsAsync(userId);
var subs = await _subRepo.FindAsync(s => s.UserId == userId && s.IsActive);
var subIds = subs.Select(s => s.NotificationTypeId).ToHashSet();
return types.Select(t => new NotificationTypeDto
{
Id = t.Id,
Code = t.Code,
Name = t.Name,
Description = t.Description,
RequiredPermission = t.RequiredPermission,
IsActive = t.IsActive,
CreatedBy = t.CreatedBy,
Sort = t.Sort,
Category = (int)t.Category,
Icon = t.Icon,
RouteTemplate = t.RouteTemplate,
Subscribed = subIds.Contains(t.Id),
CanSubscribe = string.IsNullOrEmpty(t.RequiredPermission) || perms.Contains(t.RequiredPermission)
}).ToList();
}
public async Task SubscribeAsync(long userId, long typeId, bool subscribe)
{
var type = await _typeRepo.GetByIdAsync(typeId);
if (type == null || !type.IsActive) throw new ServiceException("消息类型不存在或已关闭订阅");
// 权限校验:RequiredPermission 为空表示所有用户可订阅
if (!string.IsNullOrEmpty(type.RequiredPermission))
{
var perms = await _authService.GetUserPermissionsAsync(userId);
if (!perms.Contains(type.RequiredPermission))
throw new ServiceException($"您没有订阅「{type.Name}」的权限", 403);
}
var sub = await _subRepo.FirstOrDefaultAsync(s => s.UserId == userId && s.NotificationTypeId == typeId);
if (subscribe)
{
if (sub == null)
{
await _subRepo.AddAsync(new UserNotificationSubscription
{
UserId = userId,
NotificationTypeId = typeId,
IsActive = true,
SubscribedTime = DateTime.Now
});
}
else
{
sub.IsActive = true;
sub.SubscribedTime = DateTime.Now;
sub.CancelTime = null;
await _subRepo.UpdateAsync(sub);
}
}
else
{
if (sub != null && sub.IsActive)
{
sub.IsActive = false;
sub.CancelTime = DateTime.Now;
await _subRepo.UpdateAsync(sub);
}
}
await _subRepo.SaveChangesAsync();
}
public async Task<PageResult<NotificationMessageDto>> GetMyMessagesAsync(long userId, int pageIndex, int pageSize, int? readStatus, int? category)
{
var receivers = await _receiverRepo.FindAsync(r => r.UserId == userId);
if (readStatus.HasValue)
receivers = receivers.Where(r => (int)r.ReadStatus == readStatus.Value).ToList();
var msgIds = receivers.Select(r => r.MessageId).Distinct().ToList();
var msgs = await _msgRepo.FindAsync(m => msgIds.Contains(m.Id));
if (category.HasValue)
msgs = msgs.Where(m => (int)m.Category == category.Value).ToList();
var msgDict = msgs.ToDictionary(m => m.Id);
var typeIds = msgs.Select(m => m.NotificationTypeId).Distinct().ToList();
var types = await _typeRepo.FindAsync(t => typeIds.Contains(t.Id));
var typeDict = types.ToDictionary(t => t.Id, t => t.Name);
var items = receivers
.Where(r => msgDict.ContainsKey(r.MessageId))
.Select(r =>
{
var m = msgDict[r.MessageId];
return new NotificationMessageDto
{
Id = m.Id,
NotificationTypeId = m.NotificationTypeId,
MessageTypeCode = m.MessageTypeCode,
TypeName = typeDict.GetValueOrDefault(m.NotificationTypeId, ""),
Title = m.Title,
Content = m.Content,
Priority = (int)m.Priority,
BusinessType = m.BusinessType,
BusinessId = m.BusinessId,
Category = (int)m.Category,
RoutePath = m.RoutePath,
CreatedTime = m.CreatedAt,
ReadStatus = (int)r.ReadStatus,
ReadTime = r.ReadTime
};
})
.OrderByDescending(x => x.CreatedTime)
.ToList();
var total = items.Count;
var paged = items.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
return new PageResult<NotificationMessageDto>
{
Items = paged,
Total = total,
PageIndex = pageIndex,
PageSize = pageSize
};
}
public async Task MarkReadAsync(long userId, long messageId)
{
var receiver = await _receiverRepo.FirstOrDefaultAsync(r => r.MessageId == messageId && r.UserId == userId);
if (receiver == null) throw new ServiceException("消息不存在", 404);
receiver.ReadStatus = NotificationReadStatus.Read;
receiver.ReadTime = DateTime.Now;
await _receiverRepo.UpdateAsync(receiver);
await _receiverRepo.SaveChangesAsync();
}
public async Task MarkAllReadAsync(long userId)
{
var unread = await _receiverRepo.FindAsync(r => r.UserId == userId && r.ReadStatus == NotificationReadStatus.Unread);
foreach (var r in unread)
{
r.ReadStatus = NotificationReadStatus.Read;
r.ReadTime = DateTime.Now;
await _receiverRepo.UpdateAsync(r);
}
}
public async Task<int> GetUnreadCountAsync(long userId)
=> (int)await _receiverRepo.CountAsync(r => r.UserId == userId && r.ReadStatus == NotificationReadStatus.Unread);
// ==================== 待办 ====================
public async Task<List<TodoItemDto>> GetMyTodosAsync(long userId, int? status)
{
var todos = await _todoRepo.FindAsync(t => t.AssigneeId == userId);
if (status.HasValue)
todos = todos.Where(t => (int)t.TodoStatus == status.Value).ToList();
var msgIds = todos.Select(t => t.MessageId).Distinct().ToList();
var msgs = await _msgRepo.FindAsync(m => msgIds.Contains(m.Id));
var msgDict = msgs.ToDictionary(m => m.Id);
return todos
.Where(t => msgDict.ContainsKey(t.MessageId))
.OrderByDescending(t => t.CreatedAt)
.Select(t =>
{
var m = msgDict[t.MessageId];
return new TodoItemDto
{
Id = t.Id,
MessageId = t.MessageId,
Title = m.Title,
Content = m.Content,
Priority = (int)m.Priority,
BusinessType = m.BusinessType,
BusinessId = m.BusinessId,
RoutePath = m.RoutePath,
TodoStatus = (int)t.TodoStatus,
DueTime = t.DueTime,
CompletedTime = t.CompletedTime,
CreatedTime = t.CreatedAt
};
})
.ToList();
}
public async Task CompleteTodoAsync(long userId, long todoId, string handleResult)
{
var todo = await _todoRepo.GetByIdAsync(todoId);
if (todo == null) throw new ServiceException("待办不存在", 404);
if (todo.AssigneeId != userId) throw new ServiceException("您不是该待办的处理人", 403);
if (todo.TodoStatus == TodoItemStatus.Completed) throw new ServiceException("该待办已处理");
todo.TodoStatus = TodoItemStatus.Completed;
todo.CompletedTime = DateTime.Now;
todo.HandleResult = handleResult ?? "";
await _todoRepo.UpdateAsync(todo);
await _todoRepo.SaveChangesAsync();
// 完成待办后,标记对应消息已读(处理完了才从"未读提醒"中消失)
var receiver = await _receiverRepo.FirstOrDefaultAsync(r => r.MessageId == todo.MessageId && r.UserId == userId);
if (receiver != null && receiver.ReadStatus == NotificationReadStatus.Unread)
{
receiver.ReadStatus = NotificationReadStatus.Read;
receiver.ReadTime = DateTime.Now;
await _receiverRepo.UpdateAsync(receiver);
await _receiverRepo.SaveChangesAsync();
}
}
// ==================== 管理端:消息类型维护 ====================
public async Task<List<NotificationTypeDto>> GetTypeListAsync()
{
var types = (await _typeRepo.GetAllAsync()).OrderBy(t => t.Sort).ThenBy(t => t.Id).ToList();
return types.Select(t => new NotificationTypeDto
{
Id = t.Id,
Code = t.Code,
Name = t.Name,
Description = t.Description,
RequiredPermission = t.RequiredPermission,
IsActive = t.IsActive,
CreatedBy = t.CreatedBy,
Sort = t.Sort,
Category = (int)t.Category,
Icon = t.Icon,
RouteTemplate = t.RouteTemplate
}).ToList();
}
public async Task<NotificationTypeDto> CreateTypeAsync(NotificationTypeCreateDto dto, long creatorId)
{
if (string.IsNullOrWhiteSpace(dto.Code)) throw new ServiceException("消息类型编码不能为空");
if (string.IsNullOrWhiteSpace(dto.Name)) throw new ServiceException("消息类型名称不能为空");
if (await _typeRepo.AnyAsync(t => t.Code == dto.Code)) throw new ServiceException($"编码「{dto.Code}」已存在");
var e = new NotificationType
{
Code = dto.Code.Trim(),
Name = dto.Name,
Description = dto.Description ?? "",
RequiredPermission = dto.RequiredPermission ?? "",
IsActive = dto.IsActive,
CreatedBy = creatorId,
Sort = dto.Sort,
Category = (NotificationCategory)dto.Category,
Icon = dto.Icon ?? "",
RouteTemplate = dto.RouteTemplate ?? ""
};
await _typeRepo.AddAsync(e);
await _typeRepo.SaveChangesAsync();
// 同步订阅权限到 RBAC 权限单元(角色管理里可给角色分配)
await SyncSubscribePermissionAsync(e);
return new NotificationTypeDto
{
Id = e.Id, Code = e.Code, Name = e.Name, Description = e.Description,
RequiredPermission = e.RequiredPermission, IsActive = e.IsActive, CreatedBy = e.CreatedBy, Sort = e.Sort,
Category = (int)e.Category, Icon = e.Icon, RouteTemplate = e.RouteTemplate
};
}
public async Task<NotificationTypeDto> UpdateTypeAsync(long id, NotificationTypeCreateDto dto)
{
var e = await _typeRepo.GetByIdAsync(id);
if (e == null) throw new ServiceException("消息类型不存在", 404);
if (string.IsNullOrWhiteSpace(dto.Name)) throw new ServiceException("消息类型名称不能为空");
e.Name = dto.Name;
e.Description = dto.Description ?? "";
e.RequiredPermission = dto.RequiredPermission ?? "";
e.IsActive = dto.IsActive;
e.Sort = dto.Sort;
e.Category = (NotificationCategory)dto.Category;
e.Icon = dto.Icon ?? "";
e.RouteTemplate = dto.RouteTemplate ?? "";
await _typeRepo.UpdateAsync(e);
await _typeRepo.SaveChangesAsync();
// 同步订阅权限到 RBAC 权限单元
await SyncSubscribePermissionAsync(e);
return new NotificationTypeDto
{
Id = e.Id, Code = e.Code, Name = e.Name, Description = e.Description,
RequiredPermission = e.RequiredPermission, IsActive = e.IsActive, CreatedBy = e.CreatedBy, Sort = e.Sort,
Category = (int)e.Category, Icon = e.Icon, RouteTemplate = e.RouteTemplate
};
}
public async Task DeleteTypeAsync(long id)
{
var e = await _typeRepo.GetByIdAsync(id);
if (e == null) throw new ServiceException("消息类型不存在", 404);
// 清理对应的订阅权限及其角色关联
var permission = await _permissionRepo.FirstOrDefaultAsync(p =>
p.ResourceType == Resource_Type_Enum.消息订阅 && p.ResourceId == e.Id);
if (permission != null)
{
var relations = await _roleMenuRepo.FindAsync(rp => rp.PermissionId == permission.Id);
if (relations.Any()) await _roleMenuRepo.HardDeleteRangeAsync(relations);
await _permissionRepo.HardDeleteAsync(permission);
await _permissionRepo.SaveChangesAsync();
}
await _typeRepo.DeleteAsync(e);
await _typeRepo.SaveChangesAsync();
}
/// <summary>
/// 将消息类型的订阅权限同步为 RBAC 的权限单元(ResourceType = 消息订阅),
/// 这样在「角色管理 → 分配权限」里就能给角色勾选该消息类型的订阅权限。
/// </summary>
private async Task SyncSubscribePermissionAsync(NotificationType type)
{
// 无 RequiredPermission 表示所有用户可订阅,无需权限单元(清理旧权限)
if (string.IsNullOrEmpty(type.RequiredPermission))
{
var old = await _permissionRepo.FirstOrDefaultAsync(p =>
p.ResourceType == Resource_Type_Enum.消息订阅 && p.ResourceId == type.Id);
if (old != null)
{
var relations = await _roleMenuRepo.FindAsync(rp => rp.PermissionId == old.Id);
if (relations.Any()) await _roleMenuRepo.HardDeleteRangeAsync(relations);
await _permissionRepo.HardDeleteAsync(old);
}
await _permissionRepo.SaveChangesAsync();
return;
}
// 查找该消息类型的订阅权限单元(按 ResourceType + ResourceId 幂等)
var permission = await _permissionRepo.FirstOrDefaultAsync(p =>
p.ResourceType == Resource_Type_Enum.消息订阅 && p.ResourceId == type.Id);
if (permission == null)
{
permission = new RBAC.Permission(
_snowflake.CreateId(),
Permission_Type_Enum.Visble,
Resource_Type_Enum.消息订阅,
type.Id.ToString(),
type.Id);
await _permissionRepo.AddAsync(permission);
}
// 业务权限码与 RBAC 权限码保持一致(SubscribeAsync 用 RequiredPermission 校验)
permission.SetCode(type.RequiredPermission);
await _permissionRepo.UpdateAsync(permission);
await _permissionRepo.SaveChangesAsync();
}
}
@@ -0,0 +1,272 @@
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Prod;
using F9WebAiTest.Application.Services.Notice;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities;
using F9WebAiTest.Domain.Entities.Base;
using F9WebAiTest.Domain.Entities.Notice;
using F9WebAiTest.Domain.Entities.Prod;
using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments;
using MediatR;
namespace F9WebAiTest.Application.Services.Prod;
public interface IHbjyService
{
Task<PageResult<HbjyListItemDto>> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId);
Task<HbjyEditDto> GetEditAsync(long lsjlId);
Task SaveAsync(HbjySaveDto dto);
}
public class HbjyService : IHbjyService
{
private readonly IRepository<LsjlRecord> _lsjlRepo;
private readonly IRepository<HbjyLsjl> _hbjyLsjlRepo;
private readonly IRepository<HbjyRecord> _hbjyRecordRepo;
private readonly IRepository<SysDept.Department> _deptRepo;
private readonly IRepository<CraftZhuangKou> _zkRepo;
private readonly IMediator _mediator;
public HbjyService(
IRepository<LsjlRecord> lsjlRepo,
IRepository<HbjyLsjl> hbjyLsjlRepo,
IRepository<HbjyRecord> hbjyRecordRepo,
IRepository<SysDept.Department> deptRepo,
IRepository<CraftZhuangKou> zkRepo,
IMediator mediator)
{
_lsjlRepo = lsjlRepo;
_hbjyLsjlRepo = hbjyLsjlRepo;
_hbjyRecordRepo = hbjyRecordRepo;
_deptRepo = deptRepo;
_zkRepo = zkRepo;
_mediator = mediator;
}
public async Task<PageResult<HbjyListItemDto>> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId)
{
var lsjls = await _lsjlRepo.GetAllAsync();
var q = lsjls.AsQueryable();
if (date.HasValue) q = q.Where(x => x.WorkDate.Date == date.Value.Date);
if (deptId.HasValue) q = q.Where(x => x.DeptId == deptId.Value);
var depts = await _deptRepo.GetAllAsync();
var deptDict = depts.ToDictionary(d => d.Id, d => d.Name);
var zks = await _zkRepo.GetAllAsync();
var zkDict = zks.ToDictionary(z => z.Id, z => z.ZhuangKouName);
var sums = (await _hbjyLsjlRepo.GetAllAsync()).ToDictionary(h => h.LsjlId);
var total = q.LongCount();
var items = q.OrderByDescending(x => x.WorkDate).ThenByDescending(x => x.Id)
.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
return new PageResult<HbjyListItemDto>
{
Items = items.Select(x =>
{
sums.TryGetValue(x.Id, out var sum);
return new HbjyListItemDto
{
LsjlId = x.Id,
WorkDate = x.WorkDate,
DeptId = x.DeptId,
DeptName = deptDict.GetValueOrDefault(x.DeptId, ""),
Jizu = x.Jizu,
Huishu = x.Huishu,
Guige = x.Guige,
ZhuangKouName = zkDict.GetValueOrDefault(x.GyzkId, ""),
Yanse = x.Yanse,
Isbanche = x.Isbanche,
IsbancheName = x.Isbanche ? "半车" : "整车",
LuosiTime = x.LuosiTime,
Chetaishu = x.Chetaishu,
ChetaiStart = x.ChetaiStart,
ChetaiEnd = x.ChetaiEnd,
YingluPianshu = x.Chetaishu * 20,
IsChecked = sum != null,
Pianshu = sum?.Pianshu ?? 0,
Qingjie = sum?.Qingjie ?? 0,
Jiejing = sum?.Jiejing ?? 0,
Dengji = sum?.Dengji ?? ""
};
}).ToList(),
Total = total, PageIndex = pageIndex, PageSize = pageSize
};
}
public async Task<HbjyEditDto> GetEditAsync(long lsjlId)
{
var lsjl = await _lsjlRepo.GetByIdAsync(lsjlId);
if (lsjl == null) throw new ServiceException("落丝记录不存在", 404);
var dept = await _deptRepo.GetByIdAsync(lsjl.DeptId);
var zk = await _zkRepo.GetByIdAsync(lsjl.GyzkId);
var sum = await _hbjyLsjlRepo.FirstOrDefaultAsync(h => h.LsjlId == lsjlId);
var records = await _hbjyRecordRepo.FindAsync(h => h.LsjlId == lsjlId);
List<HbjyRecordDto> items;
if (records.Count > 0)
{
items = records.OrderBy(r => r.Chetaihao).Select(ToDto).ToList();
}
else
{
items = new List<HbjyRecordDto>();
for (int i = lsjl.ChetaiStart; i <= lsjl.ChetaiEnd; i++)
{
items.Add(new HbjyRecordDto { Chetaihao = i });
}
}
return new HbjyEditDto
{
LsjlId = lsjl.Id,
DeptName = dept?.Name ?? "",
Jizu = lsjl.Jizu,
Huishu = lsjl.Huishu,
ZhuangKouName = zk?.ZhuangKouName ?? "",
Yanse = lsjl.Yanse,
Isbanche = lsjl.Isbanche,
IsbancheName = lsjl.Isbanche ? "半车" : "整车",
Guige = lsjl.Guige,
ChetaiStart = lsjl.ChetaiStart,
ChetaiEnd = lsjl.ChetaiEnd,
Chetaishu = lsjl.Chetaishu,
YingluPianshu = lsjl.Chetaishu * 20,
Dengji = sum?.Dengji ?? "",
Records = items
};
}
public async Task SaveAsync(HbjySaveDto dto)
{
if (dto.LsjlId == 0) throw new ServiceException("落丝记录无效");
var lsjl = await _lsjlRepo.GetByIdAsync(dto.LsjlId);
if (lsjl == null) throw new ServiceException("落丝记录不存在", 404);
if (dto.Records == null || dto.Records.Count == 0) throw new ServiceException("请先录入检验数据");
var invalid = dto.Records.Where(i => i.Chetaihao < lsjl.ChetaiStart || i.Chetaihao > lsjl.ChetaiEnd).ToList();
if (invalid.Count > 0) throw new ServiceException($"车台号超出范围 [{lsjl.ChetaiStart}-{lsjl.ChetaiEnd}]");
// 计算每车台片数
foreach (var item in dto.Records)
{
item.Pianshu = CalcPianshu(item);
}
var totalPianshu = dto.Records.Sum(x => x.Pianshu);
if (totalPianshu == 0) throw new ServiceException("请至少录入一个洁净等级片数");
// 物理删除旧明细
var oldRecords = await _hbjyRecordRepo.FindAsync(h => h.LsjlId == dto.LsjlId);
await _hbjyRecordRepo.HardDeleteRangeAsync(oldRecords);
foreach (var item in dto.Records)
{
await _hbjyRecordRepo.AddAsync(new HbjyRecord
{
LsjlId = dto.LsjlId,
Chetaihao = item.Chetaihao,
YdYidu = item.YdYidu, YdErdu = item.YdErdu, YdSandu = item.YdSandu,
Jj100 = item.Jj100, Jj95 = item.Jj95, Jj90 = item.Jj90, Jj85 = item.Jj85,
Jj80 = item.Jj80, Jj75 = item.Jj75, Jj70 = item.Jj70, Jj65 = item.Jj65, Jj60 = item.Jj60,
QjZycdTdc = item.QjZycdTdc,
QjCycdFs = item.QjCycdFs, QjCycdDc = item.QjCycdDc, QjCycdNfc = item.QjCycdNfc,
QjCycdDcj = item.QjCycdDcj, QjCycdZlx = item.QjCycdZlx,
QjPtcdXc = item.QjPtcdXc, QjPtcdCj = item.QjPtcdCj, QjPtcdLx = item.QjPtcdLx, QjPtcdHjls = item.QjPtcdHjls,
Pianshu = item.Pianshu
});
}
// 汇总
var sum = CalcSummary(dto.Records);
var hbjyLsjl = await _hbjyLsjlRepo.FirstOrDefaultAsync(h => h.LsjlId == dto.LsjlId);
if (hbjyLsjl == null)
{
hbjyLsjl = new HbjyLsjl { LsjlId = dto.LsjlId };
await _hbjyLsjlRepo.AddAsync(hbjyLsjl);
}
hbjyLsjl.Pianshu = sum.Pianshu;
hbjyLsjl.Qingjie = sum.Qingjie;
hbjyLsjl.Jiejing = sum.Jiejing;
hbjyLsjl.YdErdu = sum.YdErdu;
hbjyLsjl.YdSandu = sum.YdSandu;
hbjyLsjl.Dengji = dto.Dengji ?? "";
await _hbjyLsjlRepo.UpdateAsync(hbjyLsjl);
// 业务系统触发消息:发现特大糙时,通过 MediatR 创建"发现特大糙"消息,由 BackgroundService 投递
var tdcCount = dto.Records.Sum(x => x.QjZycdTdc);
if (tdcCount > 0)
{
await _mediator.Send(new CreateNotificationCommand
{
NotificationTypeCode = "TdcFound",
BusinessType = "Hbjy",
BusinessId = dto.LsjlId.ToString(),
Title = $"发现特大糙:{lsjl.Guige} 规格",
Content = $"落丝记录(机组 {lsjl.Jizu}、回数 {lsjl.Huishu})黑板检验发现 {tdcCount} 个特大糙,请关注。",
Priority = NotificationPriority.Urgent,
CreatorId = 0,
CreatorName = "系统"
});
}
}
// ==================== 计算逻辑 ====================
private static HbjyRecordDto ToDto(HbjyRecord r) => new()
{
Chetaihao = r.Chetaihao,
YdYidu = r.YdYidu, YdErdu = r.YdErdu, YdSandu = r.YdSandu,
Jj100 = r.Jj100, Jj95 = r.Jj95, Jj90 = r.Jj90, Jj85 = r.Jj85,
Jj80 = r.Jj80, Jj75 = r.Jj75, Jj70 = r.Jj70, Jj65 = r.Jj65, Jj60 = r.Jj60,
QjZycdTdc = r.QjZycdTdc,
QjCycdFs = r.QjCycdFs, QjCycdDc = r.QjCycdDc, QjCycdNfc = r.QjCycdNfc,
QjCycdDcj = r.QjCycdDcj, QjCycdZlx = r.QjCycdZlx,
QjPtcdXc = r.QjPtcdXc, QjPtcdCj = r.QjPtcdCj, QjPtcdLx = r.QjPtcdLx, QjPtcdHjls = r.QjPtcdHjls,
Pianshu = r.Pianshu
};
// 片数 = 洁净等级各列之和
private static int CalcPianshu(HbjyRecordDto item)
=> item.Jj100 + item.Jj95 + item.Jj90 + item.Jj85 + item.Jj80 + item.Jj75 + item.Jj70 + item.Jj65 + item.Jj60;
// 清洁扣分(特大类1分/个,大类0.4分/个,小类0.1分/个)
private static decimal CalcQingjieDeduction(HbjyRecordDto item)
{
decimal teda = item.QjZycdTdc * 1.0M;
decimal dalei = (item.QjCycdFs + item.QjCycdDc + item.QjCycdNfc + item.QjCycdDcj + item.QjCycdZlx) * 0.4M;
decimal xiaolei = (item.QjPtcdXc + item.QjPtcdCj + item.QjPtcdLx + item.QjPtcdHjls) * 0.1M;
return teda + dalei + xiaolei;
}
private static HbjyLsjl CalcSummary(List<HbjyRecordDto> items)
{
int pianshu = items.Sum(x => x.Pianshu);
// 清洁分 = (片数 - 总扣分) * 100 / 片数
decimal totalDeduction = items.Sum(CalcQingjieDeduction);
double qingjie = pianshu == 0 ? 0 : Math.Round((double)((pianshu - totalDeduction) * 100 / pianshu), 2, MidpointRounding.AwayFromZero);
// 洁净分 = (片数*100 - 扣分总和*5) / 片数
int jj95 = items.Sum(x => x.Jj95), jj90 = items.Sum(x => x.Jj90), jj85 = items.Sum(x => x.Jj85);
int jj80 = items.Sum(x => x.Jj80), jj75 = items.Sum(x => x.Jj75), jj70 = items.Sum(x => x.Jj70);
int jj65 = items.Sum(x => x.Jj65), jj60 = items.Sum(x => x.Jj60);
decimal totalJiejingDeduction = 1 * jj95 + 2 * jj90 + 3 * jj85 + 4 * jj80 + 5 * jj75 + 6 * jj70 + 7 * jj65 + 8 * jj60;
double jiejing = pianshu == 0 ? 0 : Math.Round((double)((pianshu * 100 - totalJiejingDeduction * 5) / (decimal)pianshu), 2, MidpointRounding.AwayFromZero);
return new HbjyLsjl
{
Pianshu = pianshu,
Qingjie = qingjie,
Jiejing = jiejing,
YdErdu = items.Sum(x => x.YdErdu),
YdSandu = items.Sum(x => x.YdSandu)
};
}
}
@@ -0,0 +1,129 @@
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Prod;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities;
using F9WebAiTest.Domain.Entities.Base;
using F9WebAiTest.Domain.Entities.Prod;
using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments;
namespace F9WebAiTest.Application.Services.Prod;
public interface ILsjlService
{
Task<PageResult<LsjlDto>> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId, string keyword);
Task<LsjlDto> CreateAsync(LsjlCreateDto dto);
Task<LsjlDto> UpdateAsync(long id, LsjlCreateDto dto);
Task DeleteAsync(long id);
}
public class LsjlService : ILsjlService
{
private readonly IRepository<LsjlRecord> _repo;
private readonly IRepository<SysDept.Department> _deptRepo;
private readonly IRepository<CraftZhuangKou> _zkRepo;
public LsjlService(IRepository<LsjlRecord> repo, IRepository<SysDept.Department> deptRepo, IRepository<CraftZhuangKou> zkRepo)
{
_repo = repo;
_deptRepo = deptRepo;
_zkRepo = zkRepo;
}
public async Task<PageResult<LsjlDto>> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId, string keyword)
{
var all = await _repo.GetAllAsync();
var q = all.AsQueryable();
if (date.HasValue)
q = q.Where(x => x.WorkDate.Date == date.Value.Date);
if (deptId.HasValue)
q = q.Where(x => x.DeptId == deptId.Value);
var depts = await _deptRepo.GetAllAsync();
var deptDict = depts.ToDictionary(d => d.Id, d => d.Name);
var zks = await _zkRepo.GetAllAsync();
var zkDict = zks.ToDictionary(z => z.Id, z => z.ZhuangKouName);
if (!string.IsNullOrWhiteSpace(keyword))
{
var kwDeptIds = depts.Where(d => d.Name.Contains(keyword)).Select(d => d.Id).ToHashSet();
var kwZkIds = zks.Where(z => z.ZhuangKouName.Contains(keyword)).Select(z => z.Id).ToHashSet();
q = q.Where(x => kwDeptIds.Contains(x.DeptId) || kwZkIds.Contains(x.GyzkId) || x.Guige.Contains(keyword));
}
var total = q.LongCount();
var items = q.OrderByDescending(x => x.WorkDate).ThenByDescending(x => x.Id)
.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
return new PageResult<LsjlDto>
{
Items = items.Select(x => ToDto(x, deptDict, zkDict)).ToList(),
Total = total, PageIndex = pageIndex, PageSize = pageSize
};
}
public async Task<LsjlDto> CreateAsync(LsjlCreateDto dto)
{
if (dto.DeptId == 0) throw new ServiceException("请选择车间");
if (dto.GyzkId == 0) throw new ServiceException("请选择庄口");
var e = Map(new LsjlRecord(), dto);
await _repo.AddAsync(e);
await _repo.SaveChangesAsync();
return await ToDto(e);
}
public async Task<LsjlDto> UpdateAsync(long id, LsjlCreateDto dto)
{
var e = await _repo.GetByIdAsync(id);
if (e == null) throw new ServiceException("记录不存在", 404);
Map(e, dto);
await _repo.UpdateAsync(e);
await _repo.SaveChangesAsync();
return await ToDto(e);
}
public async Task DeleteAsync(long id)
{
var e = await _repo.GetByIdAsync(id);
if (e == null) throw new ServiceException("记录不存在", 404);
await _repo.DeleteAsync(e);
await _repo.SaveChangesAsync();
}
private static LsjlRecord Map(LsjlRecord e, LsjlCreateDto d)
{
e.WorkDate = d.WorkDate; e.DeptId = d.DeptId; e.GyzkId = d.GyzkId; e.Jizu = d.Jizu;
e.LuosiTime = d.LuosiTime; e.Yanse = d.Yanse; e.Huishu = d.Huishu;
e.ChetaiStart = d.ChetaiStart; e.ChetaiEnd = d.ChetaiEnd; e.Chetaishu = d.Chetaishu;
e.Isbanche = d.Isbanche; e.Hours = d.Hours; e.Guige = d.Guige; e.Chesu = d.Chesu;
return e;
}
private async Task<LsjlDto> ToDto(LsjlRecord e)
{
var dept = await _deptRepo.GetByIdAsync(e.DeptId);
var zk = await _zkRepo.GetByIdAsync(e.GyzkId);
return new LsjlDto
{
Id = e.Id, WorkDate = e.WorkDate, DeptId = e.DeptId, DeptName = dept?.Name ?? "",
GyzkId = e.GyzkId, ZhuangKouName = zk?.ZhuangKouName ?? "", Jizu = e.Jizu,
LuosiTime = e.LuosiTime, Yanse = e.Yanse, Huishu = e.Huishu,
ChetaiStart = e.ChetaiStart, ChetaiEnd = e.ChetaiEnd, Chetaishu = e.Chetaishu,
Isbanche = e.Isbanche, IsbancheName = e.Isbanche ? "半车" : "整车",
Hours = e.Hours, Guige = e.Guige, Chesu = e.Chesu
};
}
private static LsjlDto ToDto(LsjlRecord e, Dictionary<long, string> deptDict, Dictionary<long, string> zkDict)
{
return new LsjlDto
{
Id = e.Id, WorkDate = e.WorkDate, DeptId = e.DeptId, DeptName = deptDict.GetValueOrDefault(e.DeptId, ""),
GyzkId = e.GyzkId, ZhuangKouName = zkDict.GetValueOrDefault(e.GyzkId, ""), Jizu = e.Jizu,
LuosiTime = e.LuosiTime, Yanse = e.Yanse, Huishu = e.Huishu,
ChetaiStart = e.ChetaiStart, ChetaiEnd = e.ChetaiEnd, Chetaishu = e.Chetaishu,
Isbanche = e.Isbanche, IsbancheName = e.Isbanche ? "半车" : "整车",
Hours = e.Hours, Guige = e.Guige, Chesu = e.Chesu
};
}
}
@@ -0,0 +1,344 @@
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Base;
using F9WebAiTest.Application.Dtos.Prod;
using F9WebAiTest.Application.Services.Notice;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities;
using F9WebAiTest.Domain.Entities.Base;
using F9WebAiTest.Domain.Entities.Notice;
using F9WebAiTest.Domain.Entities.Prod;
using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments;
using MediatR;
namespace F9WebAiTest.Application.Services.Prod;
public interface IXdjyService
{
Task<PageResult<XdjyListItemDto>> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId);
Task<XdjyEditDto> GetEditAsync(long lsjlId);
Task SaveAsync(XdjySaveDto dto);
}
public class XdjyService : IXdjyService
{
private readonly IRepository<LsjlRecord> _lsjlRepo;
private readonly IRepository<XdjyLsjl> _xdjyLsjlRepo;
private readonly IRepository<XdjyRecord> _xdjyRecordRepo;
private readonly IRepository<ShengsiGuige> _guigeRepo;
private readonly IRepository<SysDept.Department> _deptRepo;
private readonly IRepository<CraftZhuangKou> _zkRepo;
private readonly IMediator _mediator;
public XdjyService(
IRepository<LsjlRecord> lsjlRepo,
IRepository<XdjyLsjl> xdjyLsjlRepo,
IRepository<XdjyRecord> xdjyRecordRepo,
IRepository<ShengsiGuige> guigeRepo,
IRepository<SysDept.Department> deptRepo,
IRepository<CraftZhuangKou> zkRepo,
IMediator mediator)
{
_lsjlRepo = lsjlRepo;
_xdjyLsjlRepo = xdjyLsjlRepo;
_xdjyRecordRepo = xdjyRecordRepo;
_guigeRepo = guigeRepo;
_deptRepo = deptRepo;
_zkRepo = zkRepo;
_mediator = mediator;
}
public async Task<PageResult<XdjyListItemDto>> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId)
{
var lsjls = await _lsjlRepo.GetAllAsync();
var q = lsjls.AsQueryable();
if (date.HasValue) q = q.Where(x => x.WorkDate.Date == date.Value.Date);
if (deptId.HasValue) q = q.Where(x => x.DeptId == deptId.Value);
var depts = await _deptRepo.GetAllAsync();
var deptDict = depts.ToDictionary(d => d.Id, d => d.Name);
var zks = await _zkRepo.GetAllAsync();
var zkDict = zks.ToDictionary(z => z.Id, z => z.ZhuangKouName);
var sums = (await _xdjyLsjlRepo.GetAllAsync()).ToDictionary(x => x.LsjlId);
var total = q.LongCount();
var items = q.OrderByDescending(x => x.WorkDate).ThenByDescending(x => x.Id)
.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
return new PageResult<XdjyListItemDto>
{
Items = items.Select(x =>
{
sums.TryGetValue(x.Id, out var sum);
var yingcheng = x.Chetaishu * (x.Isbanche ? 2 : 4);
return new XdjyListItemDto
{
LsjlId = x.Id,
WorkDate = x.WorkDate,
DeptId = x.DeptId,
DeptName = deptDict.GetValueOrDefault(x.DeptId, ""),
Jizu = x.Jizu,
Huishu = x.Huishu,
Guige = x.Guige,
ZhuangKouName = zkDict.GetValueOrDefault(x.GyzkId, ""),
Yanse = x.Yanse,
Isbanche = x.Isbanche,
IsbancheName = x.Isbanche ? "半车" : "整车",
Chetaishu = x.Chetaishu,
ChetaiStart = x.ChetaiStart,
ChetaiEnd = x.ChetaiEnd,
LuosiTime = x.LuosiTime,
Yingchengnum = yingcheng,
IsChecked = sum != null,
Jiaoshu = sum?.Jiaoshu ?? 0,
Avgxiandu = sum?.Avgxiandu ?? 0,
Junfangcha = sum?.Junfangcha ?? 0,
Zongcha = sum?.Zongcha ?? 0,
FanxianLv = sum?.FanxianLv ?? 0,
FanweiNum = sum?.FanweiNum ?? 0,
XiaoyeNum = sum?.XiaoyeNum ?? 0,
DayeNum = sum?.DayeNum ?? 0,
TeyeNum = sum?.TeyeNum ?? 0,
CenterNum = sum?.CenterNum ?? 0,
Dengji = sum?.Dengji ?? ""
};
}).ToList(),
Total = total, PageIndex = pageIndex, PageSize = pageSize
};
}
public async Task<XdjyEditDto> GetEditAsync(long lsjlId)
{
var lsjl = await _lsjlRepo.GetByIdAsync(lsjlId);
if (lsjl == null) throw new ServiceException("落丝记录不存在", 404);
var dept = await _deptRepo.GetByIdAsync(lsjl.DeptId);
var zk = await _zkRepo.GetByIdAsync(lsjl.GyzkId);
var gg = await _guigeRepo.FirstOrDefaultAsync(g => g.Guige == lsjl.Guige);
if (gg == null) throw new ServiceException($"未找到规格「{lsjl.Guige}」的生丝规格配置,请先维护生丝规格");
var sum = await _xdjyLsjlRepo.FirstOrDefaultAsync(x => x.LsjlId == lsjlId);
var records = await _xdjyRecordRepo.FindAsync(x => x.LsjlId == lsjlId);
List<XdjyRecordDto> items;
if (records.Count > 0)
{
// 已检验:加载已有明细
items = records.OrderBy(r => r.Chetaihao).Select(ToDto).ToList();
}
else
{
// 首次检验:按车台范围初始化空明细
items = new List<XdjyRecordDto>();
for (int i = lsjl.ChetaiStart; i <= lsjl.ChetaiEnd; i++)
{
items.Add(new XdjyRecordDto { Chetaihao = i });
}
}
return new XdjyEditDto
{
LsjlId = lsjl.Id,
DeptName = dept?.Name ?? "",
Jizu = lsjl.Jizu,
Huishu = lsjl.Huishu,
ZhuangKouName = zk?.ZhuangKouName ?? "",
Yanse = lsjl.Yanse,
Isbanche = lsjl.Isbanche,
IsbancheName = lsjl.Isbanche ? "半车" : "整车",
Guige = lsjl.Guige,
ChetaiStart = lsjl.ChetaiStart,
ChetaiEnd = lsjl.ChetaiEnd,
Chetaishu = lsjl.Chetaishu,
Yingchengnum = lsjl.Chetaishu * (lsjl.Isbanche ? 2 : 4),
Dengji = sum?.Dengji ?? "",
GuigeConfig = new ShengsiGuigeDto
{
Id = gg.Id, Guige = gg.Guige,
CenterrangeLolimit = gg.CenterrangeLolimit, CenterrangeUplimit = gg.CenterrangeUplimit,
CenterXiandu = gg.CenterXiandu,
XiaoyeLolimit = gg.XiaoyeLolimit, XiaoyeUplimit = gg.XiaoyeUplimit,
DayeLolimit = gg.DayeLolimit, DayeUplimit = gg.DayeUplimit,
TeyeLolimit = gg.TeyeLolimit, TeyeUplimit = gg.TeyeUplimit
},
Xdjy = items
};
}
public async Task SaveAsync(XdjySaveDto dto)
{
if (dto.LsjlId == 0) throw new ServiceException("落丝记录无效");
var lsjl = await _lsjlRepo.GetByIdAsync(dto.LsjlId);
if (lsjl == null) throw new ServiceException("落丝记录不存在", 404);
var gg = await _guigeRepo.FirstOrDefaultAsync(g => g.Guige == lsjl.Guige);
if (gg == null) throw new ServiceException($"未找到规格「{lsjl.Guige}」的生丝规格配置");
if (dto.Items == null || dto.Items.Count == 0) throw new ServiceException("请先录入纤度数据");
// 校验车台号在范围内
var invalid = dto.Items.Where(i => i.Chetaihao < lsjl.ChetaiStart || i.Chetaihao > lsjl.ChetaiEnd).ToList();
if (invalid.Count > 0) throw new ServiceException($"车台号超出范围 [{lsjl.ChetaiStart}-{lsjl.ChetaiEnd}]");
// 计算每个车台指标
var calcItems = dto.Items.Select(i =>
{
var r = new XdjyRecordDto { Chetaihao = i.Chetaihao, Xd1 = i.Xd1, Xd2 = i.Xd2, Xd3 = i.Xd3, Xd4 = i.Xd4 };
CalcChetai(r, gg);
return r;
}).ToList();
var jiaoshu = calcItems.Sum(x => x.XdNum);
if (jiaoshu == 0) throw new ServiceException("请至少录入一个纤度值");
// 物理删除旧明细(避免软删除残留导致唯一索引冲突)
var oldRecords = await _xdjyRecordRepo.FindAsync(x => x.LsjlId == dto.LsjlId);
await _xdjyRecordRepo.HardDeleteRangeAsync(oldRecords);
foreach (var item in calcItems)
{
await _xdjyRecordRepo.AddAsync(new XdjyRecord
{
LsjlId = dto.LsjlId,
Chetaihao = item.Chetaihao,
Xd1 = item.Xd1, Xd2 = item.Xd2, Xd3 = item.Xd3, Xd4 = item.Xd4,
XdNum = item.XdNum, XdAvg = item.XdAvg,
FanweiNum = item.FanweiNum, XiaoyeNum = item.XiaoyeNum,
DayeNum = item.DayeNum, TeyeNum = item.TeyeNum, CenterNum = item.CenterNum
});
}
// 汇总指标
var sum = CalcSummary(calcItems, gg);
var xdjyLsjl = await _xdjyLsjlRepo.FirstOrDefaultAsync(x => x.LsjlId == dto.LsjlId);
if (xdjyLsjl == null)
{
xdjyLsjl = new XdjyLsjl { LsjlId = dto.LsjlId };
await _xdjyLsjlRepo.AddAsync(xdjyLsjl);
}
xdjyLsjl.Jiaoshu = sum.Jiaoshu;
xdjyLsjl.Avgxiandu = sum.Avgxiandu;
xdjyLsjl.Junfangcha = sum.Junfangcha;
xdjyLsjl.Zongcha = sum.Zongcha;
xdjyLsjl.FanxianLv = sum.FanxianLv;
xdjyLsjl.FanweiNum = sum.FanweiNum;
xdjyLsjl.XiaoyeNum = sum.XiaoyeNum;
xdjyLsjl.DayeNum = sum.DayeNum;
xdjyLsjl.TeyeNum = sum.TeyeNum;
xdjyLsjl.CenterNum = sum.CenterNum;
xdjyLsjl.Dengji = dto.Dengji ?? "";
await _xdjyLsjlRepo.UpdateAsync(xdjyLsjl);
// 业务系统触发消息:发现特野时,通过 MediatR 创建"发现特野"消息,由 BackgroundService 投递
if (sum.TeyeNum > 0)
{
await _mediator.Send(new CreateNotificationCommand
{
NotificationTypeCode = "TeyeFound",
BusinessType = "Xdjy",
BusinessId = dto.LsjlId.ToString(),
Title = $"发现特野:{lsjl.Guige} 规格",
Content = $"落丝记录(机组 {lsjl.Jizu}、回数 {lsjl.Huishu})纤度检验发现 {sum.TeyeNum} 个特野值,请关注。",
Priority = NotificationPriority.Urgent,
CreatorId = 0,
CreatorName = "系统"
});
}
}
// ==================== 计算逻辑(与 WinForms 版保持一致) ====================
private static XdjyRecordDto ToDto(XdjyRecord r) => new()
{
Chetaihao = r.Chetaihao,
Xd1 = r.Xd1, Xd2 = r.Xd2, Xd3 = r.Xd3, Xd4 = r.Xd4,
XdNum = r.XdNum, XdAvg = r.XdAvg,
FanweiNum = r.FanweiNum, XiaoyeNum = r.XiaoyeNum,
DayeNum = r.DayeNum, TeyeNum = r.TeyeNum, CenterNum = r.CenterNum
};
private static void CalcChetai(XdjyRecordDto item, ShengsiGuige gg)
{
item.XdNum = (item.Xd1 > 0 ? 1 : 0) + (item.Xd2 > 0 ? 1 : 0) + (item.Xd3 > 0 ? 1 : 0) + (item.Xd4 > 0 ? 1 : 0);
if (item.XdNum > 0)
{
item.XdAvg = Math.Round((item.Xd1 + item.Xd2 + item.Xd3 + item.Xd4) / item.XdNum, 2, MidpointRounding.AwayFromZero);
item.FanweiNum = Fanwei(item.Xd1, gg) + Fanwei(item.Xd2, gg) + Fanwei(item.Xd3, gg) + Fanwei(item.Xd4, gg);
item.XiaoyeNum = Xiaoye(item.Xd1, gg) + Xiaoye(item.Xd2, gg) + Xiaoye(item.Xd3, gg) + Xiaoye(item.Xd4, gg);
item.DayeNum = Daye(item.Xd1, gg) + Daye(item.Xd2, gg) + Daye(item.Xd3, gg) + Daye(item.Xd4, gg);
item.TeyeNum = Teye(item.Xd1, gg) + Teye(item.Xd2, gg) + Teye(item.Xd3, gg) + Teye(item.Xd4, gg);
item.CenterNum = Center(item.Xd1, gg) + Center(item.Xd2, gg) + Center(item.Xd3, gg) + Center(item.Xd4, gg);
}
else
{
item.XdAvg = 0; item.FanweiNum = 0; item.XiaoyeNum = 0; item.DayeNum = 0; item.TeyeNum = 0; item.CenterNum = 0;
}
}
private static XdjyLsjl CalcSummary(List<XdjyRecordDto> items, ShengsiGuige gg)
{
var jiaoshu = items.Sum(x => x.XdNum);
var avgXiandu = jiaoshu == 0 ? 0 : items.Sum(x => x.Xd1 + x.Xd2 + x.Xd3 + x.Xd4) / jiaoshu;
// 均方差:sqrt(Σ(xi-avg)² / n)
double junfangcha = 0;
if (jiaoshu > 0)
{
double sumDiff = 0;
foreach (var it in items)
{
foreach (var xd in new[] { it.Xd1, it.Xd2, it.Xd3, it.Xd4 })
{
if (xd > 0) { var d = xd - avgXiandu; sumDiff += d * d; }
}
}
junfangcha = Math.Round(Math.Sqrt(Math.Round(sumDiff / jiaoshu, 2)), 2, MidpointRounding.AwayFromZero);
}
// 总差:抽样绞数 = ceil(n*0.02),最细/最粗各取,与总平均比较取最大差值
double zongcha = 0;
if (jiaoshu > 0)
{
var xds = new List<double>();
foreach (var it in items)
{
if (it.Xd1 > 0) xds.Add(it.Xd1);
if (it.Xd2 > 0) xds.Add(it.Xd2);
if (it.Xd3 > 0) xds.Add(it.Xd3);
if (it.Xd4 > 0) xds.Add(it.Xd4);
}
xds.Sort();
var chouyang = Math.Max(1, (int)Math.Ceiling(xds.Count * 0.02));
var minAvg = xds.Take(chouyang).Average();
var maxAvg = xds.Skip(xds.Count - chouyang).Take(chouyang).Average();
var avgR = Math.Round(avgXiandu, 2, MidpointRounding.AwayFromZero);
zongcha = Math.Round(Math.Max(Math.Abs(minAvg - avgR), Math.Abs(maxAvg - avgR)), 2, MidpointRounding.AwayFromZero);
}
var fanweiNum = items.Sum(x => x.FanweiNum);
var fanxianLv = jiaoshu == 0 ? 0 : Math.Round((double)fanweiNum * 100 / jiaoshu, 0, MidpointRounding.AwayFromZero);
return new XdjyLsjl
{
Jiaoshu = jiaoshu,
Avgxiandu = Math.Round(avgXiandu, 2, MidpointRounding.AwayFromZero),
Junfangcha = junfangcha,
Zongcha = zongcha,
FanxianLv = fanxianLv,
FanweiNum = fanweiNum,
XiaoyeNum = items.Sum(x => x.XiaoyeNum),
DayeNum = items.Sum(x => x.DayeNum),
TeyeNum = items.Sum(x => x.TeyeNum),
CenterNum = items.Sum(x => x.CenterNum)
};
}
// ---- 判定(纤度 <= 0 视为未称) ----
private static int Fanwei(double xd, ShengsiGuige gg) => xd > 0 && gg.CenterrangeLolimit <= xd && gg.CenterrangeUplimit >= xd ? 1 : 0;
private static int Xiaoye(double xd, ShengsiGuige gg) => xd > 0 && ((gg.XiaoyeLolimit >= xd && xd > gg.DayeLolimit) || (gg.XiaoyeUplimit <= xd && xd < gg.DayeUplimit)) ? 1 : 0;
private static int Daye(double xd, ShengsiGuige gg) => xd > 0 && ((gg.DayeLolimit >= xd && xd > gg.TeyeLolimit) || (gg.DayeUplimit <= xd && xd < gg.TeyeUplimit)) ? 1 : 0;
private static int Teye(double xd, ShengsiGuige gg) => xd > 0 && (gg.TeyeLolimit >= xd || gg.TeyeUplimit <= xd) ? 1 : 0;
private static int Center(double xd, ShengsiGuige gg) => xd > 0 && gg.CenterXiandu == xd ? 1 : 0;
}