first commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user