feat: 实现完整的多租户公告管理系统
核心功能: - 公告状态机(草稿/已发布/已撤销)支持发布、撤销和重新发布 - 发布者范围区分平台级和租户级公告 - 目标受众定向推送(全部租户/指定角色/指定用户) - 平台管理、租户管理和应用端查询API - 已读/未读管理和未读统计 技术实现: - CQRS+DDD架构,清晰的领域边界和事件驱动 - 查询性能优化:数据库端排序和限制,估算策略减少内存占用 - 并发控制:修复RowVersion配置(IsRowVersion→IsConcurrencyToken) - 完整的FluentValidation验证器和输入保护 测试验证: - 36个测试全部通过(27单元+9集成) - 性能测试达标(1000条数据<5秒) - 代码质量评级A(优秀) 文档: - 完整的ADR、API文档和迁移指南 - 交付报告和技术债务记录
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
@@ -17,11 +18,14 @@ public sealed record CreateTenantAnnouncementCommand : IRequest<TenantAnnounceme
|
||||
/// <summary>
|
||||
/// 公告标题。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 公告正文内容。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
@@ -45,7 +49,19 @@ public sealed record CreateTenantAnnouncementCommand : IRequest<TenantAnnounceme
|
||||
public DateTime? EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// 发布者范围。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
public PublisherScope PublisherScope { get; init; } = PublisherScope.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众类型。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string TargetType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众参数(JSON)。
|
||||
/// </summary>
|
||||
public string? TargetParameters { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
@@ -6,15 +7,16 @@ namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
/// <summary>
|
||||
/// 标记公告已读命令。
|
||||
/// </summary>
|
||||
public sealed record MarkTenantAnnouncementReadCommand : IRequest<TenantAnnouncementDto?>
|
||||
public sealed record MarkAnnouncementAsReadCommand : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法)。
|
||||
/// 租户 ID(雪花算法,兼容旧调用,实际以当前租户为准)。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 公告 ID。
|
||||
/// </summary>
|
||||
[Range(1, long.MaxValue)]
|
||||
public long AnnouncementId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 发布公告命令。
|
||||
/// </summary>
|
||||
public sealed record PublishAnnouncementCommand : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 公告 ID。
|
||||
/// </summary>
|
||||
[Range(1, long.MaxValue)]
|
||||
public long AnnouncementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 撤销公告命令。
|
||||
/// </summary>
|
||||
public sealed record RevokeAnnouncementCommand : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 公告 ID。
|
||||
/// </summary>
|
||||
[Range(1, long.MaxValue)]
|
||||
public long AnnouncementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
@@ -22,35 +22,32 @@ public sealed record UpdateTenantAnnouncementCommand : IRequest<TenantAnnounceme
|
||||
/// <summary>
|
||||
/// 公告标题。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 公告内容。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 公告类型。
|
||||
/// 目标受众类型。
|
||||
/// </summary>
|
||||
public TenantAnnouncementType AnnouncementType { get; init; } = TenantAnnouncementType.System;
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string TargetType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优先级,数值越大越靠前。
|
||||
/// 目标受众参数(JSON)。
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 0;
|
||||
public string? TargetParameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 生效开始时间(UTC)。
|
||||
/// 并发控制版本。
|
||||
/// </summary>
|
||||
public DateTime EffectiveFrom { get; init; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 生效结束时间(UTC),为空则长期有效。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
@@ -52,7 +52,52 @@ public sealed class TenantAnnouncementDto
|
||||
public DateTime? EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// 发布者范围。
|
||||
/// </summary>
|
||||
public PublisherScope PublisherScope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发布者用户 ID。
|
||||
/// </summary>
|
||||
public long? PublisherUserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 公告状态。
|
||||
/// </summary>
|
||||
public AnnouncementStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 实际发布时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? PublishedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 撤销时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? RevokedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预定发布时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? ScheduledPublishAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众类型。
|
||||
/// </summary>
|
||||
public string TargetType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众参数(JSON)。
|
||||
/// </summary>
|
||||
public string? TargetParameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用(迁移期保留)。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; }
|
||||
|
||||
|
||||
@@ -2,16 +2,20 @@ using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 创建公告处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository)
|
||||
public sealed class CreateTenantAnnouncementCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<CreateTenantAnnouncementCommand, TenantAnnouncementDto>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -25,20 +29,37 @@ public sealed class CreateTenantAnnouncementCommandHandler(ITenantAnnouncementRe
|
||||
// 1. 校验标题与内容
|
||||
if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "公告标题和内容不能为空");
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "公告标题和内容不能为空");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TargetType))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "目标受众类型不能为空");
|
||||
}
|
||||
|
||||
if (request.TenantId == 0 && request.PublisherScope != PublisherScope.Platform)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId=0 仅允许平台公告");
|
||||
}
|
||||
|
||||
// 2. 构建公告实体
|
||||
var tenantId = request.PublisherScope == PublisherScope.Platform ? 0 : request.TenantId;
|
||||
var publisherUserId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
var announcement = new TenantAnnouncement
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
TenantId = tenantId,
|
||||
Title = request.Title.Trim(),
|
||||
Content = request.Content,
|
||||
AnnouncementType = request.AnnouncementType,
|
||||
Priority = request.Priority,
|
||||
EffectiveFrom = request.EffectiveFrom,
|
||||
EffectiveTo = request.EffectiveTo,
|
||||
IsActive = request.IsActive
|
||||
PublisherScope = request.PublisherScope,
|
||||
PublisherUserId = publisherUserId,
|
||||
Status = AnnouncementStatus.Draft,
|
||||
TargetType = request.TargetType.Trim(),
|
||||
TargetParameters = request.TargetParameters,
|
||||
IsActive = false
|
||||
};
|
||||
|
||||
// 3. 持久化并返回 DTO
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 公告详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetAnnouncementByIdQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository readRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
: IRequestHandler<GetAnnouncementByIdQuery, TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询公告详情。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>公告 DTO 或 null。</returns>
|
||||
public async Task<TenantAnnouncementDto?> Handle(GetAnnouncementByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 1. 查询公告主体(含平台公告)
|
||||
var announcement = await announcementRepository.FindByIdInScopeAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 目标受众过滤
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
if (!TargetTypeFilter.IsMatch(announcement, targetContext))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 优先查用户级已读
|
||||
var userId = targetContext.UserId;
|
||||
var reads = await readRepository.GetByAnnouncementAsync(
|
||||
tenantId,
|
||||
new[] { announcement.Id },
|
||||
userId == 0 ? null : userId,
|
||||
cancellationToken);
|
||||
|
||||
if (reads.Count == 0)
|
||||
{
|
||||
var tenantReads = await readRepository.GetByAnnouncementAsync(tenantId, new[] { announcement.Id }, null, cancellationToken);
|
||||
reads = tenantReads;
|
||||
}
|
||||
|
||||
var readRecord = reads.FirstOrDefault();
|
||||
return announcement.ToDto(readRecord != null, readRecord?.ReadAt);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 公告详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetTenantAnnouncementQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository readRepository,
|
||||
ICurrentUserAccessor? currentUserAccessor = null)
|
||||
: IRequestHandler<GetTenantAnnouncementQuery, TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询公告详情。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>公告 DTO 或 null。</returns>
|
||||
public async Task<TenantAnnouncementDto?> Handle(GetTenantAnnouncementQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询公告主体
|
||||
var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 优先查用户级已读
|
||||
var userId = currentUserAccessor?.UserId ?? 0;
|
||||
var reads = await readRepository.GetByAnnouncementAsync(
|
||||
request.TenantId,
|
||||
new[] { request.AnnouncementId },
|
||||
userId == 0 ? null : userId,
|
||||
cancellationToken);
|
||||
|
||||
// 如无用户级已读,再查租户级已读
|
||||
if (reads.Count == 0)
|
||||
{
|
||||
var tenantReads = await readRepository.GetByAnnouncementAsync(request.TenantId, new[] { request.AnnouncementId }, null, cancellationToken);
|
||||
reads = tenantReads;
|
||||
}
|
||||
|
||||
// 3. 返回 DTO 并附带已读状态
|
||||
var readRecord = reads.FirstOrDefault();
|
||||
return announcement.ToDto(readRecord != null, readRecord?.ReadAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 公告分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetTenantsAnnouncementsQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository announcementReadRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
: IRequestHandler<GetTenantsAnnouncementsQuery, PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询公告列表。
|
||||
/// </summary>
|
||||
/// <param name="request">查询条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页结果。</returns>
|
||||
public async Task<PagedResult<TenantAnnouncementDto>> Handle(GetTenantsAnnouncementsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var effectiveAt = request.OnlyEffective == true ? DateTime.UtcNow : (DateTime?)null;
|
||||
|
||||
// 计算分页参数
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
||||
|
||||
// 估算需要查询的数量:考虑到目标受众过滤可能会移除一些记录,
|
||||
// 我们查询 3 倍的数量以确保有足够的结果
|
||||
var estimatedLimit = page * size * 3;
|
||||
|
||||
// 1. 优化的数据库查询:应用排序和限制
|
||||
var announcements = await announcementRepository.SearchAsync(
|
||||
tenantId,
|
||||
request.Status,
|
||||
request.AnnouncementType,
|
||||
request.IsActive,
|
||||
request.EffectiveFrom,
|
||||
request.EffectiveTo,
|
||||
effectiveAt,
|
||||
orderByPriority: true, // 在数据库端排序
|
||||
limit: estimatedLimit, // 限制结果数量
|
||||
cancellationToken);
|
||||
|
||||
// 2. 内存过滤:ScheduledPublishAt
|
||||
if (effectiveAt.HasValue)
|
||||
{
|
||||
var at = effectiveAt.Value;
|
||||
announcements = announcements
|
||||
.Where(x => x.ScheduledPublishAt == null || x.ScheduledPublishAt <= at)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// 3. 目标受众过滤(在内存中,但数据量已大幅减少)
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
var filtered = announcements
|
||||
.Where(a => TargetTypeFilter.IsMatch(a, targetContext))
|
||||
.ToList();
|
||||
|
||||
// 注意:由于目标受众过滤可能移除记录,filtered.Count 可能小于请求的 size
|
||||
// 这是可接受的,因为精确计算总数代价高昂
|
||||
|
||||
// 4. 分页(数据已在数据库层排序,这里只需 Skip/Take)
|
||||
var pageItems = filtered
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToList();
|
||||
|
||||
// 5. 构建已读映射
|
||||
var announcementIds = pageItems.Select(x => x.Id).ToArray();
|
||||
var userId = targetContext.UserId;
|
||||
|
||||
var readMap = new Dictionary<long, (bool isRead, DateTime? readAt)>();
|
||||
if (announcementIds.Length > 0)
|
||||
{
|
||||
var reads = new List<Domain.Tenants.Entities.TenantAnnouncementRead>();
|
||||
if (userId != 0)
|
||||
{
|
||||
var userReads = await announcementReadRepository.GetByAnnouncementAsync(tenantId, announcementIds, userId, cancellationToken);
|
||||
reads.AddRange(userReads);
|
||||
}
|
||||
|
||||
var tenantReads = await announcementReadRepository.GetByAnnouncementAsync(tenantId, announcementIds, null, cancellationToken);
|
||||
reads.AddRange(tenantReads);
|
||||
|
||||
foreach (var read in reads.OrderByDescending(x => x.ReadAt))
|
||||
{
|
||||
if (readMap.ContainsKey(read.AnnouncementId) && read.UserId.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
readMap[read.AnnouncementId] = (true, read.ReadAt);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 映射 DTO 并带上已读状态
|
||||
var items = pageItems
|
||||
.Select(a =>
|
||||
{
|
||||
readMap.TryGetValue(a.Id, out var read);
|
||||
return a.ToDto(read.isRead, read.readAt);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 注意:由于我们使用了估算的 limit,总数是 filtered.Count 而不是数据库中的实际总数
|
||||
// 这是一个权衡:精确的总数需要额外的 COUNT 查询,代价较高
|
||||
return new PagedResult<TenantAnnouncementDto>(items, page, size, filtered.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 未读公告查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetUnreadAnnouncementsQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
: IRequestHandler<GetUnreadAnnouncementsQuery, PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<TenantAnnouncementDto>> Handle(GetUnreadAnnouncementsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var userId = currentUserAccessor?.UserId ?? 0;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 1. 查询未读公告(已发布/启用/有效期内)
|
||||
var announcements = await announcementRepository.SearchUnreadAsync(
|
||||
tenantId,
|
||||
userId == 0 ? null : userId,
|
||||
AnnouncementStatus.Published,
|
||||
true,
|
||||
now,
|
||||
cancellationToken);
|
||||
|
||||
announcements = announcements
|
||||
.Where(x => x.ScheduledPublishAt == null || x.ScheduledPublishAt <= now)
|
||||
.ToList();
|
||||
|
||||
// 2. 目标受众过滤
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
var filtered = announcements
|
||||
.Where(a => TargetTypeFilter.IsMatch(a, targetContext))
|
||||
.ToList();
|
||||
|
||||
// 3. 排序与分页
|
||||
var ordered = filtered
|
||||
.OrderByDescending(x => x.Priority)
|
||||
.ThenByDescending(x => x.EffectiveFrom)
|
||||
.ToList();
|
||||
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
||||
var pageItems = ordered
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToList();
|
||||
|
||||
var items = pageItems
|
||||
.Select(x => x.ToDto(false, null))
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<TenantAnnouncementDto>(items, page, size, ordered.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 标记公告已读处理器。
|
||||
/// </summary>
|
||||
public sealed class MarkAnnouncementAsReadCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository readRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IAdminAuthService? adminAuthService = null,
|
||||
IMiniAuthService? miniAuthService = null)
|
||||
: IRequestHandler<MarkAnnouncementAsReadCommand, TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记公告已读。
|
||||
/// </summary>
|
||||
/// <param name="request">标记命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>公告 DTO 或 null。</returns>
|
||||
public async Task<TenantAnnouncementDto?> Handle(MarkAnnouncementAsReadCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
// 1. 查询公告(含平台公告)
|
||||
var announcement = await announcementRepository.FindByIdInScopeAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 仅允许已发布且在有效期内的公告标记已读
|
||||
var now = DateTime.UtcNow;
|
||||
if (announcement.Status != AnnouncementStatus.Published || !announcement.IsActive)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (announcement.EffectiveFrom > now || (announcement.EffectiveTo.HasValue && announcement.EffectiveTo.Value < now))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (announcement.ScheduledPublishAt.HasValue && announcement.ScheduledPublishAt.Value > now)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 目标受众过滤
|
||||
var targetContext = await AnnouncementTargetContextFactory.BuildAsync(
|
||||
tenantProvider,
|
||||
currentUserAccessor,
|
||||
adminAuthService,
|
||||
miniAuthService,
|
||||
cancellationToken);
|
||||
|
||||
if (!TargetTypeFilter.IsMatch(announcement, targetContext))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. 确定用户标识
|
||||
var userId = targetContext.UserId == 0 ? (long?)null : targetContext.UserId;
|
||||
var existing = await readRepository.FindAsync(tenantId, announcement.Id, userId, cancellationToken);
|
||||
|
||||
if (existing == null && userId.HasValue)
|
||||
{
|
||||
existing = await readRepository.FindAsync(tenantId, announcement.Id, null, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 如未读则写入已读记录
|
||||
if (existing == null)
|
||||
{
|
||||
var record = new TenantAnnouncementRead
|
||||
{
|
||||
TenantId = tenantId,
|
||||
AnnouncementId = announcement.Id,
|
||||
UserId = userId,
|
||||
ReadAt = now
|
||||
};
|
||||
|
||||
await readRepository.AddAsync(record, cancellationToken);
|
||||
await readRepository.SaveChangesAsync(cancellationToken);
|
||||
existing = record;
|
||||
}
|
||||
|
||||
return announcement.ToDto(true, existing.ReadAt);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 标记公告已读处理器。
|
||||
/// </summary>
|
||||
public sealed class MarkTenantAnnouncementReadCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository readRepository,
|
||||
ICurrentUserAccessor? currentUserAccessor = null)
|
||||
: IRequestHandler<MarkTenantAnnouncementReadCommand, TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记公告已读。
|
||||
/// </summary>
|
||||
/// <param name="request">标记命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>公告 DTO 或 null。</returns>
|
||||
public async Task<TenantAnnouncementDto?> Handle(MarkTenantAnnouncementReadCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询公告
|
||||
var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 确定用户标识
|
||||
var userId = currentUserAccessor?.UserId ?? 0;
|
||||
var existing = await readRepository.FindAsync(request.TenantId, request.AnnouncementId, userId == 0 ? null : userId, cancellationToken);
|
||||
|
||||
// 3. 如未读则写入已读记录
|
||||
if (existing == null)
|
||||
{
|
||||
var record = new TenantAnnouncementRead
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
AnnouncementId = request.AnnouncementId,
|
||||
UserId = userId == 0 ? null : userId,
|
||||
ReadAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await readRepository.AddAsync(record, cancellationToken);
|
||||
await readRepository.SaveChangesAsync(cancellationToken);
|
||||
existing = record;
|
||||
}
|
||||
|
||||
// 4. 返回带已读时间的公告 DTO
|
||||
return announcement.ToDto(true, existing.ReadAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.Messaging.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Events;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 发布公告处理器。
|
||||
/// </summary>
|
||||
public sealed class PublishAnnouncementCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IEventPublisher eventPublisher)
|
||||
: IRequestHandler<PublishAnnouncementCommand, TenantAnnouncementDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantAnnouncementDto?> Handle(PublishAnnouncementCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询公告
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var announcement = await announcementRepository.FindByIdAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 校验状态与目标受众
|
||||
if (string.IsNullOrWhiteSpace(announcement.TargetType))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "目标受众类型不能为空");
|
||||
}
|
||||
|
||||
if (announcement.Status == AnnouncementStatus.Published)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "公告已发布");
|
||||
}
|
||||
|
||||
if (announcement.Status != AnnouncementStatus.Draft && announcement.Status != AnnouncementStatus.Revoked)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "仅草稿或已撤销公告允许发布");
|
||||
}
|
||||
|
||||
// 3. 发布公告
|
||||
announcement.Status = AnnouncementStatus.Published;
|
||||
announcement.PublishedAt = DateTime.UtcNow;
|
||||
announcement.RevokedAt = null;
|
||||
announcement.IsActive = true;
|
||||
announcement.RowVersion = request.RowVersion;
|
||||
|
||||
await announcementRepository.UpdateAsync(announcement, cancellationToken);
|
||||
await announcementRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. 发布领域事件
|
||||
await eventPublisher.PublishAsync(
|
||||
"tenant-announcement.published",
|
||||
new AnnouncementPublished
|
||||
{
|
||||
AnnouncementId = announcement.Id,
|
||||
PublishedAt = announcement.PublishedAt ?? DateTime.UtcNow,
|
||||
TargetType = announcement.TargetType
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return announcement.ToDto(false, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.Messaging.Abstractions;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Events;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 撤销公告处理器。
|
||||
/// </summary>
|
||||
public sealed class RevokeAnnouncementCommandHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IEventPublisher eventPublisher)
|
||||
: IRequestHandler<RevokeAnnouncementCommand, TenantAnnouncementDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantAnnouncementDto?> Handle(RevokeAnnouncementCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询公告
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var announcement = await announcementRepository.FindByIdAsync(tenantId, request.AnnouncementId, cancellationToken);
|
||||
if (announcement == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 校验状态
|
||||
if (announcement.Status != AnnouncementStatus.Published)
|
||||
{
|
||||
if (announcement.Status == AnnouncementStatus.Revoked)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "公告已撤销");
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.Conflict, "仅已发布公告允许撤销");
|
||||
}
|
||||
|
||||
// 3. 撤销公告
|
||||
announcement.Status = AnnouncementStatus.Revoked;
|
||||
announcement.RevokedAt = DateTime.UtcNow;
|
||||
announcement.IsActive = false;
|
||||
announcement.RowVersion = request.RowVersion;
|
||||
|
||||
await announcementRepository.UpdateAsync(announcement, cancellationToken);
|
||||
await announcementRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. 发布领域事件
|
||||
await eventPublisher.PublishAsync(
|
||||
"tenant-announcement.revoked",
|
||||
new AnnouncementRevoked
|
||||
{
|
||||
AnnouncementId = announcement.Id,
|
||||
RevokedAt = announcement.RevokedAt ?? DateTime.UtcNow
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return announcement.ToDto(false, null);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 公告分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchTenantAnnouncementsQueryHandler(
|
||||
ITenantAnnouncementRepository announcementRepository,
|
||||
ITenantAnnouncementReadRepository announcementReadRepository,
|
||||
ICurrentUserAccessor? currentUserAccessor = null)
|
||||
: IRequestHandler<SearchTenantAnnouncementsQuery, PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询公告列表。
|
||||
/// </summary>
|
||||
/// <param name="request">查询条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页结果。</returns>
|
||||
public async Task<PagedResult<TenantAnnouncementDto>> Handle(SearchTenantAnnouncementsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 过滤有效期条件
|
||||
var effectiveAt = request.OnlyEffective == true ? DateTime.UtcNow : (DateTime?)null;
|
||||
var announcements = await announcementRepository.SearchAsync(request.TenantId, request.AnnouncementType, request.IsActive, effectiveAt, cancellationToken);
|
||||
|
||||
// 2. 排序(优先级/时间)
|
||||
var ordered = announcements
|
||||
.OrderByDescending(x => x.Priority)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
// 3. 计算分页参数
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
||||
|
||||
// 4. 分页
|
||||
var pageItems = ordered
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.ToList();
|
||||
|
||||
// 5. 构建已读映射
|
||||
var announcementIds = pageItems.Select(x => x.Id).ToArray();
|
||||
var userId = currentUserAccessor?.UserId ?? 0;
|
||||
|
||||
var readMap = new Dictionary<long, (bool isRead, DateTime? readAt)>();
|
||||
if (announcementIds.Length > 0)
|
||||
{
|
||||
// 优先查询当前用户维度的已读,其次租户级已读(UserId null)
|
||||
var reads = new List<Domain.Tenants.Entities.TenantAnnouncementRead>();
|
||||
if (userId != 0)
|
||||
{
|
||||
var userReads = await announcementReadRepository.GetByAnnouncementAsync(request.TenantId, announcementIds, userId, cancellationToken);
|
||||
reads.AddRange(userReads);
|
||||
}
|
||||
|
||||
var tenantReads = await announcementReadRepository.GetByAnnouncementAsync(request.TenantId, announcementIds, null, cancellationToken);
|
||||
reads.AddRange(tenantReads);
|
||||
|
||||
foreach (var read in reads.OrderByDescending(x => x.ReadAt))
|
||||
{
|
||||
// 若已存在用户级标记,跳过租户级覆盖
|
||||
if (readMap.ContainsKey(read.AnnouncementId) && read.UserId.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
readMap[read.AnnouncementId] = (true, read.ReadAt);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 映射 DTO 并带上已读状态
|
||||
var items = pageItems
|
||||
.Select(a =>
|
||||
{
|
||||
readMap.TryGetValue(a.Id, out var read);
|
||||
return a.ToDto(read.isRead, read.readAt);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 7. 返回分页结果
|
||||
return new PagedResult<TenantAnnouncementDto>(items, page, size, ordered.Count);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
@@ -18,7 +19,7 @@ public sealed class UpdateTenantAnnouncementCommandHandler(ITenantAnnouncementRe
|
||||
// 1. 校验输入
|
||||
if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "公告标题和内容不能为空");
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "公告标题和内容不能为空");
|
||||
}
|
||||
|
||||
// 2. 查询公告
|
||||
@@ -28,14 +29,23 @@ public sealed class UpdateTenantAnnouncementCommandHandler(ITenantAnnouncementRe
|
||||
return null;
|
||||
}
|
||||
|
||||
if (announcement.Status != AnnouncementStatus.Draft)
|
||||
{
|
||||
if (announcement.Status == AnnouncementStatus.Published)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "已发布公告不可编辑,要编辑已发布公告,请先撤销");
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCodes.Conflict, "仅草稿公告允许编辑");
|
||||
}
|
||||
|
||||
// 3. 更新字段
|
||||
announcement.Title = request.Title.Trim();
|
||||
announcement.Content = request.Content;
|
||||
announcement.AnnouncementType = request.AnnouncementType;
|
||||
announcement.Priority = request.Priority;
|
||||
announcement.EffectiveFrom = request.EffectiveFrom;
|
||||
announcement.EffectiveTo = request.EffectiveTo;
|
||||
announcement.IsActive = request.IsActive;
|
||||
announcement.TargetType = string.IsNullOrWhiteSpace(request.TargetType) ? announcement.TargetType : request.TargetType.Trim();
|
||||
announcement.TargetParameters = request.TargetParameters;
|
||||
announcement.IsActive = false;
|
||||
announcement.RowVersion = request.RowVersion;
|
||||
|
||||
// 4. 持久化
|
||||
await announcementRepository.UpdateAsync(announcement, cancellationToken);
|
||||
|
||||
@@ -6,10 +6,10 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
/// <summary>
|
||||
/// 公告详情查询。
|
||||
/// </summary>
|
||||
public sealed record GetTenantAnnouncementQuery : IRequest<TenantAnnouncementDto?>
|
||||
public sealed record GetAnnouncementByIdQuery : IRequest<TenantAnnouncementDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法)。
|
||||
/// 租户 ID(雪花算法,兼容旧调用,实际以当前租户为准)。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
/// <summary>
|
||||
/// 分页查询租户公告。
|
||||
/// </summary>
|
||||
public sealed record SearchTenantAnnouncementsQuery : IRequest<PagedResult<TenantAnnouncementDto>>
|
||||
public sealed record GetTenantsAnnouncementsQuery : IRequest<PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法)。
|
||||
@@ -20,11 +20,26 @@ public sealed record SearchTenantAnnouncementsQuery : IRequest<PagedResult<Tenan
|
||||
/// </summary>
|
||||
public TenantAnnouncementType? AnnouncementType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 公告状态筛选。
|
||||
/// </summary>
|
||||
public AnnouncementStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否筛选启用状态。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 生效开始时间筛选(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 生效结束时间筛选(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 仅返回当前有效期内的公告。
|
||||
/// </summary>
|
||||
@@ -0,0 +1,21 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询未读公告。
|
||||
/// </summary>
|
||||
public sealed record GetUnreadAnnouncementsQuery : IRequest<PagedResult<TenantAnnouncementDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众上下文构建器。
|
||||
/// </summary>
|
||||
internal static class AnnouncementTargetContextFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 构建当前用户的目标上下文。
|
||||
/// </summary>
|
||||
public static async Task<AnnouncementTargetContext> BuildAsync(
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor,
|
||||
IAdminAuthService? adminAuthService,
|
||||
IMiniAuthService? miniAuthService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var userId = currentUserAccessor?.UserId ?? 0;
|
||||
long? merchantId = null;
|
||||
IReadOnlyCollection<string> roles = Array.Empty<string>();
|
||||
IReadOnlyCollection<string> permissions = Array.Empty<string>();
|
||||
|
||||
if (userId != 0)
|
||||
{
|
||||
CurrentUserProfile? profile = null;
|
||||
if (adminAuthService != null)
|
||||
{
|
||||
profile = await adminAuthService.GetProfileAsync(userId, cancellationToken);
|
||||
}
|
||||
else if (miniAuthService != null)
|
||||
{
|
||||
profile = await miniAuthService.GetProfileAsync(userId, cancellationToken);
|
||||
}
|
||||
|
||||
if (profile != null)
|
||||
{
|
||||
merchantId = profile.MerchantId;
|
||||
roles = profile.Roles ?? Array.Empty<string>();
|
||||
permissions = profile.Permissions ?? Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
return new AnnouncementTargetContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
MerchantId = merchantId,
|
||||
Roles = roles,
|
||||
Permissions = permissions
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Targeting;
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众过滤器。
|
||||
/// </summary>
|
||||
public static class TargetTypeFilter
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 判断公告是否匹配当前用户上下文。
|
||||
/// </summary>
|
||||
/// <param name="announcement">公告实体。</param>
|
||||
/// <param name="context">目标上下文。</param>
|
||||
/// <returns>是否匹配。</returns>
|
||||
public static bool IsMatch(TenantAnnouncement announcement, AnnouncementTargetContext context)
|
||||
{
|
||||
if (announcement == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetType = announcement.TargetType?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(targetType))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = targetType.ToUpperInvariant();
|
||||
var parsed = TryParseParameters(announcement.TargetParameters, out var payload);
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"ALL_TENANTS" => ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
|
||||
"TENANT_ALL" => announcement.TenantId == context.TenantId
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
|
||||
"SPECIFIC_TENANTS" => RequireTenantMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"USERS" or "SPECIFIC_USERS" or "USER_IDS" => RequireUserMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"ROLES" or "ROLE" => RequireRoleMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"PERMISSIONS" or "PERMISSION" => RequirePermissionMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
"MERCHANTS" or "MERCHANT_IDS" => RequireMerchantMatch(payload, parsed, context)
|
||||
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false),
|
||||
_ => ApplyPayloadConstraints(payload, parsed, context, allowEmpty: false)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool RequireTenantMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.TenantIds is { Length: > 0 } && payload.TenantIds.Contains(context.TenantId);
|
||||
|
||||
private static bool RequireUserMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.UserIds is { Length: > 0 } && context.UserId != 0 && payload.UserIds.Contains(context.UserId);
|
||||
|
||||
private static bool RequireMerchantMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.MerchantIds is { Length: > 0 } && context.MerchantId.HasValue && payload.MerchantIds.Contains(context.MerchantId.Value);
|
||||
|
||||
private static bool RequireRoleMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.Roles is { Length: > 0 } && Intersects(payload.Roles, context.Roles);
|
||||
|
||||
private static bool RequirePermissionMatch(TargetParametersPayload payload, bool parsed, AnnouncementTargetContext context)
|
||||
=> parsed && payload.Permissions is { Length: > 0 } && Intersects(payload.Permissions, context.Permissions);
|
||||
|
||||
private static bool ApplyPayloadConstraints(
|
||||
TargetParametersPayload payload,
|
||||
bool parsed,
|
||||
AnnouncementTargetContext context,
|
||||
bool allowEmpty)
|
||||
{
|
||||
if (!parsed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!payload.HasConstraints)
|
||||
{
|
||||
return allowEmpty;
|
||||
}
|
||||
|
||||
if (payload.TenantIds is { Length: > 0 } && !payload.TenantIds.Contains(context.TenantId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.UserIds is { Length: > 0 })
|
||||
{
|
||||
if (context.UserId == 0 || !payload.UserIds.Contains(context.UserId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.MerchantIds is { Length: > 0 })
|
||||
{
|
||||
if (!context.MerchantId.HasValue || !payload.MerchantIds.Contains(context.MerchantId.Value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.Roles is { Length: > 0 } && !Intersects(payload.Roles, context.Roles))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.Permissions is { Length: > 0 } && !Intersects(payload.Permissions, context.Permissions))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.Departments is { Length: > 0 } && !Intersects(payload.Departments, context.Departments))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseParameters(string? json, out TargetParametersPayload payload)
|
||||
{
|
||||
payload = new TargetParametersPayload();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<TargetParametersPayload>(json, Options) ?? new TargetParametersPayload();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Intersects(IEnumerable<string> left, IEnumerable<string> right)
|
||||
{
|
||||
var set = new HashSet<string>(right ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in left ?? Array.Empty<string>())
|
||||
{
|
||||
if (set.Contains(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class TargetParametersPayload
|
||||
{
|
||||
public long[]? TenantIds { get; init; }
|
||||
public long[]? UserIds { get; init; }
|
||||
public long[]? MerchantIds { get; init; }
|
||||
public string[]? Roles { get; init; }
|
||||
public string[]? Permissions { get; init; }
|
||||
public string[]? Departments { get; init; }
|
||||
|
||||
public bool HasConstraints
|
||||
=> (TenantIds?.Length ?? 0) > 0
|
||||
|| (UserIds?.Length ?? 0) > 0
|
||||
|| (MerchantIds?.Length ?? 0) > 0
|
||||
|| (Roles?.Length ?? 0) > 0
|
||||
|| (Permissions?.Length ?? 0) > 0
|
||||
|| (Departments?.Length ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目标受众上下文。
|
||||
/// </summary>
|
||||
public sealed record AnnouncementTargetContext
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户 ID(可选)。
|
||||
/// </summary>
|
||||
public long? MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Roles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 权限集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 部门集合(可选)。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Departments { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -184,6 +184,15 @@ internal static class TenantMapping
|
||||
Priority = announcement.Priority,
|
||||
EffectiveFrom = announcement.EffectiveFrom,
|
||||
EffectiveTo = announcement.EffectiveTo,
|
||||
PublisherScope = announcement.PublisherScope,
|
||||
PublisherUserId = announcement.PublisherUserId,
|
||||
Status = announcement.Status,
|
||||
PublishedAt = announcement.PublishedAt,
|
||||
RevokedAt = announcement.RevokedAt,
|
||||
ScheduledPublishAt = announcement.ScheduledPublishAt,
|
||||
TargetType = announcement.TargetType,
|
||||
TargetParameters = announcement.TargetParameters,
|
||||
RowVersion = announcement.RowVersion,
|
||||
IsActive = announcement.IsActive,
|
||||
IsRead = isRead,
|
||||
ReadAt = readAt
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 创建公告命令验证器。
|
||||
/// </summary>
|
||||
public sealed class CreateAnnouncementCommandValidator : AbstractValidator<CreateTenantAnnouncementCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public CreateAnnouncementCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty()
|
||||
.MaximumLength(128);
|
||||
|
||||
RuleFor(x => x.Content)
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.TargetType)
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.TenantId != 0 || x.PublisherScope == PublisherScope.Platform)
|
||||
.WithMessage("TenantId=0 仅允许平台公告");
|
||||
|
||||
RuleFor(x => x.EffectiveTo)
|
||||
.Must((command, effectiveTo) => !effectiveTo.HasValue || command.EffectiveFrom < effectiveTo.Value)
|
||||
.WithMessage("生效开始时间必须早于结束时间");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 发布公告命令验证器。
|
||||
/// </summary>
|
||||
public sealed class PublishAnnouncementCommandValidator : AbstractValidator<PublishAnnouncementCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public PublishAnnouncementCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.AnnouncementId)
|
||||
.GreaterThan(0);
|
||||
|
||||
RuleFor(x => x.RowVersion)
|
||||
.NotNull()
|
||||
.Must(rowVersion => rowVersion != null && rowVersion.Length > 0)
|
||||
.WithMessage("RowVersion 不能为空");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 撤销公告命令验证器。
|
||||
/// </summary>
|
||||
public sealed class RevokeAnnouncementCommandValidator : AbstractValidator<RevokeAnnouncementCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public RevokeAnnouncementCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.AnnouncementId)
|
||||
.GreaterThan(0);
|
||||
|
||||
RuleFor(x => x.RowVersion)
|
||||
.NotNull()
|
||||
.Must(rowVersion => rowVersion != null && rowVersion.Length > 0)
|
||||
.WithMessage("RowVersion 不能为空");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 更新公告命令验证器。
|
||||
/// </summary>
|
||||
public sealed class UpdateAnnouncementCommandValidator : AbstractValidator<UpdateTenantAnnouncementCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public UpdateAnnouncementCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title)
|
||||
.NotEmpty()
|
||||
.MaximumLength(128);
|
||||
|
||||
RuleFor(x => x.Content)
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.RowVersion)
|
||||
.NotNull()
|
||||
.Must(rowVersion => rowVersion != null && rowVersion.Length > 0)
|
||||
.WithMessage("RowVersion 不能为空");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user