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