refactor: 移除平台公告与跨租户目标

This commit is contained in:
root
2026-01-29 13:15:53 +00:00
parent 0d9402d204
commit 77836e270f
16 changed files with 110 additions and 58 deletions

View File

@@ -1,6 +1,7 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Targeting;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
@@ -57,6 +58,11 @@ public sealed class CreateTenantAnnouncementCommandHandler(
{
throw new BusinessException(ErrorCodes.ValidationFailed, "目标受众类型不能为空");
}
// 4.1 (空行后) 校验目标受众类型:租户端禁止跨租户目标类型
if (!TenantAnnouncementTargetTypePolicy.IsAllowed(request.TargetType))
{
throw new BusinessException(ErrorCodes.ValidationFailed, "租户端不支持该目标受众类型");
}
if (request.EffectiveTo.HasValue && request.EffectiveFrom >= request.EffectiveTo.Value)
{
@@ -66,6 +72,8 @@ public sealed class CreateTenantAnnouncementCommandHandler(
// 5. (空行后) 构建公告实体
var tenantId = currentTenantId;
var publisherUserId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
// 5.1 (空行后) 规范化目标类型,避免写入脏数据
var normalizedTargetType = TenantAnnouncementTargetTypePolicy.Normalize(request.TargetType);
var announcement = new TenantAnnouncement
{
TenantId = tenantId,
@@ -78,7 +86,7 @@ public sealed class CreateTenantAnnouncementCommandHandler(
PublisherScope = request.PublisherScope,
PublisherUserId = publisherUserId,
Status = AnnouncementStatus.Draft,
TargetType = request.TargetType.Trim(),
TargetType = normalizedTargetType,
TargetParameters = request.TargetParameters
};

View File

@@ -31,8 +31,8 @@ public sealed class GetAnnouncementByIdQueryHandler(
{
var tenantId = tenantProvider.GetCurrentTenantId();
// 1. 查询公告主体(含平台公告)
var announcement = await announcementRepository.FindByIdInScopeAsync(tenantId, request.AnnouncementId, cancellationToken);
// 1. 查询公告主体
var announcement = await announcementRepository.FindByIdAsync(tenantId, request.AnnouncementId, cancellationToken);
if (announcement == null)
{
return null;

View File

@@ -33,8 +33,8 @@ public sealed class MarkAnnouncementAsReadCommandHandler(
{
var tenantId = tenantProvider.GetCurrentTenantId();
// 1. 查询公告(含平台公告)
var announcement = await announcementRepository.FindByIdInScopeAsync(tenantId, request.AnnouncementId, cancellationToken);
// 1. 查询公告
var announcement = await announcementRepository.FindByIdAsync(tenantId, request.AnnouncementId, cancellationToken);
if (announcement == null)
{
return null;

View File

@@ -1,6 +1,7 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Targeting;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
@@ -42,6 +43,11 @@ public sealed class UpdateTenantAnnouncementCommandHandler(
{
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
}
// 3.1 (空行后) 校验目标受众类型:租户端禁止跨租户目标类型
if (!TenantAnnouncementTargetTypePolicy.IsAllowed(request.TargetType))
{
throw new BusinessException(ErrorCodes.ValidationFailed, "租户端不支持该目标受众类型");
}
// 4. (空行后) 查询公告
var announcement = await announcementRepository.FindByIdAsync(currentTenantId, request.AnnouncementId, cancellationToken);
@@ -63,7 +69,7 @@ public sealed class UpdateTenantAnnouncementCommandHandler(
// 5. (空行后) 更新字段
announcement.Title = request.Title.Trim();
announcement.Content = request.Content;
announcement.TargetType = string.IsNullOrWhiteSpace(request.TargetType) ? announcement.TargetType : request.TargetType.Trim();
announcement.TargetType = TenantAnnouncementTargetTypePolicy.Normalize(request.TargetType);
announcement.TargetParameters = request.TargetParameters;
announcement.RowVersion = request.RowVersion;

View File

@@ -26,6 +26,12 @@ public static class TargetTypeFilter
return false;
}
// 1. 租户端严格限制:公告必须属于当前租户
if (announcement.TenantId != context.TenantId)
{
return false;
}
var targetType = announcement.TargetType?.Trim();
if (string.IsNullOrWhiteSpace(targetType))
{
@@ -37,13 +43,7 @@ public static class TargetTypeFilter
return normalized switch
{
"ALL" => announcement.TenantId == 0
? ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true)
: announcement.TenantId == context.TenantId
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
"ALL_TENANTS" => ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
"TENANT_ALL" => announcement.TenantId == context.TenantId
&& ApplyPayloadConstraints(payload, parsed, context, allowEmpty: true),
"ALL" or "ALL_TENANTS" or "TENANT_ALL" => 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)

View File

@@ -0,0 +1,49 @@
using System.Collections.Frozen;
namespace TakeoutSaaS.Application.App.Tenants.Targeting;
/// <summary>
/// 租户公告目标受众类型策略(租户端)。
/// </summary>
public static class TenantAnnouncementTargetTypePolicy
{
private static readonly FrozenSet<string> AllowedTargetTypes = FrozenSet.ToFrozenSet(
new[]
{
"ALL",
"TENANT_ALL",
"USERS",
"SPECIFIC_USERS",
"USER_IDS",
"ROLES",
"ROLE",
"PERMISSIONS",
"PERMISSION",
"MERCHANTS",
"MERCHANT_IDS"
},
StringComparer.Ordinal);
/// <summary>
/// 判断目标受众类型在租户端是否允许。
/// </summary>
/// <param name="targetType">目标受众类型。</param>
/// <returns>允许返回 true否则 false。</returns>
public static bool IsAllowed(string? targetType)
{
if (string.IsNullOrWhiteSpace(targetType))
{
return false;
}
var normalized = Normalize(targetType);
return AllowedTargetTypes.Contains(normalized);
}
/// <summary>
/// 规范化目标受众类型Trim + UpperInvariant
/// </summary>
/// <param name="targetType">目标受众类型。</param>
/// <returns>规范化后的类型。</returns>
public static string Normalize(string targetType) => targetType.Trim().ToUpperInvariant();
}

View File

@@ -1,5 +1,6 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Targeting;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Tenants.Validators;
@@ -23,6 +24,10 @@ public sealed class CreateAnnouncementCommandValidator : AbstractValidator<Creat
RuleFor(x => x.TargetType)
.NotEmpty();
// 1. (空行后) 限制租户端目标类型,禁止跨租户目标
RuleFor(x => x.TargetType)
.Must(TenantAnnouncementTargetTypePolicy.IsAllowed)
.WithMessage("租户端不支持该目标受众类型");
RuleFor(x => x.TenantId)
.GreaterThan(0)

View File

@@ -1,5 +1,6 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Targeting;
namespace TakeoutSaaS.Application.App.Tenants.Validators;
@@ -20,6 +21,11 @@ public sealed class UpdateAnnouncementCommandValidator : AbstractValidator<Updat
RuleFor(x => x.Content)
.NotEmpty();
RuleFor(x => x.TargetType)
.NotEmpty()
.Must(TenantAnnouncementTargetTypePolicy.IsAllowed)
.WithMessage("租户端不支持该目标受众类型");
RuleFor(x => x.RowVersion)
.NotNull()
.Must(rowVersion => rowVersion != null && rowVersion.Length > 0)

View File

@@ -9,7 +9,7 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories;
public interface ITenantAnnouncementRepository
{
/// <summary>
/// 查询公告列表(包含平台公告 TenantId=0,按类型、状态与生效时间筛选。
/// 查询公告列表,按类型、状态与生效时间筛选。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="keyword">关键词(标题/内容)。</param>
@@ -37,16 +37,7 @@ public interface ITenantAnnouncementRepository
CancellationToken cancellationToken = default);
/// <summary>
/// 按 ID 获取公告(包含平台公告 TenantId=0
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="announcementId">公告 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>公告实体或 null。</returns>
Task<TenantAnnouncement?> FindByIdInScopeAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default);
/// <summary>
/// 查询未读公告(包含平台公告 TenantId=0
/// 查询未读公告
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="userId">用户 ID。</param>

View File

@@ -25,10 +25,8 @@ public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context)
int? limit = null,
CancellationToken cancellationToken = default)
{
var tenantIds = new[] { tenantId, 0L };
var query = context.TenantAnnouncements.AsNoTracking()
.IgnoreQueryFilters()
.Where(x => tenantIds.Contains(x.TenantId));
.Where(x => x.TenantId == tenantId);
if (!string.IsNullOrWhiteSpace(keyword))
{
@@ -86,15 +84,6 @@ public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context)
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantAnnouncement?> FindByIdInScopeAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default)
{
var tenantIds = new[] { tenantId, 0L };
return context.TenantAnnouncements.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => tenantIds.Contains(x.TenantId) && x.Id == announcementId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantAnnouncement>> SearchUnreadAsync(
long tenantId,
@@ -104,10 +93,8 @@ public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context)
DateTime? effectiveAt,
CancellationToken cancellationToken = default)
{
var tenantIds = new[] { tenantId, 0L };
var announcementQuery = context.TenantAnnouncements.AsNoTracking()
.IgnoreQueryFilters()
.Where(x => tenantIds.Contains(x.TenantId));
.Where(x => x.TenantId == tenantId);
if (status.HasValue)
{
@@ -128,7 +115,6 @@ public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context)
}
var readQuery = context.TenantAnnouncementReads.AsNoTracking()
.IgnoreQueryFilters()
.Where(x => x.TenantId == tenantId);
readQuery = userId.HasValue