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
/// 租户 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)