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",