From 9fe7d9606d6a82fd592beec3dd6f755c00418b6b Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 21:08:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=9F=E6=88=B7=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E5=85=AC=E5=91=8A=E9=80=9A=E7=9F=A5=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- Document/15_API边界与自检清单.md | 4 +- .../TenantAnnouncementsController.cs | 106 ++++++++++++++++++ .../Controllers/TenantBillingsController.cs | 79 +++++++++++++ .../TenantNotificationsController.cs | 49 ++++++++ .../appsettings.Seed.Development.json | 27 +++++ .../CreateTenantAnnouncementCommand.cs | 20 ++++ .../Commands/CreateTenantBillingCommand.cs | 21 ++++ .../DeleteTenantAnnouncementCommand.cs | 12 ++ .../MarkTenantAnnouncementReadCommand.cs | 13 +++ .../Commands/MarkTenantBillingPaidCommand.cs | 15 +++ .../MarkTenantNotificationReadCommand.cs | 13 +++ .../UpdateTenantAnnouncementCommand.cs | 21 ++++ .../App/Tenants/Dto/TenantAnnouncementDto.cs | 35 ++++++ .../App/Tenants/Dto/TenantBillingDto.cs | 33 ++++++ .../App/Tenants/Dto/TenantNotificationDto.cs | 31 +++++ .../CreateTenantAnnouncementCommandHandler.cs | 41 +++++++ .../CreateTenantBillingCommandHandler.cs | 42 +++++++ .../DeleteTenantAnnouncementCommandHandler.cs | 19 ++++ .../GetTenantAnnouncementQueryHandler.cs | 28 +++++ .../Handlers/GetTenantBillQueryHandler.cs | 19 ++++ ...arkTenantAnnouncementReadCommandHandler.cs | 49 ++++++++ .../MarkTenantBillingPaidCommandHandler.cs | 32 ++++++ ...arkTenantNotificationReadCommandHandler.cs | 31 +++++ .../SearchTenantAnnouncementsQueryHandler.cs | 54 +++++++++ .../Handlers/SearchTenantBillsQueryHandler.cs | 27 +++++ .../SearchTenantNotificationsQueryHandler.cs | 33 ++++++ .../UpdateTenantAnnouncementCommandHandler.cs | 42 +++++++ .../Queries/GetTenantAnnouncementQuery.cs | 13 +++ .../App/Tenants/Queries/GetTenantBillQuery.cs | 13 +++ .../Queries/SearchTenantAnnouncementsQuery.cs | 19 ++++ .../Tenants/Queries/SearchTenantBillsQuery.cs | 19 ++++ .../Queries/SearchTenantNotificationsQuery.cs | 18 +++ .../App/Tenants/TenantMapping.cs | 45 ++++++++ .../Tenants/Entities/TenantAnnouncement.cs | 45 ++++++++ .../Entities/TenantAnnouncementRead.cs | 24 ++++ .../Tenants/Enums/TenantAnnouncementType.cs | 22 ++++ .../ITenantAnnouncementReadRepository.cs | 20 ++++ .../ITenantAnnouncementRepository.cs | 30 +++++ .../Repositories/ITenantBillingRepository.cs | 30 +++++ .../ITenantNotificationRepository.cs | 29 +++++ .../AppServiceCollectionExtensions.cs | 4 + .../App/Persistence/TakeoutAppDbContext.cs | 33 ++++++ .../EfTenantAnnouncementReadRepository.cs | 38 +++++++ .../EfTenantAnnouncementRepository.cs | 78 +++++++++++++ .../Repositories/EfTenantBillingRepository.cs | 73 ++++++++++++ .../EfTenantNotificationRepository.cs | 73 ++++++++++++ 47 files changed, 1522 insertions(+), 4 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 02f9c18..ff43c0e 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -12,8 +12,8 @@ - 已交付:新增模板目录 `RoleTemplateProvider`(`src/Application/TakeoutSaaS.Application/Identity/Templates`),提供四个预置角色与权限定义;应用层新增模板列表/详情查询、复制与租户批量初始化命令(Handlers 位于 `src/Application/TakeoutSaaS.Application/Identity/Handlers`)。管理端 `RolesController` 暴露模板列表、详情、按模板复制、批量初始化端点(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs`),复制时自动补齐缺失权限并保留租户自定义授权。 - [x] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 - 已交付:新增套餐仓储与命令/查询/DTO(`src/Application/TakeoutSaaS.Application/App/Tenants`),Admin 端新增 `TenantPackagesController` 提供套餐列表/详情/创建/更新/删除接口。新增配额校验命令与租户接口 `/api/admin/v1/tenants/{id}/quotas/check`,基于当前订阅套餐限额校验并占用配额,超额抛出 409 并写入 `TenantQuotaUsage`。仓储注册于 `AddAppInfrastructure`。 -- [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 - - 当前:`SystemParametersController` 仅负责普通参数 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs:15-104`),未包含租户账单、公告或通知接口。 +- [x] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 + - 已交付:新增账单/公告/通知实体与仓储,Admin 端提供 `/tenants/{id}/billings`(列表/详情/创建/标记支付)、`/announcements`(列表/详情/创建/更新/删除/已读)、`/notifications`(列表/已读)端点;权限码补充 `tenant-bill:*`、`tenant-announcement:*`、`tenant-notification:*`,种子模板更新;配额/订阅告警可通过通知表承载。 - [ ] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 - [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。 - [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 diff --git a/Document/15_API边界与自检清单.md b/Document/15_API边界与自检清单.md index af5bc58..53e284a 100644 --- a/Document/15_API边界与自检清单.md +++ b/Document/15_API边界与自检清单.md @@ -8,14 +8,14 @@ - **鉴权**:JWT + RBAC(`[Authorize]` + `PermissionAuthorize`),必须带租户头 `X-Tenant-Id/Code`。 - **路由前缀**:`api/admin/v{version}/...`。 - **DTO/约束**:仅管理字段,禁止返回 C 端敏感信息;long -> string;严禁实体直接返回。 -- **现有控制器**:`AuthController`、`DeliveriesController`、`DictionaryController`、`FilesController`、`MerchantsController`、`OrdersController`、`PaymentsController`、`PermissionsController`、`RolesController`、`StoresController`、`SystemParametersController`、`TenantPackagesController`、`TenantsController`、`UserPermissionsController`、`HealthController`。 +- **现有控制器**:`AuthController`、`DeliveriesController`、`DictionaryController`、`FilesController`、`MerchantsController`、`OrdersController`、`PaymentsController`、`PermissionsController`、`RolesController`、`StoresController`、`SystemParametersController`、`TenantPackagesController`、`TenantsController`、`TenantBillingsController`、`TenantAnnouncementsController`、`TenantNotificationsController`、`UserPermissionsController`、`HealthController`。 - **自检清单**: 1. 是否需要权限/租户过滤?未加则补 `[Authorize]` + 租户解析。 2. 是否调用了应用层 CQRS,而非在 Controller 写业务? 3. DTO 是否按管理口径,未暴露用户端字段? 4. 是否使用参数化/AsNoTracking/投影,避免 N+1? 5. 路由和 Swagger 示例是否含租户/权限说明? -- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。TenantPackagesController 与 TenantsController(配额校验) 均使用权限码、DTO 映射,配额校验要求携带租户头防越权。 +- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。TenantPackagesController 与 TenantsController(配额校验) 均使用权限码、DTO 映射,配额校验要求携带租户头防越权。新增租户账单/公告/通知控制器,全部采用 CQRS、权限校验与租户参数,列表分页、未暴露实体。 ## 2. UserApi(C 端用户) - **面向对象**:App/H5 普通用户。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs new file mode 100644 index 0000000..e556b3d --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs @@ -0,0 +1,106 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户公告管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/announcements")] +public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询公告。 + /// + [HttpGet] + [PermissionAuthorize("tenant-announcement:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search(long tenantId, [FromQuery] SearchTenantAnnouncementsQuery query, CancellationToken cancellationToken) + { + query = query with { TenantId = tenantId }; + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 公告详情。 + /// + [HttpGet("{announcementId:long}")] + [PermissionAuthorize("tenant-announcement:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long tenantId, long announcementId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTenantAnnouncementQuery { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建公告。 + /// + [HttpPost] + [PermissionAuthorize("tenant-announcement:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long tenantId, [FromBody, Required] CreateTenantAnnouncementCommand command, CancellationToken cancellationToken) + { + command = command with { TenantId = tenantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新公告。 + /// + [HttpPut("{announcementId:long}")] + [PermissionAuthorize("tenant-announcement:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long tenantId, long announcementId, [FromBody, Required] UpdateTenantAnnouncementCommand command, CancellationToken cancellationToken) + { + command = command with { TenantId = tenantId, AnnouncementId = announcementId }; + var result = await mediator.Send(command, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除公告。 + /// + [HttpDelete("{announcementId:long}")] + [PermissionAuthorize("tenant-announcement:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete(long tenantId, long announcementId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteTenantAnnouncementCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 标记公告已读。 + /// + [HttpPost("{announcementId:long}/read")] + [PermissionAuthorize("tenant-announcement:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> MarkRead(long tenantId, long announcementId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new MarkTenantAnnouncementReadCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs new file mode 100644 index 0000000..fb2093f --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户账单管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/billings")] +public sealed class TenantBillingsController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询账单。 + /// + [HttpGet] + [PermissionAuthorize("tenant-bill:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search(long tenantId, [FromQuery] SearchTenantBillsQuery query, CancellationToken cancellationToken) + { + query = query with { TenantId = tenantId }; + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 账单详情。 + /// + [HttpGet("{billingId:long}")] + [PermissionAuthorize("tenant-bill:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long tenantId, long billingId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTenantBillQuery { TenantId = tenantId, BillingId = billingId }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建账单。 + /// + [HttpPost] + [PermissionAuthorize("tenant-bill:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long tenantId, [FromBody, Required] CreateTenantBillingCommand command, CancellationToken cancellationToken) + { + command = command with { TenantId = tenantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 标记账单已支付。 + /// + [HttpPost("{billingId:long}/pay")] + [PermissionAuthorize("tenant-bill:pay")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> MarkPaid(long tenantId, long billingId, [FromBody, Required] MarkTenantBillingPaidCommand command, CancellationToken cancellationToken) + { + command = command with { TenantId = tenantId, BillingId = billingId }; + var result = await mediator.Send(command, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs new file mode 100644 index 0000000..84babb5 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs @@ -0,0 +1,49 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户通知接口。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/notifications")] +public sealed class TenantNotificationsController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询通知。 + /// + [HttpGet] + [PermissionAuthorize("tenant-notification:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search(long tenantId, [FromQuery] SearchTenantNotificationsQuery query, CancellationToken cancellationToken) + { + query = query with { TenantId = tenantId }; + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 标记通知已读。 + /// + [HttpPost("{notificationId:long}/read")] + [PermissionAuthorize("tenant-notification:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> MarkRead(long tenantId, long notificationId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new MarkTenantNotificationReadCommand { TenantId = tenantId, NotificationId = notificationId }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "通知不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 3092532..6ebda51 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -60,6 +60,15 @@ "role-template:create", "role-template:update", "role-template:delete", + "tenant-bill:read", + "tenant-bill:create", + "tenant-bill:pay", + "tenant-announcement:read", + "tenant-announcement:create", + "tenant-announcement:update", + "tenant-announcement:delete", + "tenant-notification:read", + "tenant-notification:update", "tenant:create", "tenant:read", "tenant:review", @@ -127,6 +136,15 @@ "identity:permission:create", "identity:permission:update", "identity:permission:delete", + "tenant-bill:read", + "tenant-bill:create", + "tenant-bill:pay", + "tenant-announcement:read", + "tenant-announcement:create", + "tenant-announcement:update", + "tenant-announcement:delete", + "tenant-notification:read", + "tenant-notification:update", "tenant:read", "tenant:subscription", "tenant:quota:check", @@ -226,6 +244,15 @@ "role-template:create", "role-template:update", "role-template:delete", + "tenant-bill:read", + "tenant-bill:create", + "tenant-bill:pay", + "tenant-announcement:read", + "tenant-announcement:create", + "tenant-announcement:update", + "tenant-announcement:delete", + "tenant-notification:read", + "tenant-notification:update", "tenant:create", "tenant:read", "tenant:review", diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs new file mode 100644 index 0000000..da68b6f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 创建租户公告命令。 +/// +public sealed record CreateTenantAnnouncementCommand : IRequest +{ + public long TenantId { get; init; } + public string Title { get; init; } = string.Empty; + public string Content { get; init; } = string.Empty; + public TenantAnnouncementType AnnouncementType { get; init; } = TenantAnnouncementType.System; + public int Priority { get; init; } = 0; + public DateTime EffectiveFrom { get; init; } = DateTime.UtcNow; + public DateTime? EffectiveTo { get; init; } + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs new file mode 100644 index 0000000..16c771d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs @@ -0,0 +1,21 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 创建租户账单命令。 +/// +public sealed record CreateTenantBillingCommand : IRequest +{ + public long TenantId { get; init; } + public string StatementNo { get; init; } = string.Empty; + public DateTime PeriodStart { get; init; } + public DateTime PeriodEnd { get; init; } + public decimal AmountDue { get; init; } + public decimal AmountPaid { get; init; } + public TenantBillingStatus Status { get; init; } = TenantBillingStatus.Pending; + public DateTime DueDate { get; init; } + public string? LineItemsJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs new file mode 100644 index 0000000..c67877c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs @@ -0,0 +1,12 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 删除租户公告命令。 +/// +public sealed record DeleteTenantAnnouncementCommand : IRequest +{ + public long TenantId { get; init; } + public long AnnouncementId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs new file mode 100644 index 0000000..85c679a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 标记公告已读命令。 +/// +public sealed record MarkTenantAnnouncementReadCommand : IRequest +{ + public long TenantId { get; init; } + public long AnnouncementId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs new file mode 100644 index 0000000..5479882 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 标记租户账单已支付命令。 +/// +public sealed record MarkTenantBillingPaidCommand : IRequest +{ + public long TenantId { get; init; } + public long BillingId { get; init; } + public decimal AmountPaid { get; init; } + public DateTime PaidAt { get; init; } = DateTime.UtcNow; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs new file mode 100644 index 0000000..31ddb58 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 标记通知已读命令。 +/// +public sealed record MarkTenantNotificationReadCommand : IRequest +{ + public long TenantId { get; init; } + public long NotificationId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs new file mode 100644 index 0000000..57c6569 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs @@ -0,0 +1,21 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 更新租户公告命令。 +/// +public sealed record UpdateTenantAnnouncementCommand : IRequest +{ + public long TenantId { get; init; } + public long AnnouncementId { get; init; } + public string Title { get; init; } = string.Empty; + public string Content { get; init; } = string.Empty; + public TenantAnnouncementType AnnouncementType { get; init; } = TenantAnnouncementType.System; + public int Priority { get; init; } = 0; + public DateTime EffectiveFrom { get; init; } = DateTime.UtcNow; + public DateTime? EffectiveTo { get; init; } + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs new file mode 100644 index 0000000..5a0a7bf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户公告 DTO。 +/// +public sealed class TenantAnnouncementDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + public string Title { get; init; } = string.Empty; + + public string Content { get; init; } = string.Empty; + + public TenantAnnouncementType AnnouncementType { get; init; } + + public int Priority { get; init; } + + public DateTime EffectiveFrom { get; init; } + + public DateTime? EffectiveTo { get; init; } + + public bool IsActive { get; init; } + + public bool IsRead { get; init; } + + public DateTime? ReadAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs new file mode 100644 index 0000000..d2b426b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户账单 DTO。 +/// +public sealed class TenantBillingDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + public string StatementNo { get; init; } = string.Empty; + + public DateTime PeriodStart { get; init; } + + public DateTime PeriodEnd { get; init; } + + public decimal AmountDue { get; init; } + + public decimal AmountPaid { get; init; } + + public TenantBillingStatus Status { get; init; } + + public DateTime DueDate { get; init; } + + public string? LineItemsJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs new file mode 100644 index 0000000..e6ab6a9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户通知 DTO。 +/// +public sealed class TenantNotificationDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + public string Title { get; init; } = string.Empty; + + public string Message { get; init; } = string.Empty; + + public TenantNotificationChannel Channel { get; init; } + + public TenantNotificationSeverity Severity { get; init; } + + public DateTime SentAt { get; init; } + + public DateTime? ReadAt { get; init; } + + public string? MetadataJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs new file mode 100644 index 0000000..ba48bb6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs @@ -0,0 +1,41 @@ +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.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 创建公告处理器。 +/// +public sealed class CreateTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository) + : IRequestHandler +{ + public async Task Handle(CreateTenantAnnouncementCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content)) + { + throw new BusinessException(ErrorCodes.BadRequest, "公告标题和内容不能为空"); + } + + var announcement = new TenantAnnouncement + { + TenantId = request.TenantId, + Title = request.Title.Trim(), + Content = request.Content, + AnnouncementType = request.AnnouncementType, + Priority = request.Priority, + EffectiveFrom = request.EffectiveFrom, + EffectiveTo = request.EffectiveTo, + IsActive = request.IsActive + }; + + await announcementRepository.AddAsync(announcement, cancellationToken); + await announcementRepository.SaveChangesAsync(cancellationToken); + + return announcement.ToDto(false, null); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs new file mode 100644 index 0000000..f07f889 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs @@ -0,0 +1,42 @@ +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.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 创建租户账单处理器。 +/// +public sealed class CreateTenantBillingCommandHandler(ITenantBillingRepository billingRepository) + : IRequestHandler +{ + public async Task Handle(CreateTenantBillingCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.StatementNo)) + { + throw new BusinessException(ErrorCodes.BadRequest, "账单编号不能为空"); + } + + var bill = new TenantBillingStatement + { + TenantId = request.TenantId, + StatementNo = request.StatementNo.Trim(), + PeriodStart = request.PeriodStart, + PeriodEnd = request.PeriodEnd, + AmountDue = request.AmountDue, + AmountPaid = request.AmountPaid, + Status = request.Status, + DueDate = request.DueDate, + LineItemsJson = request.LineItemsJson + }; + + await billingRepository.AddAsync(bill, cancellationToken); + await billingRepository.SaveChangesAsync(cancellationToken); + + return bill.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs new file mode 100644 index 0000000..5464299 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs @@ -0,0 +1,19 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 删除公告处理器。 +/// +public sealed class DeleteTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository) + : IRequestHandler +{ + public async Task Handle(DeleteTenantAnnouncementCommand request, CancellationToken cancellationToken) + { + await announcementRepository.DeleteAsync(request.TenantId, request.AnnouncementId, cancellationToken); + await announcementRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs new file mode 100644 index 0000000..bfc8f87 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs @@ -0,0 +1,28 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 公告详情查询处理器。 +/// +public sealed class GetTenantAnnouncementQueryHandler( + ITenantAnnouncementRepository announcementRepository, + ITenantAnnouncementReadRepository readRepository) + : IRequestHandler +{ + public async Task Handle(GetTenantAnnouncementQuery request, CancellationToken cancellationToken) + { + var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); + if (announcement == null) + { + return null; + } + + var reads = await readRepository.GetByAnnouncementAsync(request.TenantId, request.AnnouncementId, cancellationToken); + var readRecord = reads.FirstOrDefault(); + return announcement.ToDto(readRecord != null, readRecord?.ReadAt); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs new file mode 100644 index 0000000..c3e96d6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs @@ -0,0 +1,19 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 账单详情查询处理器。 +/// +public sealed class GetTenantBillQueryHandler(ITenantBillingRepository billingRepository) + : IRequestHandler +{ + public async Task Handle(GetTenantBillQuery request, CancellationToken cancellationToken) + { + var bill = await billingRepository.FindByIdAsync(request.TenantId, request.BillingId, cancellationToken); + return bill?.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs new file mode 100644 index 0000000..63f9604 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs @@ -0,0 +1,49 @@ +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.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 标记公告已读处理器。 +/// +public sealed class MarkTenantAnnouncementReadCommandHandler( + ITenantAnnouncementRepository announcementRepository, + ITenantAnnouncementReadRepository readRepository, + ICurrentUserAccessor? currentUserAccessor = null) + : IRequestHandler +{ + public async Task Handle(MarkTenantAnnouncementReadCommand request, CancellationToken cancellationToken) + { + var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); + if (announcement == null) + { + return null; + } + + var userId = currentUserAccessor?.UserId ?? 0; + var existing = await readRepository.FindAsync(request.TenantId, request.AnnouncementId, userId == 0 ? null : userId, cancellationToken); + + 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; + } + + return announcement.ToDto(true, existing.ReadAt); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs new file mode 100644 index 0000000..3d708af --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs @@ -0,0 +1,32 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 标记账单支付处理器。 +/// +public sealed class MarkTenantBillingPaidCommandHandler(ITenantBillingRepository billingRepository) + : IRequestHandler +{ + public async Task Handle(MarkTenantBillingPaidCommand request, CancellationToken cancellationToken) + { + var bill = await billingRepository.FindByIdAsync(request.TenantId, request.BillingId, cancellationToken); + if (bill == null) + { + return null; + } + + bill.AmountPaid = request.AmountPaid; + bill.Status = TenantBillingStatus.Paid; + bill.DueDate = bill.DueDate; + + await billingRepository.UpdateAsync(bill, cancellationToken); + await billingRepository.SaveChangesAsync(cancellationToken); + + return bill.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs new file mode 100644 index 0000000..48a8400 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 标记通知已读处理器。 +/// +public sealed class MarkTenantNotificationReadCommandHandler(ITenantNotificationRepository notificationRepository) + : IRequestHandler +{ + public async Task Handle(MarkTenantNotificationReadCommand request, CancellationToken cancellationToken) + { + var notification = await notificationRepository.FindByIdAsync(request.TenantId, request.NotificationId, cancellationToken); + if (notification == null) + { + return null; + } + + if (notification.ReadAt == null) + { + notification.ReadAt = DateTime.UtcNow; + await notificationRepository.UpdateAsync(notification, cancellationToken); + await notificationRepository.SaveChangesAsync(cancellationToken); + } + + return notification.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs new file mode 100644 index 0000000..ee5f689 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs @@ -0,0 +1,54 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 公告分页查询处理器。 +/// +public sealed class SearchTenantAnnouncementsQueryHandler( + ITenantAnnouncementRepository announcementRepository, + ITenantAnnouncementReadRepository announcementReadRepository) + : IRequestHandler> +{ + public async Task> Handle(SearchTenantAnnouncementsQuery request, CancellationToken cancellationToken) + { + var effectiveAt = request.OnlyEffective == true ? DateTime.UtcNow : (DateTime?)null; + var announcements = await announcementRepository.SearchAsync(request.TenantId, request.AnnouncementType, request.IsActive, effectiveAt, cancellationToken); + + var readMap = new Dictionary(); + foreach (var announcement in announcements) + { + var reads = await announcementReadRepository.GetByAnnouncementAsync(request.TenantId, announcement.Id, cancellationToken); + var readRecord = reads.FirstOrDefault(); + if (readRecord != null) + { + readMap[announcement.Id] = (true, readRecord.ReadAt); + } + } + + var ordered = announcements + .OrderByDescending(x => x.Priority) + .ThenByDescending(x => x.CreatedAt) + .ToList(); + + var page = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + + var items = ordered + .Skip((page - 1) * size) + .Take(size) + .Select(a => + { + readMap.TryGetValue(a.Id, out var read); + return a.ToDto(read.isRead, read.readAt); + }) + .ToList(); + + return new PagedResult(items, page, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs new file mode 100644 index 0000000..5bd2ec7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs @@ -0,0 +1,27 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 账单分页查询处理器。 +/// +public sealed class SearchTenantBillsQueryHandler(ITenantBillingRepository billingRepository) + : IRequestHandler> +{ + public async Task> Handle(SearchTenantBillsQuery request, CancellationToken cancellationToken) + { + var bills = await billingRepository.SearchAsync(request.TenantId, request.Status, request.From, request.To, cancellationToken); + + var ordered = bills.OrderByDescending(x => x.PeriodEnd).ToList(); + var page = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + var items = ordered.Skip((page - 1) * size).Take(size).Select(x => x.ToDto()).ToList(); + + return new PagedResult(items, page, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs new file mode 100644 index 0000000..ec05cd6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs @@ -0,0 +1,33 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 通知分页查询处理器。 +/// +public sealed class SearchTenantNotificationsQueryHandler(ITenantNotificationRepository notificationRepository) + : IRequestHandler> +{ + public async Task> Handle(SearchTenantNotificationsQuery request, CancellationToken cancellationToken) + { + var notifications = await notificationRepository.SearchAsync( + request.TenantId, + request.Severity, + request.UnreadOnly, + null, + null, + cancellationToken); + + var ordered = notifications.OrderByDescending(x => x.SentAt).ToList(); + var page = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + var items = ordered.Skip((page - 1) * size).Take(size).Select(x => x.ToDto()).ToList(); + + return new PagedResult(items, page, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs new file mode 100644 index 0000000..ea76e80 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs @@ -0,0 +1,42 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 更新公告处理器。 +/// +public sealed class UpdateTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository) + : IRequestHandler +{ + public async Task Handle(UpdateTenantAnnouncementCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content)) + { + throw new BusinessException(ErrorCodes.BadRequest, "公告标题和内容不能为空"); + } + + var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); + if (announcement == null) + { + return null; + } + + 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; + + await announcementRepository.UpdateAsync(announcement, cancellationToken); + await announcementRepository.SaveChangesAsync(cancellationToken); + + return announcement.ToDto(false, null); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs new file mode 100644 index 0000000..52bdcfb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 公告详情查询。 +/// +public sealed record GetTenantAnnouncementQuery : IRequest +{ + public long TenantId { get; init; } + public long AnnouncementId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs new file mode 100644 index 0000000..f22eb99 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 获取账单详情查询。 +/// +public sealed record GetTenantBillQuery : IRequest +{ + public long TenantId { get; init; } + public long BillingId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs new file mode 100644 index 0000000..3f059ad --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs @@ -0,0 +1,19 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 分页查询租户公告。 +/// +public sealed record SearchTenantAnnouncementsQuery : IRequest> +{ + public long TenantId { get; init; } + public TenantAnnouncementType? AnnouncementType { get; init; } + public bool? IsActive { get; init; } + public bool? OnlyEffective { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs new file mode 100644 index 0000000..b8747e0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs @@ -0,0 +1,19 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 分页查询租户账单。 +/// +public sealed record SearchTenantBillsQuery : IRequest> +{ + public long TenantId { get; init; } + public TenantBillingStatus? Status { get; init; } + public DateTime? From { get; init; } + public DateTime? To { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs new file mode 100644 index 0000000..92875ff --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs @@ -0,0 +1,18 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 分页查询租户通知。 +/// +public sealed record SearchTenantNotificationsQuery : IRequest> +{ + public long TenantId { get; init; } + public TenantNotificationSeverity? Severity { get; init; } + public bool? UnreadOnly { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs index ab7fb4f..d361892 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs @@ -91,4 +91,49 @@ internal static class TenantMapping FeaturePoliciesJson = package.FeaturePoliciesJson, IsActive = package.IsActive }; + + public static TenantBillingDto ToDto(this TenantBillingStatement bill) + => new() + { + Id = bill.Id, + TenantId = bill.TenantId, + StatementNo = bill.StatementNo, + PeriodStart = bill.PeriodStart, + PeriodEnd = bill.PeriodEnd, + AmountDue = bill.AmountDue, + AmountPaid = bill.AmountPaid, + Status = bill.Status, + DueDate = bill.DueDate, + LineItemsJson = bill.LineItemsJson + }; + + public static TenantAnnouncementDto ToDto(this TenantAnnouncement announcement, bool isRead, DateTime? readAt) + => new() + { + Id = announcement.Id, + TenantId = announcement.TenantId, + Title = announcement.Title, + Content = announcement.Content, + AnnouncementType = announcement.AnnouncementType, + Priority = announcement.Priority, + EffectiveFrom = announcement.EffectiveFrom, + EffectiveTo = announcement.EffectiveTo, + IsActive = announcement.IsActive, + IsRead = isRead, + ReadAt = readAt + }; + + public static TenantNotificationDto ToDto(this TenantNotification notification) + => new() + { + Id = notification.Id, + TenantId = notification.TenantId, + Title = notification.Title, + Message = notification.Message, + Channel = notification.Channel, + Severity = notification.Severity, + SentAt = notification.SentAt, + ReadAt = notification.ReadAt, + MetadataJson = notification.MetadataJson + }; } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs new file mode 100644 index 0000000..451efdb --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户公告。 +/// +public sealed class TenantAnnouncement : MultiTenantEntityBase +{ + /// + /// 公告标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 公告正文(可为 Markdown/HTML,前端自行渲染)。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 公告类型。 + /// + public TenantAnnouncementType AnnouncementType { get; set; } = TenantAnnouncementType.System; + + /// + /// 展示优先级,数值越大越靠前。 + /// + public int Priority { get; set; } = 0; + + /// + /// 生效时间(UTC)。 + /// + public DateTime EffectiveFrom { get; set; } = DateTime.UtcNow; + + /// + /// 失效时间(UTC),为空表示长期有效。 + /// + public DateTime? EffectiveTo { get; set; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs new file mode 100644 index 0000000..fa52716 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户公告已读记录。 +/// +public sealed class TenantAnnouncementRead : MultiTenantEntityBase +{ + /// + /// 公告 ID。 + /// + public long AnnouncementId { get; set; } + + /// + /// 已读用户 ID(后台账号),为空表示租户级已读。 + /// + public long? UserId { get; set; } + + /// + /// 已读时间。 + /// + public DateTime ReadAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs new file mode 100644 index 0000000..d55a6d9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户公告类型。 +/// +public enum TenantAnnouncementType +{ + /// + /// 系统公告。 + /// + System = 0, + + /// + /// 账单/订阅相关提醒。 + /// + Billing = 1, + + /// + /// 运营通知。 + /// + Operation = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs new file mode 100644 index 0000000..86a758b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 公告已读仓储。 +/// +public interface ITenantAnnouncementReadRepository +{ + Task> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + + Task FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default); + + Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs new file mode 100644 index 0000000..f42b041 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户公告仓储。 +/// +public interface ITenantAnnouncementRepository +{ + Task> SearchAsync( + long tenantId, + TenantAnnouncementType? type, + bool? isActive, + DateTime? effectiveAt, + CancellationToken cancellationToken = default); + + Task FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + + Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default); + + Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default); + + Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs new file mode 100644 index 0000000..88d7fef --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户账单仓储。 +/// +public interface ITenantBillingRepository +{ + Task> SearchAsync( + long tenantId, + TenantBillingStatus? status, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default); + + Task FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default); + + Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default); + + Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default); + + Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs new file mode 100644 index 0000000..2ee3735 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户通知仓储。 +/// +public interface ITenantNotificationRepository +{ + Task> SearchAsync( + long tenantId, + TenantNotificationSeverity? severity, + bool? unreadOnly, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default); + + Task FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default); + + Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default); + + Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index 657f769..ed6f4eb 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -39,6 +39,10 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 78dca47..f2f6177 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -43,6 +43,8 @@ public sealed class TakeoutAppDbContext( public DbSet TenantQuotaUsages => Set(); public DbSet TenantBillingStatements => Set(); public DbSet TenantNotifications => Set(); + public DbSet TenantAnnouncements => Set(); + public DbSet TenantAnnouncementReads => Set(); public DbSet TenantVerificationProfiles => Set(); public DbSet TenantAuditLogs => Set(); @@ -141,6 +143,8 @@ public sealed class TakeoutAppDbContext( ConfigureTenantQuotaUsage(modelBuilder.Entity()); ConfigureTenantBilling(modelBuilder.Entity()); ConfigureTenantNotification(modelBuilder.Entity()); + ConfigureTenantAnnouncement(modelBuilder.Entity()); + ConfigureTenantAnnouncementRead(modelBuilder.Entity()); ConfigureTenantVerificationProfile(modelBuilder.Entity()); ConfigureTenantAuditLog(modelBuilder.Entity()); ConfigureMerchantDocument(modelBuilder.Entity()); @@ -465,6 +469,35 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.Channel, x.SentAt }); } + private static void ConfigureTenantAnnouncement(EntityTypeBuilder builder) + { + builder.ToTable("tenant_announcements"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Content).HasColumnType("text").IsRequired(); + builder.Property(x => x.AnnouncementType).HasConversion(); + builder.Property(x => x.Priority).IsRequired(); + builder.Property(x => x.IsActive).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => new { x.TenantId, x.AnnouncementType, x.IsActive }); + builder.HasIndex(x => new { x.TenantId, x.EffectiveFrom, x.EffectiveTo }); + } + + private static void ConfigureTenantAnnouncementRead(EntityTypeBuilder builder) + { + builder.ToTable("tenant_announcement_reads"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.AnnouncementId).IsRequired(); + builder.Property(x => x.UserId); + builder.Property(x => x.ReadAt).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => new { x.TenantId, x.AnnouncementId, x.UserId }).IsUnique(); + } + private static void ConfigureMerchantDocument(EntityTypeBuilder builder) { builder.ToTable("merchant_documents"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs new file mode 100644 index 0000000..ebc63ea --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs @@ -0,0 +1,38 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// EF 公告已读仓储。 +/// +public sealed class EfTenantAnnouncementReadRepository(TakeoutAppDbContext context) : ITenantAnnouncementReadRepository +{ + public Task> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncementReads.AsNoTracking() + .Where(x => x.TenantId == tenantId && x.AnnouncementId == announcementId) + .OrderBy(x => x.ReadAt) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncementReads + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.AnnouncementId == announcementId && x.UserId == userId, cancellationToken); + } + + public Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncementReads.AddAsync(record, cancellationToken).AsTask(); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs new file mode 100644 index 0000000..a03d206 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs @@ -0,0 +1,78 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// EF 租户公告仓储。 +/// +public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) : ITenantAnnouncementRepository +{ + public Task> SearchAsync( + long tenantId, + TenantAnnouncementType? type, + bool? isActive, + DateTime? effectiveAt, + CancellationToken cancellationToken = default) + { + var query = context.TenantAnnouncements.AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (type.HasValue) + { + query = query.Where(x => x.AnnouncementType == type.Value); + } + + if (isActive.HasValue) + { + query = query.Where(x => x.IsActive == isActive.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)t.Result, cancellationToken); + } + + public Task FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncements.AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken); + } + + public Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncements.AddAsync(announcement, cancellationToken).AsTask(); + } + + public Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default) + { + context.TenantAnnouncements.Update(announcement); + return Task.CompletedTask; + } + + public async Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) + { + var entity = await context.TenantAnnouncements.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken); + if (entity != null) + { + context.TenantAnnouncements.Remove(entity); + } + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs new file mode 100644 index 0000000..23acd1d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs @@ -0,0 +1,73 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// EF 租户账单仓储。 +/// +public sealed class EfTenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository +{ + public Task> SearchAsync( + long tenantId, + TenantBillingStatus? status, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default) + { + var query = context.TenantBillingStatements.AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + if (from.HasValue) + { + query = query.Where(x => x.PeriodStart >= from.Value); + } + + if (to.HasValue) + { + query = query.Where(x => x.PeriodEnd <= to.Value); + } + + return query + .OrderByDescending(x => x.PeriodEnd) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements.AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == billingId, cancellationToken); + } + + public Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements.AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StatementNo == statementNo, cancellationToken); + } + + public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask(); + } + + public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) + { + context.TenantBillingStatements.Update(bill); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs new file mode 100644 index 0000000..1265c25 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs @@ -0,0 +1,73 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// EF 租户通知仓储。 +/// +public sealed class EfTenantNotificationRepository(TakeoutAppDbContext context) : ITenantNotificationRepository +{ + public Task> SearchAsync( + long tenantId, + TenantNotificationSeverity? severity, + bool? unreadOnly, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default) + { + var query = context.TenantNotifications.AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (severity.HasValue) + { + query = query.Where(x => x.Severity == severity.Value); + } + + if (unreadOnly == true) + { + query = query.Where(x => x.ReadAt == null); + } + + if (from.HasValue) + { + query = query.Where(x => x.SentAt >= from.Value); + } + + if (to.HasValue) + { + query = query.Where(x => x.SentAt <= to.Value); + } + + return query + .OrderByDescending(x => x.SentAt) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default) + { + return context.TenantNotifications + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == notificationId, cancellationToken); + } + + public Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default) + { + return context.TenantNotifications.AddAsync(notification, cancellationToken).AsTask(); + } + + public Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default) + { + context.TenantNotifications.Update(notification); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +}