feat: 实现完整的多租户公告管理系统

核心功能:
- 公告状态机(草稿/已发布/已撤销)支持发布、撤销和重新发布
- 发布者范围区分平台级和租户级公告
- 目标受众定向推送(全部租户/指定角色/指定用户)
- 平台管理、租户管理和应用端查询API
- 已读/未读管理和未读统计

技术实现:
- CQRS+DDD架构,清晰的领域边界和事件驱动
- 查询性能优化:数据库端排序和限制,估算策略减少内存占用
- 并发控制:修复RowVersion配置(IsRowVersion→IsConcurrencyToken)
- 完整的FluentValidation验证器和输入保护

测试验证:
- 36个测试全部通过(27单元+9集成)
- 性能测试达标(1000条数据<5秒)
- 代码质量评级A(优秀)

文档:
- 完整的ADR、API文档和迁移指南
- 交付报告和技术债务记录
This commit is contained in:
2025-12-20 19:50:17 +08:00
parent 00eb357e6e
commit 857f776447
76 changed files with 12957 additions and 281 deletions

View File

@@ -0,0 +1,120 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 应用端公告(面向已认证用户)。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/app/announcements")]
public sealed class AppAnnouncementsController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 获取当前用户可见的公告列表(已发布/有效期内)。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/app/announcements?page=1&amp;pageSize=20
/// Header: Authorization: Bearer &lt;JWT&gt;
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "items": [],
/// "page": 1,
/// "pageSize": 20,
/// "totalCount": 0
/// }
/// }
/// </code>
/// </remarks>
[HttpGet]
[SwaggerOperation(Summary = "获取可见公告列表", Description = "仅返回已发布且在有效期内的公告(含平台公告)。")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAnnouncementDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> List([FromQuery] GetTenantsAnnouncementsQuery query, CancellationToken cancellationToken)
{
var request = query with
{
Status = AnnouncementStatus.Published,
IsActive = true,
OnlyEffective = true
};
var result = await mediator.Send(request, cancellationToken);
return ApiResponse<PagedResult<TenantAnnouncementDto>>.Ok(result);
}
/// <summary>
/// 获取当前用户未读公告。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/app/announcements/unread?page=1&amp;pageSize=20
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "items": [],
/// "page": 1,
/// "pageSize": 20,
/// "totalCount": 0
/// }
/// }
/// </code>
/// </remarks>
[HttpGet("unread")]
[SwaggerOperation(Summary = "获取未读公告", Description = "仅返回未读且在有效期内的已发布公告。")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAnnouncementDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> GetUnread([FromQuery] GetUnreadAnnouncementsQuery query, CancellationToken cancellationToken)
{
var result = await mediator.Send(query, cancellationToken);
return ApiResponse<PagedResult<TenantAnnouncementDto>>.Ok(result);
}
/// <summary>
/// 标记公告已读。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// POST /api/app/announcements/900123456789012345/mark-read
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "isRead": true
/// }
/// }
/// </code>
/// </remarks>
[HttpPost("{announcementId:long}/mark-read")]
[SwaggerOperation(Summary = "标记公告已读", Description = "仅已发布且可见的公告允许标记已读。")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<TenantAnnouncementDto>> MarkRead(long announcementId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new MarkAnnouncementAsReadCommand { AnnouncementId = announcementId }, cancellationToken);
return result is null
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
: ApiResponse<TenantAnnouncementDto>.Ok(result);
}
}

View File

@@ -0,0 +1,277 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 平台公告管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/platform/announcements")]
public sealed class PlatformAnnouncementsController(IMediator mediator, ITenantContextAccessor tenantContextAccessor) : BaseApiController
{
/// <summary>
/// 创建平台公告。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// POST /api/platform/announcements
/// Header: Authorization: Bearer &lt;JWT&gt;
/// Body:
/// {
/// "title": "平台升级通知",
/// "content": "系统将于今晚 23:00 维护。",
/// "announcementType": 0,
/// "priority": 10,
/// "effectiveFrom": "2025-12-20T00:00:00Z",
/// "effectiveTo": null,
/// "targetType": "all",
/// "targetParameters": null
/// }
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "tenantId": "0",
/// "title": "平台升级通知",
/// "status": "Draft"
/// }
/// }
/// </code>
/// </remarks>
[HttpPost]
[PermissionAuthorize("platform-announcement:create")]
[SwaggerOperation(Summary = "创建平台公告", Description = "需要权限platform-announcement:create")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> Create([FromBody, Required] CreateTenantAnnouncementCommand command, CancellationToken cancellationToken)
{
command = command with
{
TenantId = 0,
PublisherScope = PublisherScope.Platform
};
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<TenantAnnouncementDto>.Ok(result);
}
/// <summary>
/// 查询平台公告列表。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/platform/announcements?page=1&amp;pageSize=20&amp;status=Published
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "items": [],
/// "page": 1,
/// "pageSize": 20,
/// "totalCount": 0
/// }
/// }
/// </code>
/// </remarks>
[HttpGet]
[PermissionAuthorize("platform-announcement:create")]
[SwaggerOperation(Summary = "查询平台公告列表", Description = "需要权限platform-announcement:create")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAnnouncementDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> List([FromQuery] GetTenantsAnnouncementsQuery query, CancellationToken cancellationToken)
{
var request = query with { TenantId = 0 };
var result = await ExecuteAsPlatformAsync(() => mediator.Send(request, cancellationToken));
return ApiResponse<PagedResult<TenantAnnouncementDto>>.Ok(result);
}
/// <summary>
/// 获取平台公告详情。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/platform/announcements/900123456789012345
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "tenantId": "0",
/// "title": "平台升级通知",
/// "status": "Draft"
/// }
/// }
/// </code>
/// </remarks>
[HttpGet("{announcementId:long}")]
[PermissionAuthorize("platform-announcement:create")]
[SwaggerOperation(Summary = "获取平台公告详情", Description = "需要权限platform-announcement:create")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> Detail(long announcementId, CancellationToken cancellationToken)
{
var result = await ExecuteAsPlatformAsync(() =>
mediator.Send(new GetAnnouncementByIdQuery { TenantId = 0, AnnouncementId = announcementId }, cancellationToken));
return result is null
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
: ApiResponse<TenantAnnouncementDto>.Ok(result);
}
/// <summary>
/// 更新平台公告(仅草稿)。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// PUT /api/platform/announcements/900123456789012345
/// Body:
/// {
/// "title": "平台升级通知(更新)",
/// "content": "维护时间调整为 23:30。",
/// "targetType": "all",
/// "targetParameters": null,
/// "rowVersion": "AAAAAAAAB9E="
/// }
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "status": "Draft"
/// }
/// }
/// </code>
/// </remarks>
[HttpPut("{announcementId:long}")]
[PermissionAuthorize("platform-announcement:create")]
[SwaggerOperation(Summary = "更新平台公告", Description = "仅草稿可更新需要权限platform-announcement:create")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> Update(long announcementId, [FromBody, Required] UpdateTenantAnnouncementCommand command, CancellationToken cancellationToken)
{
command = command with { TenantId = 0, AnnouncementId = announcementId };
var result = await mediator.Send(command, cancellationToken);
return result is null
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
: ApiResponse<TenantAnnouncementDto>.Ok(result);
}
/// <summary>
/// 发布平台公告。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// POST /api/platform/announcements/900123456789012345/publish
/// Body:
/// {
/// "rowVersion": "AAAAAAAAB9E="
/// }
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "status": "Published"
/// }
/// }
/// </code>
/// </remarks>
[HttpPost("{announcementId:long}/publish")]
[PermissionAuthorize("platform-announcement:publish")]
[SwaggerOperation(Summary = "发布平台公告", Description = "需要权限platform-announcement:publish")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> Publish(long announcementId, [FromBody, Required] PublishAnnouncementCommand command, CancellationToken cancellationToken)
{
command = command with { AnnouncementId = announcementId };
var result = await ExecuteAsPlatformAsync(() => mediator.Send(command, cancellationToken));
return result is null
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
: ApiResponse<TenantAnnouncementDto>.Ok(result);
}
/// <summary>
/// 撤销平台公告。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// POST /api/platform/announcements/900123456789012345/revoke
/// Body:
/// {
/// "rowVersion": "AAAAAAAAB9E="
/// }
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "status": "Revoked"
/// }
/// }
/// </code>
/// </remarks>
[HttpPost("{announcementId:long}/revoke")]
[PermissionAuthorize("platform-announcement:revoke")]
[SwaggerOperation(Summary = "撤销平台公告", Description = "需要权限platform-announcement:revoke")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> Revoke(long announcementId, [FromBody, Required] RevokeAnnouncementCommand command, CancellationToken cancellationToken)
{
command = command with { AnnouncementId = announcementId };
var result = await ExecuteAsPlatformAsync(() => mediator.Send(command, cancellationToken));
return result is null
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
: ApiResponse<TenantAnnouncementDto>.Ok(result);
}
private async Task<T> ExecuteAsPlatformAsync<T>(Func<Task<T>> action)
{
var original = tenantContextAccessor.Current;
tenantContextAccessor.Current = new TenantContext(0, null, "platform");
try
{
return await action();
}
finally
{
tenantContextAccessor.Current = original;
}
}
}

View File

@@ -1,6 +1,7 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
@@ -22,36 +23,64 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC
/// <summary>
/// 分页查询公告。
/// </summary>
/// <returns>租户公告分页结果。</returns>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/admin/v1/tenants/100000000000000001/announcements?page=1&amp;pageSize=20
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "items": [],
/// "page": 1,
/// "pageSize": 20,
/// "totalCount": 0
/// }
/// }
/// </code>
/// </remarks>
[HttpGet]
[PermissionAuthorize("tenant-announcement:read")]
[SwaggerOperation(Summary = "查询租户公告列表", Description = "需要权限tenant-announcement:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAnnouncementDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> Search(long tenantId, [FromQuery] SearchTenantAnnouncementsQuery query, CancellationToken cancellationToken)
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> Search(long tenantId, [FromQuery] GetTenantsAnnouncementsQuery query, CancellationToken cancellationToken)
{
// 1. 绑定租户标识
query = query with { TenantId = tenantId };
// 2. 查询公告列表
var result = await mediator.Send(query, cancellationToken);
// 3. 返回分页结果
return ApiResponse<PagedResult<TenantAnnouncementDto>>.Ok(result);
}
/// <summary>
/// 公告详情。
/// </summary>
/// <returns>租户公告详情。</returns>
/// <remarks>
/// 示例:
/// <code>
/// GET /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "tenantId": "100000000000000001",
/// "title": "租户公告",
/// "status": "Draft"
/// }
/// }
/// </code>
/// </remarks>
[HttpGet("{announcementId:long}")]
[PermissionAuthorize("tenant-announcement:read")]
[SwaggerOperation(Summary = "获取公告详情", Description = "需要权限tenant-announcement:read")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> Detail(long tenantId, long announcementId, CancellationToken cancellationToken)
{
// 1. 查询指定公告
var result = await mediator.Send(new GetTenantAnnouncementQuery { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken);
// 2. 返回详情或 404
var result = await mediator.Send(new GetAnnouncementByIdQuery { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken);
return result is null
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
: ApiResponse<TenantAnnouncementDto>.Ok(result);
@@ -60,37 +89,159 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC
/// <summary>
/// 创建公告。
/// </summary>
/// <returns>创建的公告信息。</returns>
/// <remarks>
/// 示例:
/// <code>
/// POST /api/admin/v1/tenants/100000000000000001/announcements
/// Body:
/// {
/// "title": "租户公告",
/// "content": "新品上线提醒",
/// "announcementType": 0,
/// "priority": 5,
/// "effectiveFrom": "2025-12-20T00:00:00Z",
/// "targetType": "roles",
/// "targetParameters": "{\"roles\":[\"OpsManager\"]}"
/// }
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "tenantId": "100000000000000001",
/// "title": "租户公告",
/// "status": "Draft"
/// }
/// }
/// </code>
/// </remarks>
[HttpPost]
[PermissionAuthorize("tenant-announcement:create")]
[SwaggerOperation(Summary = "创建租户公告", Description = "需要权限tenant-announcement:create")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> Create(long tenantId, [FromBody, Required] CreateTenantAnnouncementCommand command, CancellationToken cancellationToken)
{
// 1. 绑定租户标识
command = command with { TenantId = tenantId };
// 2. 创建公告并返回
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<TenantAnnouncementDto>.Ok(result);
}
/// <summary>
/// 更新公告。
/// 更新公告(仅草稿)
/// </summary>
/// <returns>更新后的公告信息。</returns>
/// <remarks>
/// 示例:
/// <code>
/// PUT /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345
/// Body:
/// {
/// "title": "租户公告(更新)",
/// "content": "公告内容更新",
/// "targetType": "all",
/// "targetParameters": null,
/// "rowVersion": "AAAAAAAAB9E="
/// }
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "status": "Draft"
/// }
/// }
/// </code>
/// </remarks>
[HttpPut("{announcementId:long}")]
[PermissionAuthorize("tenant-announcement:update")]
[SwaggerOperation(Summary = "更新租户公告", Description = "仅草稿可更新需要权限tenant-announcement:update")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> Update(long tenantId, long announcementId, [FromBody, Required] UpdateTenantAnnouncementCommand command, CancellationToken cancellationToken)
{
// 1. 绑定租户与公告标识
command = command with { TenantId = tenantId, AnnouncementId = announcementId };
// 2. 执行更新
var result = await mediator.Send(command, cancellationToken);
return result is null
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
: ApiResponse<TenantAnnouncementDto>.Ok(result);
}
// 3. 返回更新结果或 404
/// <summary>
/// 发布公告。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// POST /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345/publish
/// Body:
/// {
/// "rowVersion": "AAAAAAAAB9E="
/// }
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "status": "Published"
/// }
/// }
/// </code>
/// </remarks>
[HttpPost("{announcementId:long}/publish")]
[PermissionAuthorize("tenant-announcement:publish")]
[SwaggerOperation(Summary = "发布租户公告", Description = "需要权限tenant-announcement:publish")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> Publish(long tenantId, long announcementId, [FromBody, Required] PublishAnnouncementCommand command, CancellationToken cancellationToken)
{
command = command with { AnnouncementId = announcementId };
var result = await mediator.Send(command, cancellationToken);
return result is null
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
: ApiResponse<TenantAnnouncementDto>.Ok(result);
}
/// <summary>
/// 撤销公告。
/// </summary>
/// <remarks>
/// 示例:
/// <code>
/// POST /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345/revoke
/// Body:
/// {
/// "rowVersion": "AAAAAAAAB9E="
/// }
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "status": "Revoked"
/// }
/// }
/// </code>
/// </remarks>
[HttpPost("{announcementId:long}/revoke")]
[PermissionAuthorize("tenant-announcement:revoke")]
[SwaggerOperation(Summary = "撤销租户公告", Description = "需要权限tenant-announcement:revoke")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> Revoke(long tenantId, long announcementId, [FromBody, Required] RevokeAnnouncementCommand command, CancellationToken cancellationToken)
{
command = command with { AnnouncementId = announcementId };
var result = await mediator.Send(command, cancellationToken);
return result is null
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
: ApiResponse<TenantAnnouncementDto>.Ok(result);
@@ -99,33 +250,56 @@ public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiC
/// <summary>
/// 删除公告。
/// </summary>
/// <returns>删除结果。</returns>
/// <remarks>
/// 示例:
/// <code>
/// DELETE /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": true
/// }
/// </code>
/// </remarks>
[HttpDelete("{announcementId:long}")]
[PermissionAuthorize("tenant-announcement:delete")]
[SwaggerOperation(Summary = "删除租户公告", Description = "需要权限tenant-announcement:delete")]
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<bool>> Delete(long tenantId, long announcementId, CancellationToken cancellationToken)
{
// 1. 删除公告
var result = await mediator.Send(new DeleteTenantAnnouncementCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken);
// 2. 返回执行结果
return ApiResponse<bool>.Ok(result);
}
/// <summary>
/// 标记公告已读。
/// 标记公告已读(兼容旧路径)
/// </summary>
/// <returns>标记已读后的公告信息。</returns>
/// <remarks>
/// 示例:
/// <code>
/// POST /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345/read
/// 响应:
/// {
/// "success": true,
/// "code": 200,
/// "data": {
/// "id": "900123456789012345",
/// "isRead": true
/// }
/// }
/// </code>
/// </remarks>
[HttpPost("{announcementId:long}/read")]
[PermissionAuthorize("tenant-announcement:read")]
[SwaggerOperation(Summary = "标记公告已读", Description = "需要权限tenant-announcement:read")]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<TenantAnnouncementDto>> MarkRead(long tenantId, long announcementId, CancellationToken cancellationToken)
{
// 1. 标记公告已读
var result = await mediator.Send(new MarkTenantAnnouncementReadCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken);
// 2. 返回结果或 404
var result = await mediator.Send(new MarkAnnouncementAsReadCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken);
return result is null
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
: ApiResponse<TenantAnnouncementDto>.Ok(result);

View File

@@ -68,6 +68,11 @@
"tenant-announcement:create",
"tenant-announcement:update",
"tenant-announcement:delete",
"tenant-announcement:publish",
"tenant-announcement:revoke",
"platform-announcement:create",
"platform-announcement:publish",
"platform-announcement:revoke",
"tenant-notification:read",
"tenant-notification:update",
"tenant:create",
@@ -173,6 +178,8 @@
"tenant-announcement:create",
"tenant-announcement:update",
"tenant-announcement:delete",
"tenant-announcement:publish",
"tenant-announcement:revoke",
"tenant-notification:read",
"tenant-notification:update",
"tenant:read",
@@ -359,6 +366,11 @@
"tenant-announcement:create",
"tenant-announcement:update",
"tenant-announcement:delete",
"tenant-announcement:publish",
"tenant-announcement:revoke",
"platform-announcement:create",
"platform-announcement:publish",
"platform-announcement:revoke",
"tenant-notification:read",
"tenant-notification:update",
"tenant:create",

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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>();
}

View File

@@ -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>();
}

View File

@@ -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>();
}

View File

@@ -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; }

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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; }

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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
};
}
}

View File

@@ -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>();
}

View File

@@ -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

View File

@@ -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("生效开始时间必须早于结束时间");
}
}

View File

@@ -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 不能为空");
}
}

View File

@@ -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 不能为空");
}
}

View File

@@ -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 不能为空");
}
}

View File

@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.Annotations;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace TakeoutSaaS.Shared.Web.Swagger;
@@ -28,6 +29,7 @@ public static class SwaggerExtensions
{
options.IncludeXmlComments(xml, true);
}
options.EnableAnnotations();
});
services.AddSingleton(_ =>
{

View File

@@ -13,6 +13,7 @@
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.1" />
</ItemGroup>

View File

@@ -39,7 +39,53 @@ public sealed class TenantAnnouncement : MultiTenantEntityBase
public DateTime? EffectiveTo { get; set; }
/// <summary>
/// 是否启用
/// 发布者范围
/// </summary>
public PublisherScope PublisherScope { get; set; }
/// <summary>
/// 发布者用户 ID平台或租户后台账号
/// </summary>
public long? PublisherUserId { get; set; }
/// <summary>
/// 公告状态。
/// </summary>
public AnnouncementStatus Status { get; set; } = AnnouncementStatus.Draft;
/// <summary>
/// 实际发布时间UTC
/// </summary>
public DateTime? PublishedAt { get; set; }
/// <summary>
/// 撤销时间UTC
/// </summary>
public DateTime? RevokedAt { get; set; }
/// <summary>
/// 预定发布时间UTC
/// </summary>
public DateTime? ScheduledPublishAt { get; set; }
/// <summary>
/// 目标受众类型。
/// </summary>
public string TargetType { get; set; } = string.Empty;
/// <summary>
/// 目标受众参数JSON
/// </summary>
public string? TargetParameters { get; set; }
/// <summary>
/// 并发控制字段。
/// </summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
/// <summary>
/// 是否启用(已弃用,迁移期保留)。
/// </summary>
[Obsolete("Use Status instead.")]
public bool IsActive { get; set; } = true;
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 公告状态。
/// </summary>
public enum AnnouncementStatus
{
/// <summary>
/// 草稿。
/// </summary>
Draft = 0,
/// <summary>
/// 已发布。
/// </summary>
Published = 1,
/// <summary>
/// 已撤销。
/// </summary>
Revoked = 2
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 发布者范围。
/// </summary>
public enum PublisherScope
{
/// <summary>
/// 平台发布。
/// </summary>
Platform = 0,
/// <summary>
/// 租户发布。
/// </summary>
Tenant = 1
}

View File

@@ -18,5 +18,35 @@ public enum TenantAnnouncementType
/// <summary>
/// 运营通知。
/// </summary>
Operation = 2
Operation = 2,
/// <summary>
/// 平台系统更新公告。
/// </summary>
SYSTEM_PLATFORM_UPDATE = 3,
/// <summary>
/// 系统安全公告。
/// </summary>
SYSTEM_SECURITY_NOTICE = 4,
/// <summary>
/// 系统合规公告。
/// </summary>
SYSTEM_COMPLIANCE = 5,
/// <summary>
/// 租户内部公告。
/// </summary>
TENANT_INTERNAL = 6,
/// <summary>
/// 租户财务公告。
/// </summary>
TENANT_FINANCE = 7,
/// <summary>
/// 租户运营公告。
/// </summary>
TENANT_OPERATION = 8
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Tenants.Events;
/// <summary>
/// 公告发布事件。
/// </summary>
public sealed class AnnouncementPublished
{
/// <summary>
/// 公告 ID。
/// </summary>
public long AnnouncementId { get; init; }
/// <summary>
/// 发布时间UTC
/// </summary>
public DateTime PublishedAt { get; init; }
/// <summary>
/// 目标受众类型。
/// </summary>
public string TargetType { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Tenants.Events;
/// <summary>
/// 公告撤销事件。
/// </summary>
public sealed class AnnouncementRevoked
{
/// <summary>
/// 公告 ID。
/// </summary>
public long AnnouncementId { get; init; }
/// <summary>
/// 撤销时间UTC
/// </summary>
public DateTime RevokedAt { get; init; }
}

View File

@@ -9,18 +9,55 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories;
public interface ITenantAnnouncementRepository
{
/// <summary>
/// 查询公告列表,按类型、启用状态与生效时间筛选。
/// 查询公告列表(包含平台公告 TenantId=0,按类型、状态与生效时间筛选。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="status">公告状态。</param>
/// <param name="type">公告类型。</param>
/// <param name="isActive">启用状态。</param>
/// <param name="effectiveFrom">生效开始时间筛选。</param>
/// <param name="effectiveTo">生效结束时间筛选。</param>
/// <param name="effectiveAt">生效时间点,为空不限制。</param>
/// <param name="orderByPriority">是否按优先级降序和生效时间降序排序,默认 false。</param>
/// <param name="limit">限制返回数量,为空不限制。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>公告集合。</returns>
Task<IReadOnlyList<TenantAnnouncement>> SearchAsync(
long tenantId,
AnnouncementStatus? status,
TenantAnnouncementType? type,
bool? isActive,
DateTime? effectiveFrom,
DateTime? effectiveTo,
DateTime? effectiveAt,
bool orderByPriority = false,
int? limit = null,
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>
/// <param name="status">公告状态。</param>
/// <param name="isActive">启用状态。</param>
/// <param name="effectiveAt">生效时间点,为空不限制。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>未读公告集合。</returns>
Task<IReadOnlyList<TenantAnnouncement>> SearchUnreadAsync(
long tenantId,
long? userId,
AnnouncementStatus? status,
bool? isActive,
DateTime? effectiveAt,
CancellationToken cancellationToken = default);

View File

@@ -39,6 +39,7 @@ public sealed class AppDataSeeder(
var appDbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
var dictionaryDbContext = scope.ServiceProvider.GetRequiredService<DictionaryDbContext>();
await EnsurePlatformTenantAsync(appDbContext, cancellationToken);
var defaultTenantId = await EnsureDefaultTenantAsync(appDbContext, cancellationToken);
await EnsureDictionarySeedsAsync(dictionaryDbContext, defaultTenantId, cancellationToken);
@@ -130,6 +131,33 @@ public sealed class AppDataSeeder(
return existingTenant.Id;
}
/// <summary>
/// 确保平台租户存在。
/// </summary>
private async Task EnsurePlatformTenantAsync(TakeoutAppDbContext dbContext, CancellationToken cancellationToken)
{
var existingTenant = await dbContext.Tenants
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == 0, cancellationToken);
if (existingTenant != null)
{
return;
}
var tenant = new Tenant
{
Id = 0,
Code = "PLATFORM",
Name = "Platform",
Status = TenantStatus.Active
};
await dbContext.Tenants.AddAsync(tenant, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("AppSeed 已创建平台租户 PLATFORM");
}
/// <summary>
/// 确保基础字典存在。
/// </summary>

View File

@@ -808,12 +808,25 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
builder.Property(x => x.Content).HasColumnType("text").IsRequired();
builder.Property(x => x.AnnouncementType).HasConversion<int>();
builder.Property(x => x.PublisherScope).HasConversion<int>();
builder.Property(x => x.PublisherUserId);
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.PublishedAt);
builder.Property(x => x.RevokedAt);
builder.Property(x => x.ScheduledPublishAt);
builder.Property(x => x.TargetType).HasMaxLength(64).IsRequired();
builder.Property(x => x.TargetParameters).HasColumnType("text");
builder.Property(x => x.Priority).IsRequired();
builder.Property(x => x.IsActive).IsRequired();
builder.Property(x => x.RowVersion)
.IsConcurrencyToken();
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => new { x.TenantId, x.AnnouncementType, x.IsActive });
builder.HasIndex(x => new { x.TenantId, x.EffectiveFrom, x.EffectiveTo });
builder.HasIndex(x => new { x.TenantId, x.Status, x.EffectiveFrom });
builder.HasIndex(x => new { x.Status, x.EffectiveFrom })
.HasFilter("\"TenantId\" = 0");
}
private static void ConfigureTenantAnnouncementRead(EntityTypeBuilder<TenantAnnouncementRead> builder)
@@ -957,7 +970,8 @@ public sealed class TakeoutAppDbContext(
builder.HasKey(x => x.Id);
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.DefaultCutoffMinutes).HasDefaultValue(30);
builder.Property(x => x.RowVersion).IsRowVersion();
builder.Property(x => x.RowVersion)
.IsConcurrencyToken();
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
}
@@ -969,7 +983,8 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.Weekdays).HasMaxLength(32).IsRequired();
builder.Property(x => x.CutoffMinutes).HasDefaultValue(30);
builder.Property(x => x.RowVersion).IsRowVersion();
builder.Property(x => x.RowVersion)
.IsConcurrencyToken();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name });
}
@@ -1056,7 +1071,8 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.ProductSkuId).IsRequired();
builder.Property(x => x.BatchNumber).HasMaxLength(64);
builder.Property(x => x.Location).HasMaxLength(64);
builder.Property(x => x.RowVersion).IsRowVersion();
builder.Property(x => x.RowVersion)
.IsConcurrencyToken();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber });
}
@@ -1077,7 +1093,8 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.StoreId).IsRequired();
builder.Property(x => x.ProductSkuId).IsRequired();
builder.Property(x => x.BatchNumber).HasMaxLength(64).IsRequired();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.Property(x => x.RowVersion)
.IsConcurrencyToken();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }).IsUnique();
}
@@ -1090,7 +1107,8 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.Quantity).IsRequired();
builder.Property(x => x.IdempotencyKey).HasMaxLength(128).IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.RowVersion).IsRowVersion();
builder.Property(x => x.RowVersion)
.IsConcurrencyToken();
builder.HasIndex(x => new { x.TenantId, x.IdempotencyKey }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.Status });
}

View File

@@ -12,15 +12,27 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories;
public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) : ITenantAnnouncementRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<TenantAnnouncement>> SearchAsync(
public async Task<IReadOnlyList<TenantAnnouncement>> SearchAsync(
long tenantId,
AnnouncementStatus? status,
TenantAnnouncementType? type,
bool? isActive,
DateTime? effectiveFrom,
DateTime? effectiveTo,
DateTime? effectiveAt,
bool orderByPriority = false,
int? limit = null,
CancellationToken cancellationToken = default)
{
var tenantIds = new[] { tenantId, 0L };
var query = context.TenantAnnouncements.AsNoTracking()
.Where(x => x.TenantId == tenantId);
.IgnoreQueryFilters()
.Where(x => tenantIds.Contains(x.TenantId));
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (type.HasValue)
{
@@ -32,17 +44,90 @@ public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context)
query = query.Where(x => x.IsActive == isActive.Value);
}
if (effectiveFrom.HasValue)
{
query = query.Where(x => x.EffectiveFrom >= effectiveFrom.Value);
}
if (effectiveTo.HasValue)
{
query = query.Where(x => x.EffectiveTo == null || x.EffectiveTo <= effectiveTo.Value);
}
if (effectiveAt.HasValue)
{
var at = effectiveAt.Value;
query = query.Where(x => x.EffectiveFrom <= at && (x.EffectiveTo == null || x.EffectiveTo >= at));
}
return query
.OrderByDescending(x => x.Priority)
.ThenByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<TenantAnnouncement>)t.Result, cancellationToken);
// 应用排序(如果启用)
if (orderByPriority)
{
query = query.OrderByDescending(x => x.Priority).ThenByDescending(x => x.EffectiveFrom);
}
// 应用限制(如果指定)
if (limit.HasValue && limit.Value > 0)
{
query = query.Take(limit.Value);
}
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,
long? userId,
AnnouncementStatus? status,
bool? isActive,
DateTime? effectiveAt,
CancellationToken cancellationToken = default)
{
var tenantIds = new[] { tenantId, 0L };
var announcementQuery = context.TenantAnnouncements.AsNoTracking()
.IgnoreQueryFilters()
.Where(x => tenantIds.Contains(x.TenantId));
if (status.HasValue)
{
announcementQuery = announcementQuery.Where(x => x.Status == status.Value);
}
if (isActive.HasValue)
{
announcementQuery = announcementQuery.Where(x => x.IsActive == isActive.Value);
}
if (effectiveAt.HasValue)
{
var at = effectiveAt.Value;
announcementQuery = announcementQuery.Where(x => x.EffectiveFrom <= at && (x.EffectiveTo == null || x.EffectiveTo >= at));
}
var readQuery = context.TenantAnnouncementReads.AsNoTracking()
.IgnoreQueryFilters()
.Where(x => x.TenantId == tenantId);
readQuery = userId.HasValue
? readQuery.Where(x => x.UserId == null || x.UserId == userId.Value)
: readQuery.Where(x => x.UserId == null);
var query = from announcement in announcementQuery
join read in readQuery on announcement.Id equals read.AnnouncementId into readGroup
where !readGroup.Any()
select announcement;
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />

View File

@@ -0,0 +1,146 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTenantAnnouncementStatusAndPublisher : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PublisherScope",
table: "tenant_announcements",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "发布者范围。");
migrationBuilder.AddColumn<long>(
name: "PublisherUserId",
table: "tenant_announcements",
type: "bigint",
nullable: true,
comment: "发布者用户 ID平台或租户后台账号。");
migrationBuilder.AddColumn<int>(
name: "Status",
table: "tenant_announcements",
type: "integer",
nullable: false,
defaultValue: 0,
comment: "公告状态。");
migrationBuilder.AddColumn<DateTime>(
name: "PublishedAt",
table: "tenant_announcements",
type: "timestamp with time zone",
nullable: true,
comment: "实际发布时间UTC。");
migrationBuilder.AddColumn<DateTime>(
name: "RevokedAt",
table: "tenant_announcements",
type: "timestamp with time zone",
nullable: true,
comment: "撤销时间UTC。");
migrationBuilder.AddColumn<DateTime>(
name: "ScheduledPublishAt",
table: "tenant_announcements",
type: "timestamp with time zone",
nullable: true,
comment: "预定发布时间UTC。");
migrationBuilder.AddColumn<string>(
name: "TargetType",
table: "tenant_announcements",
type: "character varying(64)",
maxLength: 64,
nullable: false,
defaultValue: "",
comment: "目标受众类型。");
migrationBuilder.AddColumn<string>(
name: "TargetParameters",
table: "tenant_announcements",
type: "text",
nullable: true,
comment: "目标受众参数JSON。");
migrationBuilder.AddColumn<byte[]>(
name: "RowVersion",
table: "tenant_announcements",
type: "bytea",
rowVersion: true,
nullable: false,
defaultValue: new byte[0],
comment: "并发控制字段。");
migrationBuilder.Sql(
"UPDATE tenant_announcements SET \"Status\" = CASE WHEN \"IsActive\" THEN 1 ELSE 0 END;");
migrationBuilder.CreateIndex(
name: "IX_tenant_announcements_TenantId_Status_EffectiveFrom",
table: "tenant_announcements",
columns: new[] { "TenantId", "Status", "EffectiveFrom" });
migrationBuilder.CreateIndex(
name: "IX_tenant_announcements_Status_EffectiveFrom_Platform",
table: "tenant_announcements",
columns: new[] { "Status", "EffectiveFrom" },
filter: "\"TenantId\" = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_tenant_announcements_TenantId_Status_EffectiveFrom",
table: "tenant_announcements");
migrationBuilder.DropIndex(
name: "IX_tenant_announcements_Status_EffectiveFrom_Platform",
table: "tenant_announcements");
migrationBuilder.DropColumn(
name: "PublisherScope",
table: "tenant_announcements");
migrationBuilder.DropColumn(
name: "PublisherUserId",
table: "tenant_announcements");
migrationBuilder.DropColumn(
name: "Status",
table: "tenant_announcements");
migrationBuilder.DropColumn(
name: "PublishedAt",
table: "tenant_announcements");
migrationBuilder.DropColumn(
name: "RevokedAt",
table: "tenant_announcements");
migrationBuilder.DropColumn(
name: "ScheduledPublishAt",
table: "tenant_announcements");
migrationBuilder.DropColumn(
name: "TargetType",
table: "tenant_announcements");
migrationBuilder.DropColumn(
name: "TargetParameters",
table: "tenant_announcements");
migrationBuilder.DropColumn(
name: "RowVersion",
table: "tenant_announcements");
}
}
}

View File

@@ -0,0 +1,681 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TakeoutSaaS.Infrastructure.Identity.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb
{
[DbContext(typeof(IdentityDbContext))]
[Migration("20251220183000_GrantAnnouncementPermissionsToSuperAdmin")]
partial class GrantAnnouncementPermissionsToSuperAdmin
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.IdentityUser", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Account")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("登录账号。");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasComment("头像地址。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("展示名称。");
b.Property<long?>("MerchantId")
.HasColumnType("bigint")
.HasComment("所属商户(平台管理员为空)。");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasComment("密码哈希。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("TenantId", "Account")
.IsUnique();
b.ToTable("identity_users", null, t =>
{
t.HasComment("管理后台账户实体(平台管理员、租户管理员或商户员工)。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MenuDefinition", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("AuthListJson")
.HasColumnType("text")
.HasComment("按钮权限列表 JSON存储 MenuAuthItemDto 数组)。");
b.Property<string>("Component")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasComment("组件路径(不含 .vue。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("Icon")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("图标标识。");
b.Property<bool>("IsIframe")
.HasColumnType("boolean")
.HasComment("是否 iframe。");
b.Property<bool>("KeepAlive")
.HasColumnType("boolean")
.HasComment("是否缓存。");
b.Property<string>("Link")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("外链或 iframe 地址。");
b.Property<string>("MetaPermissions")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasComment("Meta.permissions逗号分隔。");
b.Property<string>("MetaRoles")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasComment("Meta.roles逗号分隔。");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("菜单名称(前端路由 name。");
b.Property<long>("ParentId")
.HasColumnType("bigint")
.HasComment("父级菜单 ID根节点为 0。");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasComment("路由路径。");
b.Property<string>("RequiredPermissions")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasComment("访问该菜单所需的权限集合(逗号分隔)。");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasComment("排序。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("标题。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "ParentId", "SortOrder");
b.ToTable("menu_definitions", null, t =>
{
t.HasComment("管理端菜单定义。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MiniUser", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Avatar")
.HasColumnType("text")
.HasComment("头像地址。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("Nickname")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("昵称。");
b.Property<string>("OpenId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("微信 OpenId。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<string>("UnionId")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("微信 UnionId可能为空。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("TenantId", "OpenId")
.IsUnique();
b.ToTable("mini_users", null, t =>
{
t.HasComment("小程序用户实体。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Permission", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("权限编码(租户内唯一)。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("Description")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasComment("描述。");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("权限名称。");
b.Property<long>("ParentId")
.HasColumnType("bigint")
.HasComment("父级权限 ID根节点为 0。");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasComment("排序值,值越小越靠前。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasComment("权限类型group/leaf。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("TenantId", "Code")
.IsUnique();
b.HasIndex("TenantId", "ParentId", "SortOrder");
b.ToTable("permissions", null, t =>
{
t.HasComment("权限定义。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Role", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("角色编码(租户内唯一)。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("Description")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasComment("描述。");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("角色名称。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("TenantId", "Code")
.IsUnique();
b.ToTable("roles", null, t =>
{
t.HasComment("角色定义。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RolePermission", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<long>("PermissionId")
.HasColumnType("bigint")
.HasComment("权限 ID。");
b.Property<long>("RoleId")
.HasColumnType("bigint")
.HasComment("角色 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("TenantId", "RoleId", "PermissionId")
.IsUnique();
b.ToTable("role_permissions", null, t =>
{
t.HasComment("角色-权限关系。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplate", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("Description")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasComment("模板描述。");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasComment("是否启用。");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("模板名称。");
b.Property<string>("TemplateCode")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("模板编码(唯一)。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TemplateCode")
.IsUnique();
b.ToTable("role_templates", null, t =>
{
t.HasComment("角色模板定义(平台级)。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplatePermission", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("PermissionCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("权限编码。");
b.Property<long>("RoleTemplateId")
.HasColumnType("bigint")
.HasComment("模板 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("RoleTemplateId", "PermissionCode")
.IsUnique();
b.ToTable("role_template_permissions", null, t =>
{
t.HasComment("角色模板-权限关系(平台级)。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.UserRole", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<long>("RoleId")
.HasColumnType("bigint")
.HasComment("角色 ID。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasComment("用户 ID。");
b.HasKey("Id");
b.HasIndex("TenantId");
b.HasIndex("TenantId", "UserId", "RoleId")
.IsUnique();
b.ToTable("user_roles", null, t =>
{
t.HasComment("用户-角色关系。");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,122 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb
{
/// <inheritdoc />
public partial class GrantAnnouncementPermissionsToSuperAdmin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"WITH target_roles AS (
SELECT ""Id"" AS role_id, ""TenantId"" AS tenant_id
FROM ""roles""
WHERE ""Code"" IN ('super-admin', 'SUPER_ADMIN', 'PlatformAdmin', 'platform-admin')
AND ""DeletedAt"" IS NULL
),
target_permissions AS (
SELECT DISTINCT tr.tenant_id, pc.code
FROM target_roles tr
CROSS JOIN (VALUES
('platform-announcement:create'),
('platform-announcement:publish'),
('platform-announcement:revoke'),
('tenant-announcement:publish'),
('tenant-announcement:revoke')
) AS pc(code)
)
INSERT INTO ""permissions"" (
""TenantId"",
""Name"",
""Code"",
""Description"",
""CreatedAt"",
""CreatedBy"",
""UpdatedAt"",
""UpdatedBy"",
""DeletedAt"",
""DeletedBy""
)
SELECT
tp.tenant_id,
tp.code,
tp.code,
CONCAT('Seed permission ', tp.code),
NOW(),
NULL,
NULL,
NULL,
NULL,
NULL
FROM target_permissions tp
ON CONFLICT (""TenantId"", ""Code"") DO NOTHING;
INSERT INTO ""role_permissions"" (
""TenantId"",
""RoleId"",
""PermissionId"",
""CreatedAt"",
""CreatedBy"",
""UpdatedAt"",
""UpdatedBy"",
""DeletedAt"",
""DeletedBy""
)
SELECT
tr.tenant_id,
tr.role_id,
p.""Id"",
NOW(),
NULL,
NULL,
NULL,
NULL,
NULL
FROM target_roles tr
JOIN ""permissions"" p
ON p.""TenantId"" = tr.tenant_id
AND p.""Code"" IN (
'platform-announcement:create',
'platform-announcement:publish',
'platform-announcement:revoke',
'tenant-announcement:publish',
'tenant-announcement:revoke'
)
WHERE p.""DeletedAt"" IS NULL
ON CONFLICT (""TenantId"", ""RoleId"", ""PermissionId"") DO NOTHING;"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"WITH target_roles AS (
SELECT ""Id"" AS role_id, ""TenantId"" AS tenant_id
FROM ""roles""
WHERE ""Code"" IN ('super-admin', 'SUPER_ADMIN', 'PlatformAdmin', 'platform-admin')
AND ""DeletedAt"" IS NULL
),
target_permissions AS (
SELECT ""Id"" AS permission_id, ""TenantId"" AS tenant_id
FROM ""permissions""
WHERE ""Code"" IN (
'platform-announcement:create',
'platform-announcement:publish',
'platform-announcement:revoke',
'tenant-announcement:publish',
'tenant-announcement:revoke'
)
)
DELETE FROM ""role_permissions"" rp
USING target_roles tr, target_permissions tp
WHERE rp.""TenantId"" = tr.tenant_id
AND rp.""RoleId"" = tr.role_id
AND rp.""PermissionId"" = tp.permission_id;"
);
}
}
}

View File

@@ -6032,9 +6032,50 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("timestamp with time zone")
.HasComment("失效时间UTC为空表示长期有效。");
b.Property<int>("PublisherScope")
.HasColumnType("integer")
.HasComment("发布者范围。");
b.Property<long?>("PublisherUserId")
.HasColumnType("bigint")
.HasComment("发布者用户 ID平台或租户后台账号。");
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("公告状态。");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("timestamp with time zone")
.HasComment("实际发布时间UTC。");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasComment("撤销时间UTC。");
b.Property<DateTime?>("ScheduledPublishAt")
.HasColumnType("timestamp with time zone")
.HasComment("预定发布时间UTC。");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("目标受众类型。");
b.Property<string>("TargetParameters")
.HasColumnType("text")
.HasComment("目标受众参数JSON。");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea")
.HasComment("并发控制字段。");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasComment("是否启用。");
.HasComment("是否启用(已弃用,迁移期保留)。");
b.Property<int>("Priority")
.HasColumnType("integer")
@@ -6064,6 +6105,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasIndex("TenantId", "EffectiveFrom", "EffectiveTo");
b.HasIndex("TenantId", "Status", "EffectiveFrom");
b.HasIndex("Status", "EffectiveFrom")
.HasFilter("\"TenantId\" = 0");
b.ToTable("tenant_announcements", null, t =>
{
t.HasComment("租户公告。");