From 755b61a044a97c80f7386bd9791812491136f9d2 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Fri, 26 Dec 2025 09:08:30 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=A0=B8=E5=BF=83=E9=97=AE=E9=A2=98=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要修复内容: 1. 修复 RowVersion 并发控制 - 配置 EF Core RowVersion 映射为 bytea 类型 - 添加 PostgreSQL 触发器自动生成 RowVersion - 在更新/发布/撤销操作中添加 RowVersion 校验 - 移除 Application 层对 EF Core 的直接依赖 2. 修复 API 路由和校验问题 - 添加平台公告列表路由的版本化别名 - 租户公告接口添加 X-Tenant-Id 必填校验,返回 400 - 生效时间校验返回 422 而非 500 - 修复 FluentValidation 异常命名冲突 3. 实现关键词搜索功能 - 在查询参数中添加 keyword 字段 - 使用 PostgreSQL ILIKE 实现大小写不敏感搜索 - 支持标题和内容字段的模糊匹配 4. 数据库迁移 - 新增 RowVersion 触发器迁移文件 - 回填现有公告记录的 RowVersion --- .../PlatformAnnouncementsController.cs | 1 + .../TenantAnnouncementsController.cs | 65 +++++++++++++++++++ .../Common/Behaviors/ValidationBehavior.cs | 12 +++- .../GetTenantsAnnouncementsQueryHandler.cs | 1 + .../PublishAnnouncementCommandHandler.cs | 5 ++ .../RevokeAnnouncementCommandHandler.cs | 5 ++ .../UpdateTenantAnnouncementCommandHandler.cs | 5 ++ .../Queries/GetTenantsAnnouncementsQuery.cs | 5 ++ .../App/Tenants/Targeting/TargetTypeFilter.cs | 4 ++ .../CreateAnnouncementCommandValidator.cs | 5 +- .../ITenantAnnouncementRepository.cs | 2 + .../App/Persistence/TakeoutAppDbContext.cs | 13 ++-- .../EfTenantAnnouncementRepository.cs | 9 +++ ..._AddTenantAnnouncementRowVersionTrigger.cs | 50 ++++++++++++++ .../App/Tenants/AnnouncementWorkflowTests.cs | 5 +- 15 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251225090000_AddTenantAnnouncementRowVersionTrigger.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PlatformAnnouncementsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PlatformAnnouncementsController.cs index fedfaa0..0c12f90 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/PlatformAnnouncementsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PlatformAnnouncementsController.cs @@ -20,6 +20,7 @@ namespace TakeoutSaaS.AdminApi.Controllers; [ApiVersion("1.0")] [Authorize] [Route("api/platform/announcements")] +[Route("api/admin/v{version:apiVersion}/platform/announcements")] public sealed class PlatformAnnouncementsController(IMediator mediator, ITenantContextAccessor tenantContextAccessor) : BaseApiController { /// diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs index 1c1bb7f..59f47e7 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs @@ -20,6 +20,8 @@ namespace TakeoutSaaS.AdminApi.Controllers; [Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/announcements")] public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiController { + private const string TenantIdHeaderName = "X-Tenant-Id"; + /// /// 分页查询公告。 /// @@ -47,6 +49,12 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] public async Task>> Search(long tenantId, [FromQuery] GetTenantsAnnouncementsQuery query, CancellationToken cancellationToken) { + var headerError = EnsureTenantHeader>(); + if (headerError != null) + { + return headerError; + } + query = query with { TenantId = tenantId }; var result = await mediator.Send(query, cancellationToken); return ApiResponse>.Ok(result); @@ -80,6 +88,12 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] public async Task> Detail(long tenantId, long announcementId, CancellationToken cancellationToken) { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + var result = await mediator.Send(new GetAnnouncementByIdQuery { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") @@ -124,6 +138,12 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] public async Task> Create(long tenantId, [FromBody, Required] CreateTenantAnnouncementCommand command, CancellationToken cancellationToken) { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + command = command with { TenantId = tenantId }; var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); @@ -164,6 +184,12 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] public async Task> Update(long tenantId, long announcementId, [FromBody, Required] UpdateTenantAnnouncementCommand command, CancellationToken cancellationToken) { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + command = command with { TenantId = tenantId, AnnouncementId = announcementId }; var result = await mediator.Send(command, cancellationToken); return result is null @@ -202,6 +228,12 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] public async Task> Publish(long tenantId, long announcementId, [FromBody, Required] PublishAnnouncementCommand command, CancellationToken cancellationToken) { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + command = command with { AnnouncementId = announcementId }; var result = await mediator.Send(command, cancellationToken); return result is null @@ -240,6 +272,12 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] public async Task> Revoke(long tenantId, long announcementId, [FromBody, Required] RevokeAnnouncementCommand command, CancellationToken cancellationToken) { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + command = command with { AnnouncementId = announcementId }; var result = await mediator.Send(command, cancellationToken); return result is null @@ -269,6 +307,12 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] public async Task> Delete(long tenantId, long announcementId, CancellationToken cancellationToken) { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + var result = await mediator.Send(new DeleteTenantAnnouncementCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); return ApiResponse.Ok(result); } @@ -299,9 +343,30 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] public async Task> MarkRead(long tenantId, long announcementId, CancellationToken cancellationToken) { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + var result = await mediator.Send(new MarkAnnouncementAsReadCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") : ApiResponse.Ok(result); } + + private ApiResponse? EnsureTenantHeader() + { + if (!Request.Headers.TryGetValue(TenantIdHeaderName, out var tenantHeader) || string.IsNullOrWhiteSpace(tenantHeader)) + { + return ApiResponse.Error(StatusCodes.Status400BadRequest, $"缺少租户标识,请在请求头 {TenantIdHeaderName} 指定租户"); + } + + if (!long.TryParse(tenantHeader.FirstOrDefault(), out _)) + { + return ApiResponse.Error(StatusCodes.Status400BadRequest, $"租户标识无效,请在请求头 {TenantIdHeaderName} 指定正确的租户 ID"); + } + + return null; + } } diff --git a/src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs b/src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs index 177ebd9..3ad2db0 100644 --- a/src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs +++ b/src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs @@ -1,5 +1,6 @@ using FluentValidation; using MediatR; +using TakeoutSaaS.Shared.Abstractions.Exceptions; namespace TakeoutSaaS.Application.App.Common.Behaviors; @@ -26,7 +27,16 @@ public sealed class ValidationBehavior(IEnumerable 0) { - throw new ValidationException(failures); + var errors = failures + .GroupBy(f => string.IsNullOrWhiteSpace(f.PropertyName) ? "Request" : f.PropertyName) + .ToDictionary( + group => group.Key, + group => group + .Select(f => string.IsNullOrWhiteSpace(f.ErrorMessage) ? "Invalid" : f.ErrorMessage) + .Distinct() + .ToArray()); + + throw new TakeoutSaaS.Shared.Abstractions.Exceptions.ValidationException(errors); } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantsAnnouncementsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantsAnnouncementsQueryHandler.cs index 0c8d18b..22b85d6 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantsAnnouncementsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantsAnnouncementsQueryHandler.cs @@ -44,6 +44,7 @@ public sealed class GetTenantsAnnouncementsQueryHandler( // 1. 优化的数据库查询:应用排序和限制 var announcements = await announcementRepository.SearchAsync( tenantId, + request.Keyword, request.Status, request.AnnouncementType, request.IsActive, diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/PublishAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/PublishAnnouncementCommandHandler.cs index 8637d7a..abc308a 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/PublishAnnouncementCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/PublishAnnouncementCommandHandler.cs @@ -23,6 +23,11 @@ public sealed class PublishAnnouncementCommandHandler( /// public async Task Handle(PublishAnnouncementCommand request, CancellationToken cancellationToken) { + if (request.RowVersion == null || request.RowVersion.Length == 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空"); + } + // 1. 查询公告 var tenantId = tenantProvider.GetCurrentTenantId(); var announcement = await announcementRepository.FindByIdAsync(tenantId, request.AnnouncementId, cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RevokeAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RevokeAnnouncementCommandHandler.cs index 0481942..648a755 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RevokeAnnouncementCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RevokeAnnouncementCommandHandler.cs @@ -23,6 +23,11 @@ public sealed class RevokeAnnouncementCommandHandler( /// public async Task Handle(RevokeAnnouncementCommand request, CancellationToken cancellationToken) { + if (request.RowVersion == null || request.RowVersion.Length == 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空"); + } + // 1. 查询公告 var tenantId = tenantProvider.GetCurrentTenantId(); var announcement = await announcementRepository.FindByIdAsync(tenantId, request.AnnouncementId, cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs index 0694ca6..4b0a989 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs @@ -22,6 +22,11 @@ public sealed class UpdateTenantAnnouncementCommandHandler(ITenantAnnouncementRe throw new BusinessException(ErrorCodes.ValidationFailed, "公告标题和内容不能为空"); } + if (request.RowVersion == null || request.RowVersion.Length == 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空"); + } + // 2. 查询公告 var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); if (announcement == null) diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantsAnnouncementsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantsAnnouncementsQuery.cs index 0155524..87f5be5 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantsAnnouncementsQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantsAnnouncementsQuery.cs @@ -25,6 +25,11 @@ public sealed record GetTenantsAnnouncementsQuery : IRequest public AnnouncementStatus? Status { get; init; } + /// + /// 关键词搜索(标题/内容)。 + /// + public string? Keyword { get; init; } + /// /// 是否筛选启用状态。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Targeting/TargetTypeFilter.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Targeting/TargetTypeFilter.cs index e67ff20..5642549 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Targeting/TargetTypeFilter.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Targeting/TargetTypeFilter.cs @@ -37,6 +37,10 @@ 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), diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/CreateAnnouncementCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/CreateAnnouncementCommandValidator.cs index b5bff12..69e04c4 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/CreateAnnouncementCommandValidator.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Validators/CreateAnnouncementCommandValidator.cs @@ -28,8 +28,9 @@ public sealed class CreateAnnouncementCommandValidator : AbstractValidator x.TenantId != 0 || x.PublisherScope == PublisherScope.Platform) .WithMessage("TenantId=0 仅允许平台公告"); - RuleFor(x => x.EffectiveTo) - .Must((command, effectiveTo) => !effectiveTo.HasValue || command.EffectiveFrom < effectiveTo.Value) + RuleFor(x => x.EffectiveFrom) + .LessThan(x => x.EffectiveTo!.Value) + .When(x => x.EffectiveTo.HasValue) .WithMessage("生效开始时间必须早于结束时间"); } } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs index ce8554e..1b926af 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs @@ -12,6 +12,7 @@ public interface ITenantAnnouncementRepository /// 查询公告列表(包含平台公告 TenantId=0),按类型、状态与生效时间筛选。 /// /// 租户 ID。 + /// 关键词(标题/内容)。 /// 公告状态。 /// 公告类型。 /// 启用状态。 @@ -24,6 +25,7 @@ public interface ITenantAnnouncementRepository /// 公告集合。 Task> SearchAsync( long tenantId, + string? keyword, AnnouncementStatus? status, TenantAnnouncementType? type, bool? isActive, diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index ec4a71f..39b8931 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -807,10 +807,13 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.TenantId).IsRequired(); builder.Property(x => x.Title).HasMaxLength(128).IsRequired(); builder.Property(x => x.Content).HasColumnType("text").IsRequired(); - builder.Property(x => x.AnnouncementType).HasConversion(); - builder.Property(x => x.PublisherScope).HasConversion(); + builder.Property(x => x.AnnouncementType) + .HasConversion(); + builder.Property(x => x.PublisherScope) + .HasConversion(); builder.Property(x => x.PublisherUserId); - builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.Status) + .HasConversion(); builder.Property(x => x.PublishedAt); builder.Property(x => x.RevokedAt); builder.Property(x => x.ScheduledPublishAt); @@ -819,7 +822,9 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.Priority).IsRequired(); builder.Property(x => x.IsActive).IsRequired(); builder.Property(x => x.RowVersion) - .IsConcurrencyToken(); + .IsRowVersion() + .IsConcurrencyToken() + .HasColumnType("bytea"); ConfigureAuditableEntity(builder); ConfigureSoftDeleteEntity(builder); builder.HasIndex(x => new { x.TenantId, x.AnnouncementType, x.IsActive }); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs index e057b0c..5d64847 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs @@ -14,6 +14,7 @@ public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) /// public async Task> SearchAsync( long tenantId, + string? keyword, AnnouncementStatus? status, TenantAnnouncementType? type, bool? isActive, @@ -29,6 +30,14 @@ public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) .IgnoreQueryFilters() .Where(x => tenantIds.Contains(x.TenantId)); + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => + EF.Functions.ILike(x.Title, $"%{normalized}%") + || EF.Functions.ILike(x.Content, $"%{normalized}%")); + } + if (status.HasValue) { query = query.Where(x => x.Status == status.Value); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251225090000_AddTenantAnnouncementRowVersionTrigger.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251225090000_AddTenantAnnouncementRowVersionTrigger.cs new file mode 100644 index 0000000..444d637 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251225090000_AddTenantAnnouncementRowVersionTrigger.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + /// + public partial class AddTenantAnnouncementRowVersionTrigger : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE OR REPLACE FUNCTION public.set_tenant_announcement_row_version() + RETURNS trigger AS $$ + BEGIN + NEW."RowVersion" = decode(md5(random()::text || clock_timestamp()::text), 'hex'); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """); + + migrationBuilder.Sql( + """ + DROP TRIGGER IF EXISTS trg_tenant_announcements_row_version ON tenant_announcements; + CREATE TRIGGER trg_tenant_announcements_row_version + BEFORE INSERT OR UPDATE ON tenant_announcements + FOR EACH ROW EXECUTE FUNCTION public.set_tenant_announcement_row_version(); + """); + + migrationBuilder.Sql( + """ + UPDATE tenant_announcements + SET "RowVersion" = decode(md5(random()::text || clock_timestamp()::text), 'hex') + WHERE "RowVersion" IS NULL OR octet_length("RowVersion") = 0; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DROP TRIGGER IF EXISTS trg_tenant_announcements_row_version ON tenant_announcements; + DROP FUNCTION IF EXISTS public.set_tenant_announcement_row_version(); + """); + } + } +} diff --git a/tests/TakeoutSaaS.Integration.Tests/App/Tenants/AnnouncementWorkflowTests.cs b/tests/TakeoutSaaS.Integration.Tests/App/Tenants/AnnouncementWorkflowTests.cs index ce2cbe2..4561e5f 100644 --- a/tests/TakeoutSaaS.Integration.Tests/App/Tenants/AnnouncementWorkflowTests.cs +++ b/tests/TakeoutSaaS.Integration.Tests/App/Tenants/AnnouncementWorkflowTests.cs @@ -164,7 +164,7 @@ public sealed class AnnouncementWorkflowTests } [Fact] - public async Task GivenStaleRowVersion_WhenUpdate_ThenThrowsConcurrencyException() + public async Task GivenStaleRowVersion_WhenUpdate_ThenReturnsConflict() { // Arrange using var database = new SqliteTestDatabase(); @@ -197,7 +197,8 @@ public sealed class AnnouncementWorkflowTests Func act = async () => await handler.Handle(command, CancellationToken.None); // Assert - await act.Should().ThrowAsync(); + var exception = await act.Should().ThrowAsync(); + exception.Which.ErrorCode.Should().Be(ErrorCodes.Conflict); } private static TenantAnnouncement CreateDraftAnnouncement(long tenantId, long id)