diff --git a/Document/Completed/后端套餐管理.md b/Document/Completed/后端套餐管理.md
new file mode 100644
index 0000000..23c0496
--- /dev/null
+++ b/Document/Completed/后端套餐管理.md
@@ -0,0 +1,79 @@
+# TakeoutSaaS 开发 TODO(自动维护)
+
+> 说明:该文档用于记录本仓库待办与进度。我会在完成每个任务后更新标记状态,并尽量保持“一个功能点一个原子提交”。
+>
+> 状态标记:[x] 已完成 / [~] 部分完成 / [ ] 未开始
+
+## 一期:套餐管理 MVP(已落地 + 待收口)
+
+[x] 1. 套餐增删改:新建/编辑/复制套餐,支持草稿保存
+
+- [x] 新建套餐(表单:基础信息 + 定价 + 权益配额)
+- [x] 编辑套餐(同新增表单)
+- [x] 复制套餐(基于现有套餐快速创建新套餐)
+- [x] 删除套餐(软删)
+- [x] 草稿保存(草稿/发布状态、草稿发布、回滚草稿)
+- [x] 修复“保存草稿却变发布”根因(EF 默认值哨兵导致 insert 省略字段,已将发布状态默认值调整为草稿并补齐哨兵配置)
+
+[x] 2. 上架体系:上架/下架、是否对外可见、是否允许新租户购买
+
+- [x] 上架/下架(启用/禁用套餐,含二次确认与提示)
+- [x] 是否对外可见(展示与可售解耦)
+- [x] 是否允许新租户购买(可售开关与可见开关解耦)
+- [x] 修复“新增时开关 false 无法落库”根因(EF 默认值哨兵导致 insert 省略字段,已将哨兵改为 true)
+
+[~] 3. 价格与计费周期:月付/年付、阶梯价/按量计费
+
+- [x] 月付/年付价格(`monthlyPrice` / `yearlyPrice`)
+- [ ] 阶梯价/按量计费
+
+[x] 4. 权益/配额配置:功能开关 + 数值配额(门店/账号/存储/短信/配送单量/更多)
+
+- [x] 数值配额(门店数、账号数、存储、短信、配送单量)
+- [x] 功能策略(`featurePoliciesJson`:可视化编辑 + JSON 预览,保留未知字段)
+- [x] 商品/菜单上限
+- [x] API 调用次数
+- [x] 报表/导出权限
+- [x] 打印/小票能力
+- [x] 营销功能(优惠券/满减/会员/积分等开关)
+
+[~] 5. 展示配置:卖点文案、推荐标识、排序、标签、对比页字段
+
+- [x] 排序(`sortOrder`)
+- [~] 卖点/描述(`description`)
+- [x] 推荐标识(Recommended)
+- [x] 标签(推荐/性价比/旗舰)
+- [ ] (已移除)对比页展示字段配置(对比维度/顺序)
+- [ ] 自助入驻选套餐页展示推荐/标签(公共套餐列表接口返回 isRecommended/tags + 前端展示/置顶)
+
+[x] 6. 订阅关联视图:当前使用该套餐的租户数量、MRR/ARR 粗看、到期分布(运营常用)
+
+- [x] 订阅/租户数量:活跃订阅数、总订阅数、使用租户数
+- [x] 使用租户列表入口:抽屉列表(支持分页与搜索)
+- [x] MRR/ARR 粗看
+- [x] 到期分布(7/15/30 天到期租户数、到期列表入口)
+
+[x] 7. 后端接口补齐(套餐使用统计/使用租户分页查询等)
+
+[x] 8. 前端联调与回归(包含权限、空态、错误提示)
+
+## 二期:上架与配置完善(建议)
+
+- [x] 1. 草稿保存与发布流程(草稿/发布、回滚到草稿)
+- [x] 2. 可见性/可售开关拆分(对外可见、允许新租户购买、已订阅不受影响说明)
+- [x] 4. 权益/配额可视化编辑器(JSON 结构化编辑、Schema 校验、预设模板)
+- [x] 5. 更多常用配额字段补齐(商品/菜单、API 次数、导出/报表、打印等)
+
+## 三期:计费与权益策略(建议)
+
+- [ ] 1. 阶梯价/按量计费(超配计费、账单明细)
+- [ ] 2. 权益变更影响说明:升配/降配规则(立即/次周期)、影响范围提示
+- [ ] 3. 超配策略(禁止/只读/按量计费/宽限期)
+
+## 四期:商业化与合规增强(建议)
+
+- [ ] 1. 附加计费(Add-ons):短信包、存储包、额外门店/账号包、配送单量包(可叠加、单独定价)
+- [ ] 2. 版本与历史:套餐版本号、变更记录、回滚、对已订阅租户的影响范围提示
+- [ ] 3. 购买限制:可购买地区/行业、仅邀请可见、最大购买数量、是否允许叠加订阅
+- [ ] 4. 订阅关联视图增强:MRR/ARR、到期分布、续费转化漏斗(运营常用)
+- [ ] 5. 操作审计:谁改了套餐、何时改、改了什么(合规必备)
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs
new file mode 100644
index 0000000..97d43df
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs
@@ -0,0 +1,121 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Billings.Commands;
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Application.App.Billings.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}/bills")]
+public sealed class BillingsController(IMediator mediator) : BaseApiController
+{
+ ///
+ /// 分页查询账单列表。
+ ///
+ /// 账单分页结果。
+ [HttpGet]
+ [PermissionAuthorize("bill:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetList([FromQuery] GetBillListQuery query, CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(query, cancellationToken);
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 获取账单详情。
+ ///
+ /// 账单 ID。
+ /// 取消标记。
+ /// 账单详情。
+ [HttpGet("{id:long}")]
+ [PermissionAuthorize("bill:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> GetDetail(long id, CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new GetBillDetailQuery { BillId = id }, cancellationToken);
+
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 手动创建账单。
+ ///
+ /// 创建账单命令。
+ /// 取消标记。
+ /// 创建的账单信息。
+ [HttpPost]
+ [PermissionAuthorize("bill:create")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Create([FromBody, Required] CreateBillCommand command, CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 更新账单状态。
+ ///
+ /// 账单 ID。
+ /// 更新状态命令。
+ /// 取消标记。
+ /// 更新后的账单信息。
+ [HttpPut("{id:long}/status")]
+ [PermissionAuthorize("bill:update")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> UpdateStatus(long id, [FromBody, Required] UpdateBillStatusCommand command, CancellationToken cancellationToken)
+ {
+ command = command with { BillId = id };
+ var result = await mediator.Send(command, cancellationToken);
+
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 获取账单支付记录。
+ ///
+ /// 账单 ID。
+ /// 取消标记。
+ /// 支付记录列表。
+ [HttpGet("{billId:long}/payments")]
+ [PermissionAuthorize("bill:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetPayments(long billId, CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new GetTenantPaymentsQuery { BillId = billId }, cancellationToken);
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 记录支付(线下支付确认)。
+ ///
+ /// 账单 ID。
+ /// 记录支付命令。
+ /// 取消标记。
+ /// 支付记录信息。
+ [HttpPost("{billId:long}/payments")]
+ [PermissionAuthorize("bill:pay")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> RecordPayment(long billId, [FromBody, Required] RecordPaymentCommand command, CancellationToken cancellationToken)
+ {
+ command = command with { BillId = billId };
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+}
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/QuotaPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/QuotaPackagesController.cs
new file mode 100644
index 0000000..b6a3ee2
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/QuotaPackagesController.cs
@@ -0,0 +1,198 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.QuotaPackages.Commands;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Application.App.QuotaPackages.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}/quota-packages")]
+public sealed class QuotaPackagesController(IMediator mediator) : BaseApiController
+{
+ ///
+ /// 配额包列表。
+ ///
+ /// 查询条件。
+ /// 取消标记。
+ /// 配额包分页结果。
+ [HttpGet]
+ [PermissionAuthorize("quota-package:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> List([FromQuery] GetQuotaPackageListQuery query, CancellationToken cancellationToken)
+ {
+ // 1. 查询配额包分页
+ var result = await mediator.Send(query, cancellationToken);
+
+ // 2. 返回结果
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 创建配额包。
+ ///
+ /// 创建命令。
+ /// 取消标记。
+ /// 创建后的配额包。
+ [HttpPost]
+ [PermissionAuthorize("quota-package:create")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Create([FromBody, Required] CreateQuotaPackageCommand command, CancellationToken cancellationToken)
+ {
+ // 1. 执行创建
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 2. 返回创建结果
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 更新配额包。
+ ///
+ /// 配额包 ID。
+ /// 更新命令。
+ /// 取消标记。
+ /// 更新后的配额包或未找到。
+ [HttpPut("{quotaPackageId:long}")]
+ [PermissionAuthorize("quota-package:update")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> Update(long quotaPackageId, [FromBody, Required] UpdateQuotaPackageCommand command, CancellationToken cancellationToken)
+ {
+ // 1. 绑定路由 ID
+ command = command with { QuotaPackageId = quotaPackageId };
+
+ // 2. 执行更新
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回更新结果或 404
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "配额包不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 删除配额包。
+ ///
+ /// 配额包 ID。
+ /// 取消标记。
+ /// 删除结果。
+ [HttpDelete("{quotaPackageId:long}")]
+ [PermissionAuthorize("quota-package:delete")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Delete(long quotaPackageId, CancellationToken cancellationToken)
+ {
+ // 1. 构建删除命令
+ var command = new DeleteQuotaPackageCommand { QuotaPackageId = quotaPackageId };
+
+ // 2. 执行删除并返回
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 上架/下架配额包。
+ ///
+ /// 配额包 ID。
+ /// 状态更新命令。
+ /// 取消标记。
+ /// 更新结果。
+ [HttpPut("{quotaPackageId:long}/status")]
+ [PermissionAuthorize("quota-package:update")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> UpdateStatus(long quotaPackageId, [FromBody, Required] UpdateQuotaPackageStatusCommand command, CancellationToken cancellationToken)
+ {
+ // 1. 绑定路由 ID
+ command = command with { QuotaPackageId = quotaPackageId };
+
+ // 2. 执行状态更新
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回结果
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 为租户购买配额包。
+ ///
+ /// 租户 ID。
+ /// 购买命令。
+ /// 取消标记。
+ /// 购买记录。
+ [HttpPost("~/api/admin/v{version:apiVersion}/tenants/{tenantId:long}/quota-packages")]
+ [PermissionAuthorize("tenant:quota:purchase")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> PurchaseForTenant(
+ long tenantId,
+ [FromBody, Required] PurchaseQuotaPackageCommand command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 绑定租户 ID
+ command = command with { TenantId = tenantId };
+
+ // 2. 执行购买
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回购买结果
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 租户配额使用情况。
+ ///
+ /// 租户 ID。
+ /// 查询条件。
+ /// 取消标记。
+ /// 配额使用情况列表。
+ [HttpGet("~/api/admin/v{version:apiVersion}/tenants/{tenantId:long}/quota-usage")]
+ [PermissionAuthorize("tenant:quota:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetTenantQuotaUsage(
+ long tenantId,
+ [FromQuery] GetTenantQuotaUsageQuery query,
+ CancellationToken cancellationToken)
+ {
+ // 1. 绑定租户 ID
+ query = query with { TenantId = tenantId };
+
+ // 2. 查询配额使用情况
+ var result = await mediator.Send(query, cancellationToken);
+
+ // 3. 返回结果
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 租户配额购买记录。
+ ///
+ /// 租户 ID。
+ /// 查询条件。
+ /// 取消标记。
+ /// 购买记录分页结果。
+ [HttpGet("~/api/admin/v{version:apiVersion}/tenants/{tenantId:long}/quota-purchases")]
+ [PermissionAuthorize("tenant:quota:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetTenantQuotaPurchases(
+ long tenantId,
+ [FromQuery] GetTenantQuotaPurchasesQuery query,
+ CancellationToken cancellationToken)
+ {
+ // 1. 绑定租户 ID
+ query = query with { TenantId = tenantId };
+
+ // 2. 查询购买记录
+ var result = await mediator.Send(query, cancellationToken);
+
+ // 3. 返回结果
+ return ApiResponse>.Ok(result);
+ }
+}
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StatisticsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StatisticsController.cs
new file mode 100644
index 0000000..cf4dd7f
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StatisticsController.cs
@@ -0,0 +1,96 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Statistics.Dto;
+using TakeoutSaaS.Application.App.Statistics.Queries;
+using TakeoutSaaS.Domain.Tenants.Enums;
+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}/statistics")]
+public sealed class StatisticsController(IMediator mediator) : BaseApiController
+{
+ ///
+ /// 获取订阅概览统计。
+ ///
+ /// 取消标记。
+ /// 订阅概览数据。
+ [HttpGet("subscription-overview")]
+ [PermissionAuthorize("statistics:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> GetSubscriptionOverview(CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(new GetSubscriptionOverviewQuery(), cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 获取配额使用排行。
+ ///
+ /// 配额类型。
+ /// 返回前N条记录,默认10。
+ /// 取消标记。
+ /// 配额使用排行数据。
+ [HttpGet("quota-ranking")]
+ [PermissionAuthorize("statistics:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> GetQuotaRanking(
+ [FromQuery] TenantQuotaType quotaType,
+ [FromQuery] int topN = 10,
+ CancellationToken cancellationToken = default)
+ {
+ var query = new GetQuotaUsageRankingQuery { QuotaType = quotaType, TopN = topN };
+ var result = await mediator.Send(query, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 获取收入统计。
+ ///
+ /// 统计月份数量,默认12个月。
+ /// 取消标记。
+ /// 收入统计数据。
+ [HttpGet("revenue")]
+ [PermissionAuthorize("statistics:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> GetRevenue(
+ [FromQuery] int monthsCount = 12,
+ CancellationToken cancellationToken = default)
+ {
+ var query = new GetRevenueStatisticsQuery { MonthsCount = monthsCount };
+ var result = await mediator.Send(query, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 获取即将到期的订阅列表。
+ ///
+ /// 筛选天数,默认7天内到期。
+ /// 是否只返回未开启自动续费的订阅。
+ /// 取消标记。
+ /// 即将到期的订阅列表。
+ [HttpGet("expiring-subscriptions")]
+ [PermissionAuthorize("statistics:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> GetExpiringSubscriptions(
+ [FromQuery] int daysAhead = 7,
+ [FromQuery] bool onlyWithoutAutoRenew = false,
+ CancellationToken cancellationToken = default)
+ {
+ var query = new GetExpiringSubscriptionsQuery
+ {
+ DaysAhead = daysAhead,
+ OnlyWithoutAutoRenew = onlyWithoutAutoRenew
+ };
+ var result = await mediator.Send(query, cancellationToken);
+ return ApiResponse>.Ok(result);
+ }
+}
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/SubscriptionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/SubscriptionsController.cs
new file mode 100644
index 0000000..75336d1
--- /dev/null
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/SubscriptionsController.cs
@@ -0,0 +1,210 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Subscriptions.Commands;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+using TakeoutSaaS.Application.App.Subscriptions.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}/subscriptions")]
+public sealed class SubscriptionsController(IMediator mediator) : BaseApiController
+{
+ ///
+ /// 分页查询订阅列表(支持按状态、套餐、到期时间筛选)。
+ ///
+ /// 查询条件。
+ /// 取消标记。
+ /// 订阅分页结果。
+ [HttpGet]
+ [PermissionAuthorize("subscription:read")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] GetSubscriptionListQuery query,
+ CancellationToken cancellationToken)
+ {
+ // 1. 查询订阅分页
+ var result = await mediator.Send(query, cancellationToken);
+
+ // 2. 返回结果
+ return ApiResponse>.Ok(result);
+ }
+
+ ///
+ /// 查看订阅详情(含套餐信息、配额使用、变更历史)。
+ ///
+ /// 订阅 ID。
+ /// 取消标记。
+ /// 订阅详情或未找到。
+ [HttpGet("{subscriptionId:long}")]
+ [PermissionAuthorize("subscription:read")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> Detail(
+ long subscriptionId,
+ CancellationToken cancellationToken)
+ {
+ // 1. 查询订阅详情
+ var result = await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscriptionId }, cancellationToken);
+
+ // 2. 返回查询结果或 404
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "订阅不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 更新订阅基础信息(备注、自动续费等)。
+ ///
+ /// 订阅 ID。
+ /// 更新命令。
+ /// 取消标记。
+ /// 更新后的订阅详情或未找到。
+ [HttpPut("{subscriptionId:long}")]
+ [PermissionAuthorize("subscription:update")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> Update(
+ long subscriptionId,
+ [FromBody, Required] UpdateSubscriptionCommand command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 绑定路由 ID
+ command = command with { SubscriptionId = subscriptionId };
+
+ // 2. 执行更新
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回更新结果或 404
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "订阅不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 延期订阅(增加订阅时长)。
+ ///
+ /// 订阅 ID。
+ /// 延期命令。
+ /// 取消标记。
+ /// 延期后的订阅详情或未找到。
+ [HttpPost("{subscriptionId:long}/extend")]
+ [PermissionAuthorize("subscription:extend")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> Extend(
+ long subscriptionId,
+ [FromBody, Required] ExtendSubscriptionCommand command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 绑定路由 ID
+ command = command with { SubscriptionId = subscriptionId };
+
+ // 2. 执行延期
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回延期结果或 404
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "订阅不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 变更套餐(支持立即生效或下周期生效)。
+ ///
+ /// 订阅 ID。
+ /// 变更套餐命令。
+ /// 取消标记。
+ /// 变更后的订阅详情或未找到。
+ [HttpPost("{subscriptionId:long}/change-plan")]
+ [PermissionAuthorize("subscription:change-plan")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> ChangePlan(
+ long subscriptionId,
+ [FromBody, Required] ChangeSubscriptionPlanCommand command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 绑定路由 ID
+ command = command with { SubscriptionId = subscriptionId };
+
+ // 2. 执行套餐变更
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回变更结果或 404
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "订阅不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 变更订阅状态。
+ ///
+ /// 订阅 ID。
+ /// 状态变更命令。
+ /// 取消标记。
+ /// 变更后的订阅详情或未找到。
+ [HttpPost("{subscriptionId:long}/status")]
+ [PermissionAuthorize("subscription:update-status")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
+ public async Task> UpdateStatus(
+ long subscriptionId,
+ [FromBody, Required] UpdateSubscriptionStatusCommand command,
+ CancellationToken cancellationToken)
+ {
+ // 1. 绑定路由 ID
+ command = command with { SubscriptionId = subscriptionId };
+
+ // 2. 执行状态变更
+ var result = await mediator.Send(command, cancellationToken);
+
+ // 3. 返回变更结果或 404
+ return result is null
+ ? ApiResponse.Error(StatusCodes.Status404NotFound, "订阅不存在")
+ : ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 批量延期订阅。
+ ///
+ /// 批量延期命令。
+ /// 取消标记。
+ /// 批量延期结果。
+ [HttpPost("batch-extend")]
+ [PermissionAuthorize("subscription:batch-extend")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> BatchExtend(
+ [FromBody, Required] BatchExtendSubscriptionsCommand command,
+ CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+
+ ///
+ /// 批量发送续费提醒。
+ ///
+ /// 批量发送提醒命令。
+ /// 取消标记。
+ /// 批量发送提醒结果。
+ [HttpPost("batch-remind")]
+ [PermissionAuthorize("subscription:batch-remind")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> BatchRemind(
+ [FromBody, Required] BatchSendReminderCommand command,
+ CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(command, cancellationToken);
+ return ApiResponse.Ok(result);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/BillingMapping.cs b/src/Application/TakeoutSaaS.Application/App/Billings/BillingMapping.cs
new file mode 100644
index 0000000..6a8c797
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/BillingMapping.cs
@@ -0,0 +1,80 @@
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+
+namespace TakeoutSaaS.Application.App.Billings;
+
+///
+/// 账单 DTO 映射助手。
+///
+internal static class BillingMapping
+{
+ ///
+ /// 将账单实体映射为账单 DTO。
+ ///
+ /// 账单实体。
+ /// 租户名称。
+ /// 账单 DTO。
+ public static BillDto ToDto(this TenantBillingStatement bill, string? tenantName = null)
+ => new()
+ {
+ Id = bill.Id,
+ TenantId = bill.TenantId,
+ TenantName = tenantName,
+ StatementNo = bill.StatementNo,
+ PeriodStart = bill.PeriodStart,
+ PeriodEnd = bill.PeriodEnd,
+ AmountDue = bill.AmountDue,
+ AmountPaid = bill.AmountPaid,
+ Status = bill.Status,
+ DueDate = bill.DueDate,
+ CreatedAt = bill.CreatedAt
+ };
+
+ ///
+ /// 将账单实体与支付记录映射为账单详情 DTO。
+ ///
+ /// 账单实体。
+ /// 支付记录列表。
+ /// 租户名称。
+ /// 账单详情 DTO。
+ public static BillDetailDto ToDetailDto(
+ this TenantBillingStatement bill,
+ List payments,
+ string? tenantName = null)
+ => new()
+ {
+ Id = bill.Id,
+ TenantId = bill.TenantId,
+ TenantName = tenantName,
+ StatementNo = bill.StatementNo,
+ PeriodStart = bill.PeriodStart,
+ PeriodEnd = bill.PeriodEnd,
+ AmountDue = bill.AmountDue,
+ AmountPaid = bill.AmountPaid,
+ Status = bill.Status,
+ DueDate = bill.DueDate,
+ LineItemsJson = bill.LineItemsJson,
+ CreatedAt = bill.CreatedAt,
+ Payments = payments.Select(p => p.ToDto()).ToList()
+ };
+
+ ///
+ /// 将支付记录实体映射为支付 DTO。
+ ///
+ /// 支付记录实体。
+ /// 支付 DTO。
+ public static PaymentDto ToDto(this TenantPayment payment)
+ => new()
+ {
+ Id = payment.Id,
+ BillingStatementId = payment.BillingStatementId,
+ Amount = payment.Amount,
+ Method = payment.Method,
+ Status = payment.Status,
+ TransactionNo = payment.TransactionNo,
+ ProofUrl = payment.ProofUrl,
+ PaidAt = payment.PaidAt,
+ Notes = payment.Notes,
+ CreatedAt = payment.CreatedAt
+ };
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/CreateBillCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/CreateBillCommand.cs
new file mode 100644
index 0000000..d22ecde
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/CreateBillCommand.cs
@@ -0,0 +1,30 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Dto;
+
+namespace TakeoutSaaS.Application.App.Billings.Commands;
+
+///
+/// 创建账单命令。
+///
+public sealed record CreateBillCommand : IRequest
+{
+ ///
+ /// 租户 ID(雪花算法)。
+ ///
+ public long TenantId { get; init; }
+
+ ///
+ /// 应付金额。
+ ///
+ public decimal AmountDue { get; init; }
+
+ ///
+ /// 到期日(UTC)。
+ ///
+ public DateTime DueDate { get; init; }
+
+ ///
+ /// 备注信息。
+ ///
+ public string? Notes { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/RecordPaymentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/RecordPaymentCommand.cs
new file mode 100644
index 0000000..0d81407
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/RecordPaymentCommand.cs
@@ -0,0 +1,41 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Application.App.Billings.Commands;
+
+///
+/// 记录支付命令。
+///
+public sealed record RecordPaymentCommand : IRequest
+{
+ ///
+ /// 账单 ID(雪花算法)。
+ ///
+ public long BillId { get; init; }
+
+ ///
+ /// 支付金额。
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// 支付方式。
+ ///
+ public PaymentMethod Method { get; init; }
+
+ ///
+ /// 交易号。
+ ///
+ public string? TransactionNo { get; init; }
+
+ ///
+ /// 支付凭证 URL。
+ ///
+ public string? ProofUrl { get; init; }
+
+ ///
+ /// 备注信息。
+ ///
+ public string? Notes { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/UpdateBillStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/UpdateBillStatusCommand.cs
new file mode 100644
index 0000000..fcbff76
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/UpdateBillStatusCommand.cs
@@ -0,0 +1,26 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Application.App.Billings.Commands;
+
+///
+/// 更新账单状态命令。
+///
+public sealed record UpdateBillStatusCommand : IRequest
+{
+ ///
+ /// 账单 ID(雪花算法)。
+ ///
+ public long BillId { get; init; }
+
+ ///
+ /// 新状态。
+ ///
+ public TenantBillingStatus Status { get; init; }
+
+ ///
+ /// 备注信息。
+ ///
+ public string? Notes { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillDetailDto.cs
new file mode 100644
index 0000000..36b6851
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillDetailDto.cs
@@ -0,0 +1,78 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Billings.Dto;
+
+///
+/// 账单详情 DTO。
+///
+public sealed record BillDetailDto
+{
+ ///
+ /// 账单 ID(雪花算法,序列化为字符串)。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 租户 ID(雪花算法,序列化为字符串)。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 租户名称。
+ ///
+ public string? TenantName { get; init; }
+
+ ///
+ /// 账单编号。
+ ///
+ public string StatementNo { get; init; } = string.Empty;
+
+ ///
+ /// 计费周期开始时间(UTC)。
+ ///
+ public DateTime PeriodStart { get; init; }
+
+ ///
+ /// 计费周期结束时间(UTC)。
+ ///
+ public DateTime PeriodEnd { get; init; }
+
+ ///
+ /// 应付金额。
+ ///
+ public decimal AmountDue { get; init; }
+
+ ///
+ /// 已付金额。
+ ///
+ public decimal AmountPaid { get; init; }
+
+ ///
+ /// 账单状态。
+ ///
+ public TenantBillingStatus Status { get; init; }
+
+ ///
+ /// 到期日(UTC)。
+ ///
+ public DateTime DueDate { get; init; }
+
+ ///
+ /// 账单明细 JSON。
+ ///
+ public string? LineItemsJson { get; init; }
+
+ ///
+ /// 创建时间(UTC)。
+ ///
+ public DateTime CreatedAt { get; init; }
+
+ ///
+ /// 支付记录列表。
+ ///
+ public List Payments { get; init; } = new();
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillDto.cs
new file mode 100644
index 0000000..dfc76d1
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/BillDto.cs
@@ -0,0 +1,68 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Billings.Dto;
+
+///
+/// 账单 DTO。
+///
+public sealed record BillDto
+{
+ ///
+ /// 账单 ID(雪花算法,序列化为字符串)。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 租户 ID(雪花算法,序列化为字符串)。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 租户名称。
+ ///
+ public string? TenantName { get; init; }
+
+ ///
+ /// 账单编号。
+ ///
+ public string StatementNo { get; init; } = string.Empty;
+
+ ///
+ /// 计费周期开始时间(UTC)。
+ ///
+ public DateTime PeriodStart { get; init; }
+
+ ///
+ /// 计费周期结束时间(UTC)。
+ ///
+ public DateTime PeriodEnd { get; init; }
+
+ ///
+ /// 应付金额。
+ ///
+ public decimal AmountDue { get; init; }
+
+ ///
+ /// 已付金额。
+ ///
+ public decimal AmountPaid { get; init; }
+
+ ///
+ /// 账单状态。
+ ///
+ public TenantBillingStatus Status { get; init; }
+
+ ///
+ /// 到期日(UTC)。
+ ///
+ public DateTime DueDate { get; init; }
+
+ ///
+ /// 创建时间(UTC)。
+ ///
+ public DateTime CreatedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentDto.cs
new file mode 100644
index 0000000..41e5784
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Dto/PaymentDto.cs
@@ -0,0 +1,63 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Billings.Dto;
+
+///
+/// 支付记录 DTO。
+///
+public sealed record PaymentDto
+{
+ ///
+ /// 支付记录 ID(雪花算法,序列化为字符串)。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 账单 ID(雪花算法,序列化为字符串)。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long BillingStatementId { get; init; }
+
+ ///
+ /// 支付金额。
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// 支付方式。
+ ///
+ public PaymentMethod Method { get; init; }
+
+ ///
+ /// 支付状态。
+ ///
+ public PaymentStatus Status { get; init; }
+
+ ///
+ /// 交易号。
+ ///
+ public string? TransactionNo { get; init; }
+
+ ///
+ /// 支付凭证 URL。
+ ///
+ public string? ProofUrl { get; init; }
+
+ ///
+ /// 支付时间。
+ ///
+ public DateTime? PaidAt { get; init; }
+
+ ///
+ /// 备注信息。
+ ///
+ public string? Notes { get; init; }
+
+ ///
+ /// 创建时间(UTC)。
+ ///
+ public DateTime CreatedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CreateBillCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CreateBillCommandHandler.cs
new file mode 100644
index 0000000..bbcf044
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/CreateBillCommandHandler.cs
@@ -0,0 +1,61 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Commands;
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.Billings.Handlers;
+
+///
+/// 创建账单处理器。
+///
+public sealed class CreateBillCommandHandler(
+ ITenantBillingRepository billingRepository,
+ ITenantRepository tenantRepository,
+ IIdGenerator idGenerator)
+ : IRequestHandler
+{
+ ///
+ /// 处理创建账单请求。
+ ///
+ /// 创建命令。
+ /// 取消标记。
+ /// 账单 DTO。
+ public async Task Handle(CreateBillCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 验证租户存在
+ var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
+ if (tenant is null)
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
+ }
+
+ // 2. 生成账单编号
+ var statementNo = $"BILL-{DateTime.UtcNow:yyyyMMdd}-{idGenerator.NextId()}";
+
+ // 3. 构建账单实体
+ var bill = new TenantBillingStatement
+ {
+ TenantId = request.TenantId,
+ StatementNo = statementNo,
+ PeriodStart = DateTime.UtcNow,
+ PeriodEnd = DateTime.UtcNow,
+ AmountDue = request.AmountDue,
+ AmountPaid = 0,
+ Status = TenantBillingStatus.Pending,
+ DueDate = request.DueDate,
+ LineItemsJson = request.Notes
+ };
+
+ // 4. 持久化账单
+ await billingRepository.AddAsync(bill, cancellationToken);
+ await billingRepository.SaveChangesAsync(cancellationToken);
+
+ // 5. 返回 DTO
+ return bill.ToDto(tenant.Name);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillDetailQueryHandler.cs
new file mode 100644
index 0000000..c368fac
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillDetailQueryHandler.cs
@@ -0,0 +1,41 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Application.App.Billings.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.Billings.Handlers;
+
+///
+/// 获取账单详情查询处理器。
+///
+public sealed class GetBillDetailQueryHandler(
+ ITenantBillingRepository billingRepository,
+ ITenantPaymentRepository paymentRepository,
+ ITenantRepository tenantRepository)
+ : IRequestHandler
+{
+ ///
+ /// 处理获取账单详情请求。
+ ///
+ /// 查询请求。
+ /// 取消标记。
+ /// 账单详情或 null。
+ public async Task Handle(GetBillDetailQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 查询账单
+ var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
+ if (bill is null)
+ {
+ return null;
+ }
+
+ // 2. 查询支付记录
+ var payments = await paymentRepository.GetByBillingIdAsync(request.BillId, cancellationToken);
+
+ // 3. 查询租户名称
+ var tenant = await tenantRepository.FindByIdAsync(bill.TenantId, cancellationToken);
+
+ // 4. 返回详情 DTO
+ return bill.ToDetailDto(payments.ToList(), tenant?.Name);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillListQueryHandler.cs
new file mode 100644
index 0000000..f63d55a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillListQueryHandler.cs
@@ -0,0 +1,53 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Application.App.Billings.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.Billings.Handlers;
+
+///
+/// 获取账单列表查询处理器。
+///
+public sealed class GetBillListQueryHandler(
+ ITenantBillingRepository billingRepository,
+ ITenantRepository tenantRepository)
+ : IRequestHandler>
+{
+ ///
+ /// 处理获取账单列表请求。
+ ///
+ /// 查询请求。
+ /// 取消标记。
+ /// 分页账单列表。
+ public async Task> Handle(GetBillListQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 分页查询账单
+ var (bills, total) = await billingRepository.SearchPagedAsync(
+ request.TenantId,
+ request.Status,
+ request.StartDate,
+ request.EndDate,
+ request.Keyword,
+ request.PageNumber,
+ request.PageSize,
+ cancellationToken);
+
+ // 2. 无数据直接返回
+ if (bills.Count == 0)
+ {
+ return new PagedResult([], request.PageNumber, request.PageSize, total);
+ }
+
+ // 3. 批量查询租户信息
+ var tenantIds = bills.Select(b => b.TenantId).Distinct().ToArray();
+ var tenants = await tenantRepository.FindByIdsAsync(tenantIds, cancellationToken);
+ var tenantDict = tenants.ToDictionary(t => t.Id, t => t.Name);
+
+ // 4. 映射 DTO
+ var result = bills.Select(b => b.ToDto(tenantDict.GetValueOrDefault(b.TenantId))).ToList();
+
+ // 5. 返回分页结果
+ return new PagedResult(result, request.PageNumber, request.PageSize, total);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetTenantPaymentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetTenantPaymentsQueryHandler.cs
new file mode 100644
index 0000000..80d1538
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetTenantPaymentsQueryHandler.cs
@@ -0,0 +1,28 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Application.App.Billings.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.Billings.Handlers;
+
+///
+/// 获取租户支付记录查询处理器。
+///
+public sealed class GetTenantPaymentsQueryHandler(ITenantPaymentRepository paymentRepository)
+ : IRequestHandler>
+{
+ ///
+ /// 处理获取支付记录请求。
+ ///
+ /// 查询请求。
+ /// 取消标记。
+ /// 支付记录列表。
+ public async Task> Handle(GetTenantPaymentsQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 查询支付记录
+ var payments = await paymentRepository.GetByBillingIdAsync(request.BillId, cancellationToken);
+
+ // 2. 映射并返回 DTO
+ return payments.Select(p => p.ToDto()).ToList();
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/RecordPaymentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/RecordPaymentCommandHandler.cs
new file mode 100644
index 0000000..c993631
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/RecordPaymentCommandHandler.cs
@@ -0,0 +1,66 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Commands;
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+
+namespace TakeoutSaaS.Application.App.Billings.Handlers;
+
+///
+/// 记录支付处理器。
+///
+public sealed class RecordPaymentCommandHandler(
+ ITenantBillingRepository billingRepository,
+ ITenantPaymentRepository paymentRepository)
+ : IRequestHandler
+{
+ ///
+ /// 处理记录支付请求。
+ ///
+ /// 记录支付命令。
+ /// 取消标记。
+ /// 支付 DTO。
+ public async Task Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 查询账单
+ var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
+ if (bill is null)
+ {
+ throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
+ }
+
+ // 2. 构建支付记录
+ var payment = new TenantPayment
+ {
+ TenantId = bill.TenantId,
+ BillingStatementId = request.BillId,
+ Amount = request.Amount,
+ Method = request.Method,
+ Status = PaymentStatus.Success,
+ TransactionNo = request.TransactionNo,
+ ProofUrl = request.ProofUrl,
+ PaidAt = DateTime.UtcNow,
+ Notes = request.Notes
+ };
+
+ // 3. 更新账单已付金额
+ bill.AmountPaid += request.Amount;
+
+ // 4. 如果已付金额 >= 应付金额,标记为已支付
+ if (bill.AmountPaid >= bill.AmountDue)
+ {
+ bill.Status = TenantBillingStatus.Paid;
+ }
+
+ // 5. 持久化变更
+ await paymentRepository.AddAsync(payment, cancellationToken);
+ await billingRepository.UpdateAsync(bill, cancellationToken);
+ await paymentRepository.SaveChangesAsync(cancellationToken);
+
+ // 6. 返回 DTO
+ return payment.ToDto();
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/UpdateBillStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/UpdateBillStatusCommandHandler.cs
new file mode 100644
index 0000000..9a6f33b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/UpdateBillStatusCommandHandler.cs
@@ -0,0 +1,50 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Commands;
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+
+namespace TakeoutSaaS.Application.App.Billings.Handlers;
+
+///
+/// 更新账单状态处理器。
+///
+public sealed class UpdateBillStatusCommandHandler(
+ ITenantBillingRepository billingRepository,
+ ITenantRepository tenantRepository)
+ : IRequestHandler
+{
+ ///
+ /// 处理更新账单状态请求。
+ ///
+ /// 更新命令。
+ /// 取消标记。
+ /// 账单 DTO 或 null。
+ public async Task Handle(UpdateBillStatusCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 查询账单
+ var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
+ if (bill is null)
+ {
+ return null;
+ }
+
+ // 2. 更新状态
+ bill.Status = request.Status;
+ if (!string.IsNullOrWhiteSpace(request.Notes))
+ {
+ bill.LineItemsJson = request.Notes;
+ }
+
+ // 3. 持久化变更
+ await billingRepository.UpdateAsync(bill, cancellationToken);
+ await billingRepository.SaveChangesAsync(cancellationToken);
+
+ // 4. 查询租户名称
+ var tenant = await tenantRepository.FindByIdAsync(bill.TenantId, cancellationToken);
+
+ // 5. 返回 DTO
+ return bill.ToDto(tenant?.Name);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillDetailQuery.cs
new file mode 100644
index 0000000..d155acd
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillDetailQuery.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Dto;
+
+namespace TakeoutSaaS.Application.App.Billings.Queries;
+
+///
+/// 获取账单详情查询。
+///
+public sealed record GetBillDetailQuery : IRequest
+{
+ ///
+ /// 账单 ID(雪花算法)。
+ ///
+ public long BillId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillListQuery.cs
new file mode 100644
index 0000000..903c333
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillListQuery.cs
@@ -0,0 +1,47 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.Billings.Queries;
+
+///
+/// 获取账单列表查询。
+///
+public sealed record GetBillListQuery : IRequest>
+{
+ ///
+ /// 页码(从 1 开始)。
+ ///
+ public int PageNumber { get; init; } = 1;
+
+ ///
+ /// 页大小。
+ ///
+ public int PageSize { get; init; } = 20;
+
+ ///
+ /// 租户 ID 筛选(可选)。
+ ///
+ public long? TenantId { get; init; }
+
+ ///
+ /// 状态筛选(可选)。
+ ///
+ public TenantBillingStatus? Status { get; init; }
+
+ ///
+ /// 开始日期筛选(可选)。
+ ///
+ public DateTime? StartDate { get; init; }
+
+ ///
+ /// 结束日期筛选(可选)。
+ ///
+ public DateTime? EndDate { get; init; }
+
+ ///
+ /// 搜索关键词(账单号或租户名)。
+ ///
+ public string? Keyword { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetTenantPaymentsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetTenantPaymentsQuery.cs
new file mode 100644
index 0000000..986ca41
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetTenantPaymentsQuery.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Billings.Dto;
+
+namespace TakeoutSaaS.Application.App.Billings.Queries;
+
+///
+/// 获取租户支付记录查询。
+///
+public sealed record GetTenantPaymentsQuery : IRequest>
+{
+ ///
+ /// 账单 ID(雪花算法)。
+ ///
+ public long BillId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/CreateQuotaPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/CreateQuotaPackageCommand.cs
new file mode 100644
index 0000000..7c8de9e
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/CreateQuotaPackageCommand.cs
@@ -0,0 +1,46 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
+
+///
+/// 创建配额包命令。
+///
+public sealed record CreateQuotaPackageCommand : IRequest
+{
+ ///
+ /// 配额包名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 配额类型。
+ ///
+ public TenantQuotaType QuotaType { get; init; }
+
+ ///
+ /// 配额数值。
+ ///
+ public decimal QuotaValue { get; init; }
+
+ ///
+ /// 价格。
+ ///
+ public decimal Price { get; init; }
+
+ ///
+ /// 是否上架。
+ ///
+ public bool IsActive { get; init; } = true;
+
+ ///
+ /// 排序。
+ ///
+ public int SortOrder { get; init; } = 0;
+
+ ///
+ /// 描述。
+ ///
+ public string? Description { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/DeleteQuotaPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/DeleteQuotaPackageCommand.cs
new file mode 100644
index 0000000..62c8f3f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/DeleteQuotaPackageCommand.cs
@@ -0,0 +1,14 @@
+using MediatR;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
+
+///
+/// 删除配额包命令。
+///
+public sealed record DeleteQuotaPackageCommand : IRequest
+{
+ ///
+ /// 配额包 ID。
+ ///
+ public long QuotaPackageId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/PurchaseQuotaPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/PurchaseQuotaPackageCommand.cs
new file mode 100644
index 0000000..3485036
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/PurchaseQuotaPackageCommand.cs
@@ -0,0 +1,30 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
+
+///
+/// 为租户购买配额包命令。
+///
+public sealed record PurchaseQuotaPackageCommand : IRequest
+{
+ ///
+ /// 租户 ID。
+ ///
+ public long TenantId { get; init; }
+
+ ///
+ /// 配额包 ID。
+ ///
+ public long QuotaPackageId { get; init; }
+
+ ///
+ /// 过期时间(可选)。
+ ///
+ public DateTime? ExpiredAt { get; init; }
+
+ ///
+ /// 备注。
+ ///
+ public string? Notes { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/UpdateQuotaPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/UpdateQuotaPackageCommand.cs
new file mode 100644
index 0000000..f6f43c3
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/UpdateQuotaPackageCommand.cs
@@ -0,0 +1,51 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
+
+///
+/// 更新配额包命令。
+///
+public sealed record UpdateQuotaPackageCommand : IRequest
+{
+ ///
+ /// 配额包 ID。
+ ///
+ public long QuotaPackageId { get; init; }
+
+ ///
+ /// 配额包名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 配额类型。
+ ///
+ public TenantQuotaType QuotaType { get; init; }
+
+ ///
+ /// 配额数值。
+ ///
+ public decimal QuotaValue { get; init; }
+
+ ///
+ /// 价格。
+ ///
+ public decimal Price { get; init; }
+
+ ///
+ /// 是否上架。
+ ///
+ public bool IsActive { get; init; } = true;
+
+ ///
+ /// 排序。
+ ///
+ public int SortOrder { get; init; } = 0;
+
+ ///
+ /// 描述。
+ ///
+ public string? Description { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/UpdateQuotaPackageStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/UpdateQuotaPackageStatusCommand.cs
new file mode 100644
index 0000000..4a0fdb0
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Commands/UpdateQuotaPackageStatusCommand.cs
@@ -0,0 +1,19 @@
+using MediatR;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
+
+///
+/// 更新配额包状态命令(上架/下架)。
+///
+public sealed record UpdateQuotaPackageStatusCommand : IRequest
+{
+ ///
+ /// 配额包 ID。
+ ///
+ public long QuotaPackageId { get; init; }
+
+ ///
+ /// 是否上架。
+ ///
+ public bool IsActive { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/QuotaPackageDto.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/QuotaPackageDto.cs
new file mode 100644
index 0000000..f710e8c
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/QuotaPackageDto.cs
@@ -0,0 +1,62 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
+
+///
+/// 配额包 DTO。
+///
+public sealed record QuotaPackageDto
+{
+ ///
+ /// 配额包 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 配额包名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 配额类型。
+ ///
+ public TenantQuotaType QuotaType { get; init; }
+
+ ///
+ /// 配额数值。
+ ///
+ public decimal QuotaValue { get; init; }
+
+ ///
+ /// 价格。
+ ///
+ public decimal Price { get; init; }
+
+ ///
+ /// 是否上架。
+ ///
+ public bool IsActive { get; init; }
+
+ ///
+ /// 排序。
+ ///
+ public int SortOrder { get; init; }
+
+ ///
+ /// 描述。
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// 创建时间。
+ ///
+ public DateTime CreatedAt { get; init; }
+
+ ///
+ /// 更新时间。
+ ///
+ public DateTime? UpdatedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/QuotaPackageListDto.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/QuotaPackageListDto.cs
new file mode 100644
index 0000000..9dd8fdf
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/QuotaPackageListDto.cs
@@ -0,0 +1,47 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
+
+///
+/// 配额包列表 DTO。
+///
+public sealed record QuotaPackageListDto
+{
+ ///
+ /// 配额包 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 配额包名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 配额类型。
+ ///
+ public TenantQuotaType QuotaType { get; init; }
+
+ ///
+ /// 配额数值。
+ ///
+ public decimal QuotaValue { get; init; }
+
+ ///
+ /// 价格。
+ ///
+ public decimal Price { get; init; }
+
+ ///
+ /// 是否上架。
+ ///
+ public bool IsActive { get; init; }
+
+ ///
+ /// 排序。
+ ///
+ public int SortOrder { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/TenantQuotaPurchaseDto.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/TenantQuotaPurchaseDto.cs
new file mode 100644
index 0000000..2ce1bb4
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/TenantQuotaPurchaseDto.cs
@@ -0,0 +1,64 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
+
+///
+/// 租户配额购买记录 DTO。
+///
+public sealed record TenantQuotaPurchaseDto
+{
+ ///
+ /// 购买记录 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 租户 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 配额包 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long QuotaPackageId { get; init; }
+
+ ///
+ /// 配额包名称。
+ ///
+ public string QuotaPackageName { get; init; } = string.Empty;
+
+ ///
+ /// 配额类型。
+ ///
+ public TenantQuotaType QuotaType { get; init; }
+
+ ///
+ /// 购买时的配额值。
+ ///
+ public decimal QuotaValue { get; init; }
+
+ ///
+ /// 购买价格。
+ ///
+ public decimal Price { get; init; }
+
+ ///
+ /// 购买时间。
+ ///
+ public DateTime PurchasedAt { get; init; }
+
+ ///
+ /// 过期时间(可选)。
+ ///
+ public DateTime? ExpiredAt { get; init; }
+
+ ///
+ /// 备注。
+ ///
+ public string? Notes { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/TenantQuotaUsageDto.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/TenantQuotaUsageDto.cs
new file mode 100644
index 0000000..a651fd8
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Dto/TenantQuotaUsageDto.cs
@@ -0,0 +1,47 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
+
+///
+/// 租户配额使用情况 DTO。
+///
+public sealed record TenantQuotaUsageDto
+{
+ ///
+ /// 租户 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 配额类型。
+ ///
+ public TenantQuotaType QuotaType { get; init; }
+
+ ///
+ /// 配额上限。
+ ///
+ public decimal LimitValue { get; init; }
+
+ ///
+ /// 已使用值。
+ ///
+ public decimal UsedValue { get; init; }
+
+ ///
+ /// 剩余值。
+ ///
+ public decimal RemainingValue { get; init; }
+
+ ///
+ /// 配额刷新周期。
+ ///
+ public string? ResetCycle { get; init; }
+
+ ///
+ /// 最近一次重置时间。
+ ///
+ public DateTime? LastResetAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/CreateQuotaPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/CreateQuotaPackageCommandHandler.cs
new file mode 100644
index 0000000..28c9b3a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/CreateQuotaPackageCommandHandler.cs
@@ -0,0 +1,54 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Commands;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
+
+///
+/// 创建配额包命令处理器。
+///
+public sealed class CreateQuotaPackageCommandHandler(
+ IQuotaPackageRepository quotaPackageRepository,
+ IIdGenerator idGenerator)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(CreateQuotaPackageCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 创建配额包实体
+ var quotaPackage = new QuotaPackage
+ {
+ Id = idGenerator.NextId(),
+ Name = request.Name,
+ QuotaType = request.QuotaType,
+ QuotaValue = request.QuotaValue,
+ Price = request.Price,
+ IsActive = request.IsActive,
+ SortOrder = request.SortOrder,
+ Description = request.Description,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ // 2. 保存到数据库
+ await quotaPackageRepository.AddAsync(quotaPackage, cancellationToken);
+ await quotaPackageRepository.SaveChangesAsync(cancellationToken);
+
+ // 3. 返回 DTO
+ return new QuotaPackageDto
+ {
+ Id = quotaPackage.Id,
+ Name = quotaPackage.Name,
+ QuotaType = quotaPackage.QuotaType,
+ QuotaValue = quotaPackage.QuotaValue,
+ Price = quotaPackage.Price,
+ IsActive = quotaPackage.IsActive,
+ SortOrder = quotaPackage.SortOrder,
+ Description = quotaPackage.Description,
+ CreatedAt = quotaPackage.CreatedAt,
+ UpdatedAt = quotaPackage.UpdatedAt
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/DeleteQuotaPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/DeleteQuotaPackageCommandHandler.cs
new file mode 100644
index 0000000..1180d7e
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/DeleteQuotaPackageCommandHandler.cs
@@ -0,0 +1,29 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Commands;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
+
+///
+/// 删除配额包命令处理器。
+///
+public sealed class DeleteQuotaPackageCommandHandler(IQuotaPackageRepository quotaPackageRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(DeleteQuotaPackageCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 软删除配额包
+ var deleted = await quotaPackageRepository.SoftDeleteAsync(request.QuotaPackageId, cancellationToken);
+
+ if (!deleted)
+ {
+ return false;
+ }
+
+ // 2. 保存变更
+ await quotaPackageRepository.SaveChangesAsync(cancellationToken);
+
+ return true;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/GetQuotaPackageListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/GetQuotaPackageListQueryHandler.cs
new file mode 100644
index 0000000..a61d883
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/GetQuotaPackageListQueryHandler.cs
@@ -0,0 +1,41 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Application.App.QuotaPackages.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
+
+///
+/// 获取配额包列表查询处理器。
+///
+public sealed class GetQuotaPackageListQueryHandler(IQuotaPackageRepository quotaPackageRepository)
+ : IRequestHandler>
+{
+ ///
+ public async Task> Handle(GetQuotaPackageListQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 分页查询
+ var (items, total) = await quotaPackageRepository.SearchPagedAsync(
+ request.QuotaType,
+ request.IsActive,
+ request.Page,
+ request.PageSize,
+ cancellationToken);
+
+ // 2. 映射为 DTO
+ var dtos = items.Select(x => new QuotaPackageListDto
+ {
+ Id = x.Id,
+ Name = x.Name,
+ QuotaType = x.QuotaType,
+ QuotaValue = x.QuotaValue,
+ Price = x.Price,
+ IsActive = x.IsActive,
+ SortOrder = x.SortOrder
+ }).ToList();
+
+ // 3. 返回分页结果
+ return new PagedResult(dtos, request.Page, request.PageSize, total);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/GetTenantQuotaPurchasesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/GetTenantQuotaPurchasesQueryHandler.cs
new file mode 100644
index 0000000..1b232d3
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/GetTenantQuotaPurchasesQueryHandler.cs
@@ -0,0 +1,43 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Application.App.QuotaPackages.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
+
+///
+/// 获取租户配额购买记录查询处理器。
+///
+public sealed class GetTenantQuotaPurchasesQueryHandler(IQuotaPackageRepository quotaPackageRepository)
+ : IRequestHandler>
+{
+ ///
+ public async Task> Handle(GetTenantQuotaPurchasesQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 分页查询购买记录
+ var (items, total) = await quotaPackageRepository.GetPurchasesPagedAsync(
+ request.TenantId,
+ request.Page,
+ request.PageSize,
+ cancellationToken);
+
+ // 2. 映射为 DTO
+ var dtos = items.Select(x => new TenantQuotaPurchaseDto
+ {
+ Id = x.Purchase.Id,
+ TenantId = x.Purchase.TenantId,
+ QuotaPackageId = x.Purchase.QuotaPackageId,
+ QuotaPackageName = x.Package.Name,
+ QuotaType = x.Package.QuotaType,
+ QuotaValue = x.Purchase.QuotaValue,
+ Price = x.Purchase.Price,
+ PurchasedAt = x.Purchase.PurchasedAt,
+ ExpiredAt = x.Purchase.ExpiredAt,
+ Notes = x.Purchase.Notes
+ }).ToList();
+
+ // 3. 返回分页结果
+ return new PagedResult(dtos, request.Page, request.PageSize, total);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/GetTenantQuotaUsageQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/GetTenantQuotaUsageQueryHandler.cs
new file mode 100644
index 0000000..ee246b6
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/GetTenantQuotaUsageQueryHandler.cs
@@ -0,0 +1,35 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Application.App.QuotaPackages.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
+
+///
+/// 获取租户配额使用情况查询处理器。
+///
+public sealed class GetTenantQuotaUsageQueryHandler(IQuotaPackageRepository quotaPackageRepository)
+ : IRequestHandler>
+{
+ ///
+ public async Task> Handle(GetTenantQuotaUsageQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 查询配额使用情况
+ var items = await quotaPackageRepository.GetUsageByTenantAsync(
+ request.TenantId,
+ request.QuotaType,
+ cancellationToken);
+
+ // 2. 映射为 DTO
+ return items.Select(x => new TenantQuotaUsageDto
+ {
+ TenantId = x.TenantId,
+ QuotaType = x.QuotaType,
+ LimitValue = x.LimitValue,
+ UsedValue = x.UsedValue,
+ RemainingValue = x.LimitValue - x.UsedValue,
+ ResetCycle = x.ResetCycle,
+ LastResetAt = x.LastResetAt
+ }).ToList();
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/PurchaseQuotaPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/PurchaseQuotaPackageCommandHandler.cs
new file mode 100644
index 0000000..28c4e37
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/PurchaseQuotaPackageCommandHandler.cs
@@ -0,0 +1,72 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Commands;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
+
+///
+/// 购买配额包命令处理器。
+///
+public sealed class PurchaseQuotaPackageCommandHandler(
+ IQuotaPackageRepository quotaPackageRepository,
+ IIdGenerator idGenerator)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(PurchaseQuotaPackageCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 查找配额包
+ var quotaPackage = await quotaPackageRepository.FindByIdAsync(request.QuotaPackageId, cancellationToken);
+
+ if (quotaPackage == null)
+ {
+ throw new InvalidOperationException("配额包不存在");
+ }
+
+ // 2. 创建购买记录
+ var purchase = new TenantQuotaPackagePurchase
+ {
+ Id = idGenerator.NextId(),
+ TenantId = request.TenantId,
+ QuotaPackageId = request.QuotaPackageId,
+ QuotaValue = quotaPackage.QuotaValue,
+ Price = quotaPackage.Price,
+ PurchasedAt = DateTime.UtcNow,
+ ExpiredAt = request.ExpiredAt,
+ Notes = request.Notes,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ // 3. 保存购买记录
+ await quotaPackageRepository.AddPurchaseAsync(purchase, cancellationToken);
+
+ // 4. 更新租户配额(根据配额类型更新对应配额)
+ var quotaUsage = await quotaPackageRepository.FindUsageAsync(request.TenantId, quotaPackage.QuotaType, cancellationToken);
+
+ if (quotaUsage != null)
+ {
+ quotaUsage.LimitValue += quotaPackage.QuotaValue;
+ await quotaPackageRepository.UpdateUsageAsync(quotaUsage, cancellationToken);
+ }
+
+ await quotaPackageRepository.SaveChangesAsync(cancellationToken);
+
+ // 5. 返回 DTO
+ return new TenantQuotaPurchaseDto
+ {
+ Id = purchase.Id,
+ TenantId = purchase.TenantId,
+ QuotaPackageId = purchase.QuotaPackageId,
+ QuotaPackageName = quotaPackage.Name,
+ QuotaType = quotaPackage.QuotaType,
+ QuotaValue = purchase.QuotaValue,
+ Price = purchase.Price,
+ PurchasedAt = purchase.PurchasedAt,
+ ExpiredAt = purchase.ExpiredAt,
+ Notes = purchase.Notes
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/UpdateQuotaPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/UpdateQuotaPackageCommandHandler.cs
new file mode 100644
index 0000000..5e57207
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/UpdateQuotaPackageCommandHandler.cs
@@ -0,0 +1,54 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Commands;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
+
+///
+/// 更新配额包命令处理器。
+///
+public sealed class UpdateQuotaPackageCommandHandler(IQuotaPackageRepository quotaPackageRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(UpdateQuotaPackageCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 查找配额包
+ var quotaPackage = await quotaPackageRepository.FindByIdAsync(request.QuotaPackageId, cancellationToken);
+
+ if (quotaPackage == null)
+ {
+ return null;
+ }
+
+ // 2. 更新配额包
+ quotaPackage.Name = request.Name;
+ quotaPackage.QuotaType = request.QuotaType;
+ quotaPackage.QuotaValue = request.QuotaValue;
+ quotaPackage.Price = request.Price;
+ quotaPackage.IsActive = request.IsActive;
+ quotaPackage.SortOrder = request.SortOrder;
+ quotaPackage.Description = request.Description;
+ quotaPackage.UpdatedAt = DateTime.UtcNow;
+
+ // 3. 保存到数据库
+ await quotaPackageRepository.UpdateAsync(quotaPackage, cancellationToken);
+ await quotaPackageRepository.SaveChangesAsync(cancellationToken);
+
+ // 4. 返回 DTO
+ return new QuotaPackageDto
+ {
+ Id = quotaPackage.Id,
+ Name = quotaPackage.Name,
+ QuotaType = quotaPackage.QuotaType,
+ QuotaValue = quotaPackage.QuotaValue,
+ Price = quotaPackage.Price,
+ IsActive = quotaPackage.IsActive,
+ SortOrder = quotaPackage.SortOrder,
+ Description = quotaPackage.Description,
+ CreatedAt = quotaPackage.CreatedAt,
+ UpdatedAt = quotaPackage.UpdatedAt
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/UpdateQuotaPackageStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/UpdateQuotaPackageStatusCommandHandler.cs
new file mode 100644
index 0000000..e5ea40b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Handlers/UpdateQuotaPackageStatusCommandHandler.cs
@@ -0,0 +1,34 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Commands;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
+
+///
+/// 更新配额包状态命令处理器。
+///
+public sealed class UpdateQuotaPackageStatusCommandHandler(IQuotaPackageRepository quotaPackageRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(UpdateQuotaPackageStatusCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 查找配额包
+ var quotaPackage = await quotaPackageRepository.FindByIdAsync(request.QuotaPackageId, cancellationToken);
+
+ if (quotaPackage == null)
+ {
+ return false;
+ }
+
+ // 2. 更新状态
+ quotaPackage.IsActive = request.IsActive;
+ quotaPackage.UpdatedAt = DateTime.UtcNow;
+
+ // 3. 保存到数据库
+ await quotaPackageRepository.UpdateAsync(quotaPackage, cancellationToken);
+ await quotaPackageRepository.SaveChangesAsync(cancellationToken);
+
+ return true;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Queries/GetQuotaPackageListQuery.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Queries/GetQuotaPackageListQuery.cs
new file mode 100644
index 0000000..538d1ef
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Queries/GetQuotaPackageListQuery.cs
@@ -0,0 +1,32 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
+
+///
+/// 获取配额包列表查询。
+///
+public sealed record GetQuotaPackageListQuery : IRequest>
+{
+ ///
+ /// 配额类型(可选筛选)。
+ ///
+ public TenantQuotaType? QuotaType { get; init; }
+
+ ///
+ /// 状态(可选筛选)。
+ ///
+ public bool? IsActive { get; init; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页大小。
+ ///
+ public int PageSize { get; init; } = 20;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Queries/GetTenantQuotaPurchasesQuery.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Queries/GetTenantQuotaPurchasesQuery.cs
new file mode 100644
index 0000000..56348e6
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Queries/GetTenantQuotaPurchasesQuery.cs
@@ -0,0 +1,26 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
+
+///
+/// 获取租户配额购买记录查询。
+///
+public sealed record GetTenantQuotaPurchasesQuery : IRequest>
+{
+ ///
+ /// 租户 ID。
+ ///
+ public long TenantId { get; init; }
+
+ ///
+ /// 页码。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页大小。
+ ///
+ public int PageSize { get; init; } = 20;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Queries/GetTenantQuotaUsageQuery.cs b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Queries/GetTenantQuotaUsageQuery.cs
new file mode 100644
index 0000000..f5b395c
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/QuotaPackages/Queries/GetTenantQuotaUsageQuery.cs
@@ -0,0 +1,21 @@
+using MediatR;
+using TakeoutSaaS.Application.App.QuotaPackages.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
+
+///
+/// 获取租户配额使用情况查询。
+///
+public sealed record GetTenantQuotaUsageQuery : IRequest>
+{
+ ///
+ /// 租户 ID。
+ ///
+ public long TenantId { get; init; }
+
+ ///
+ /// 配额类型(可选筛选)。
+ ///
+ public TenantQuotaType? QuotaType { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/ExpiringSubscriptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/ExpiringSubscriptionDto.cs
new file mode 100644
index 0000000..c9a6d30
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/ExpiringSubscriptionDto.cs
@@ -0,0 +1,49 @@
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Application.App.Statistics.Dto;
+
+///
+/// 即将到期的订阅项。
+///
+public record ExpiringSubscriptionDto
+{
+ ///
+ /// 订阅ID。
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// 租户ID。
+ ///
+ public string TenantId { get; init; } = string.Empty;
+
+ ///
+ /// 租户名称。
+ ///
+ public string TenantName { get; init; } = string.Empty;
+
+ ///
+ /// 套餐名称。
+ ///
+ public string PackageName { get; init; } = string.Empty;
+
+ ///
+ /// 订阅状态。
+ ///
+ public SubscriptionStatus Status { get; init; }
+
+ ///
+ /// 到期时间。
+ ///
+ public DateTime EffectiveTo { get; init; }
+
+ ///
+ /// 剩余天数。
+ ///
+ public int DaysRemaining { get; init; }
+
+ ///
+ /// 是否开启自动续费。
+ ///
+ public bool AutoRenew { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/QuotaUsageRankingDto.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/QuotaUsageRankingDto.cs
new file mode 100644
index 0000000..57f53c2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/QuotaUsageRankingDto.cs
@@ -0,0 +1,50 @@
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Application.App.Statistics.Dto;
+
+///
+/// 配额使用排行。
+///
+public record QuotaUsageRankingDto
+{
+ ///
+ /// 配额类型。
+ ///
+ public TenantQuotaType QuotaType { get; init; }
+
+ ///
+ /// 排行列表。
+ ///
+ public IReadOnlyList Rankings { get; init; } = Array.Empty();
+}
+
+///
+/// 配额使用排行项。
+///
+public record QuotaUsageRankItem
+{
+ ///
+ /// 租户ID。
+ ///
+ public string TenantId { get; init; } = string.Empty;
+
+ ///
+ /// 租户名称。
+ ///
+ public string TenantName { get; init; } = string.Empty;
+
+ ///
+ /// 已使用值。
+ ///
+ public decimal UsedValue { get; init; }
+
+ ///
+ /// 限制值。
+ ///
+ public decimal LimitValue { get; init; }
+
+ ///
+ /// 使用百分比。
+ ///
+ public decimal UsagePercentage { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/RevenueStatisticsDto.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/RevenueStatisticsDto.cs
new file mode 100644
index 0000000..ceb604f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/RevenueStatisticsDto.cs
@@ -0,0 +1,53 @@
+namespace TakeoutSaaS.Application.App.Statistics.Dto;
+
+///
+/// 收入统计。
+///
+public record RevenueStatisticsDto
+{
+ ///
+ /// 总收入。
+ ///
+ public decimal TotalRevenue { get; init; }
+
+ ///
+ /// 本月收入。
+ ///
+ public decimal MonthlyRevenue { get; init; }
+
+ ///
+ /// 本季度收入。
+ ///
+ public decimal QuarterlyRevenue { get; init; }
+
+ ///
+ /// 月度收入明细。
+ ///
+ public IReadOnlyList MonthlyDetails { get; init; } = Array.Empty();
+}
+
+///
+/// 月度收入项。
+///
+public record MonthlyRevenueItem
+{
+ ///
+ /// 年份。
+ ///
+ public int Year { get; init; }
+
+ ///
+ /// 月份。
+ ///
+ public int Month { get; init; }
+
+ ///
+ /// 收入金额。
+ ///
+ public decimal Amount { get; init; }
+
+ ///
+ /// 账单数量。
+ ///
+ public int BillCount { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/SubscriptionOverviewDto.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/SubscriptionOverviewDto.cs
new file mode 100644
index 0000000..9323faa
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Dto/SubscriptionOverviewDto.cs
@@ -0,0 +1,42 @@
+namespace TakeoutSaaS.Application.App.Statistics.Dto;
+
+///
+/// 订阅概览。
+///
+public record SubscriptionOverviewDto
+{
+ ///
+ /// 活跃订阅总数。
+ ///
+ public int TotalActive { get; init; }
+
+ ///
+ /// 7天内到期数量。
+ ///
+ public int ExpiringIn7Days { get; init; }
+
+ ///
+ /// 3天内到期数量。
+ ///
+ public int ExpiringIn3Days { get; init; }
+
+ ///
+ /// 1天内到期数量。
+ ///
+ public int ExpiringIn1Day { get; init; }
+
+ ///
+ /// 已过期数量。
+ ///
+ public int Expired { get; init; }
+
+ ///
+ /// 待激活数量。
+ ///
+ public int Pending { get; init; }
+
+ ///
+ /// 已暂停数量。
+ ///
+ public int Suspended { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetExpiringSubscriptionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetExpiringSubscriptionsQueryHandler.cs
new file mode 100644
index 0000000..a913aac
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetExpiringSubscriptionsQueryHandler.cs
@@ -0,0 +1,38 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Statistics.Dto;
+using TakeoutSaaS.Application.App.Statistics.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.Statistics.Handlers;
+
+///
+/// 获取即将到期的订阅列表处理器。
+///
+public sealed class GetExpiringSubscriptionsQueryHandler(IStatisticsRepository statisticsRepository)
+ : IRequestHandler>
+{
+ ///
+ public async Task> Handle(GetExpiringSubscriptionsQuery request, CancellationToken cancellationToken)
+ {
+ var now = DateTime.UtcNow;
+
+ // 查询即将到期的订阅
+ var items = await statisticsRepository.GetExpiringSubscriptionsAsync(
+ request.DaysAhead,
+ request.OnlyWithoutAutoRenew,
+ cancellationToken);
+
+ // 映射为 DTO
+ return items.Select(x => new ExpiringSubscriptionDto
+ {
+ Id = x.Subscription.Id,
+ TenantId = x.Subscription.TenantId.ToString(),
+ TenantName = x.TenantName,
+ PackageName = x.PackageName,
+ Status = x.Subscription.Status,
+ EffectiveTo = x.Subscription.EffectiveTo,
+ DaysRemaining = (int)(x.Subscription.EffectiveTo - now).TotalDays,
+ AutoRenew = x.Subscription.AutoRenew
+ }).ToList();
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetQuotaUsageRankingQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetQuotaUsageRankingQueryHandler.cs
new file mode 100644
index 0000000..b6fc116
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetQuotaUsageRankingQueryHandler.cs
@@ -0,0 +1,39 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Statistics.Dto;
+using TakeoutSaaS.Application.App.Statistics.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.Statistics.Handlers;
+
+///
+/// 获取配额使用排行处理器。
+///
+public sealed class GetQuotaUsageRankingQueryHandler(IStatisticsRepository statisticsRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(GetQuotaUsageRankingQuery request, CancellationToken cancellationToken)
+ {
+ // 查询指定类型的配额使用排行
+ var items = await statisticsRepository.GetQuotaUsageRankingAsync(
+ request.QuotaType,
+ request.TopN,
+ cancellationToken);
+
+ // 映射为 DTO
+ var rankings = items.Select(x => new QuotaUsageRankItem
+ {
+ TenantId = x.TenantId.ToString(),
+ TenantName = x.TenantName,
+ UsedValue = x.UsedValue,
+ LimitValue = x.LimitValue,
+ UsagePercentage = x.UsagePercentage
+ }).ToList();
+
+ return new QuotaUsageRankingDto
+ {
+ QuotaType = request.QuotaType,
+ Rankings = rankings
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetRevenueStatisticsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetRevenueStatisticsQueryHandler.cs
new file mode 100644
index 0000000..55cee9f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetRevenueStatisticsQueryHandler.cs
@@ -0,0 +1,71 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Statistics.Dto;
+using TakeoutSaaS.Application.App.Statistics.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.Statistics.Handlers;
+
+///
+/// 获取收入统计处理器。
+///
+public sealed class GetRevenueStatisticsQueryHandler(IStatisticsRepository statisticsRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(GetRevenueStatisticsQuery request, CancellationToken cancellationToken)
+ {
+ var now = DateTime.UtcNow;
+ var currentMonth = new DateTime(now.Year, now.Month, 1);
+ var currentQuarter = GetQuarterStart(now);
+ var startMonth = currentMonth.AddMonths(-request.MonthsCount + 1);
+
+ // 查询所有已付款的账单
+ var bills = await statisticsRepository.GetPaidBillsAsync(cancellationToken);
+
+ // 总收入
+ var totalRevenue = bills.Sum(b => b.AmountPaid);
+
+ // 本月收入
+ var monthlyRevenue = bills
+ .Where(b => b.PeriodStart >= currentMonth)
+ .Sum(b => b.AmountPaid);
+
+ // 本季度收入
+ var quarterlyRevenue = bills
+ .Where(b => b.PeriodStart >= currentQuarter)
+ .Sum(b => b.AmountPaid);
+
+ // 月度收入明细
+ var monthlyDetails = bills
+ .Where(b => b.PeriodStart >= startMonth)
+ .GroupBy(b => new { b.PeriodStart.Year, b.PeriodStart.Month })
+ .Select(g => new MonthlyRevenueItem
+ {
+ Year = g.Key.Year,
+ Month = g.Key.Month,
+ Amount = g.Sum(b => b.AmountPaid),
+ BillCount = g.Count()
+ })
+ .OrderBy(m => m.Year)
+ .ThenBy(m => m.Month)
+ .ToList();
+
+ return new RevenueStatisticsDto
+ {
+ TotalRevenue = totalRevenue,
+ MonthlyRevenue = monthlyRevenue,
+ QuarterlyRevenue = quarterlyRevenue,
+ MonthlyDetails = monthlyDetails
+ };
+ }
+
+ ///
+ /// 获取季度开始时间。
+ ///
+ private static DateTime GetQuarterStart(DateTime date)
+ {
+ var quarter = (date.Month - 1) / 3;
+ var quarterStartMonth = quarter * 3 + 1;
+ return new DateTime(date.Year, quarterStartMonth, 1);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetSubscriptionOverviewQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetSubscriptionOverviewQueryHandler.cs
new file mode 100644
index 0000000..054044b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Handlers/GetSubscriptionOverviewQueryHandler.cs
@@ -0,0 +1,49 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Statistics.Dto;
+using TakeoutSaaS.Application.App.Statistics.Queries;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.Statistics.Handlers;
+
+///
+/// 获取订阅概览统计处理器。
+///
+public sealed class GetSubscriptionOverviewQueryHandler(IStatisticsRepository statisticsRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(GetSubscriptionOverviewQuery request, CancellationToken cancellationToken)
+ {
+ var now = DateTime.UtcNow;
+ var in7Days = now.AddDays(7);
+ var in3Days = now.AddDays(3);
+ var in1Day = now.AddDays(1);
+
+ // 查询所有订阅
+ var subscriptions = await statisticsRepository.GetAllSubscriptionsAsync(cancellationToken);
+
+ // 统计各项数据
+ var overview = new SubscriptionOverviewDto
+ {
+ TotalActive = subscriptions.Count(s => s.Status == SubscriptionStatus.Active),
+ ExpiringIn7Days = subscriptions.Count(s =>
+ s.Status == SubscriptionStatus.Active &&
+ s.EffectiveTo >= now &&
+ s.EffectiveTo <= in7Days),
+ ExpiringIn3Days = subscriptions.Count(s =>
+ s.Status == SubscriptionStatus.Active &&
+ s.EffectiveTo >= now &&
+ s.EffectiveTo <= in3Days),
+ ExpiringIn1Day = subscriptions.Count(s =>
+ s.Status == SubscriptionStatus.Active &&
+ s.EffectiveTo >= now &&
+ s.EffectiveTo <= in1Day),
+ Expired = subscriptions.Count(s => s.Status == SubscriptionStatus.GracePeriod),
+ Pending = subscriptions.Count(s => s.Status == SubscriptionStatus.Pending),
+ Suspended = subscriptions.Count(s => s.Status == SubscriptionStatus.Suspended)
+ };
+
+ return overview;
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetExpiringSubscriptionsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetExpiringSubscriptionsQuery.cs
new file mode 100644
index 0000000..3fcd2e6
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetExpiringSubscriptionsQuery.cs
@@ -0,0 +1,20 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Statistics.Dto;
+
+namespace TakeoutSaaS.Application.App.Statistics.Queries;
+
+///
+/// 获取即将到期的订阅列表。
+///
+public sealed record GetExpiringSubscriptionsQuery : IRequest>
+{
+ ///
+ /// 筛选天数,默认7天内到期。
+ ///
+ public int DaysAhead { get; init; } = 7;
+
+ ///
+ /// 是否只返回未开启自动续费的订阅。
+ ///
+ public bool OnlyWithoutAutoRenew { get; init; } = false;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetQuotaUsageRankingQuery.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetQuotaUsageRankingQuery.cs
new file mode 100644
index 0000000..af8d0cc
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetQuotaUsageRankingQuery.cs
@@ -0,0 +1,21 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Statistics.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Application.App.Statistics.Queries;
+
+///
+/// 获取配额使用排行。
+///
+public sealed record GetQuotaUsageRankingQuery : IRequest
+{
+ ///
+ /// 配额类型。
+ ///
+ public TenantQuotaType QuotaType { get; init; }
+
+ ///
+ /// 返回前N条记录,默认前10。
+ ///
+ public int TopN { get; init; } = 10;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetRevenueStatisticsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetRevenueStatisticsQuery.cs
new file mode 100644
index 0000000..bda07b6
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetRevenueStatisticsQuery.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Statistics.Dto;
+
+namespace TakeoutSaaS.Application.App.Statistics.Queries;
+
+///
+/// 获取收入统计。
+///
+public sealed record GetRevenueStatisticsQuery : IRequest
+{
+ ///
+ /// 统计月份数量,默认12个月。
+ ///
+ public int MonthsCount { get; init; } = 12;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetSubscriptionOverviewQuery.cs b/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetSubscriptionOverviewQuery.cs
new file mode 100644
index 0000000..68b356a
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Statistics/Queries/GetSubscriptionOverviewQuery.cs
@@ -0,0 +1,11 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Statistics.Dto;
+
+namespace TakeoutSaaS.Application.App.Statistics.Queries;
+
+///
+/// 获取订阅概览统计。
+///
+public sealed record GetSubscriptionOverviewQuery : IRequest
+{
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/BatchExtendSubscriptionsCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/BatchExtendSubscriptionsCommand.cs
new file mode 100644
index 0000000..0f2954b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/BatchExtendSubscriptionsCommand.cs
@@ -0,0 +1,72 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
+
+///
+/// 批量延期订阅命令。
+///
+public sealed record BatchExtendSubscriptionsCommand : IRequest
+{
+ ///
+ /// 订阅ID列表。
+ ///
+ [Required]
+ [MinLength(1, ErrorMessage = "至少需要选择一个订阅")]
+ public IReadOnlyList SubscriptionIds { get; init; } = Array.Empty();
+
+ ///
+ /// 延期时长(天)。
+ ///
+ [Range(1, 3650, ErrorMessage = "延期天数必须在1-3650天之间")]
+ public int? DurationDays { get; init; }
+
+ ///
+ /// 延期时长(月)。
+ ///
+ [Range(1, 120, ErrorMessage = "延期月数必须在1-120月之间")]
+ public int? DurationMonths { get; init; }
+
+ ///
+ /// 备注信息。
+ ///
+ [MaxLength(500)]
+ public string? Notes { get; init; }
+}
+
+///
+/// 批量延期结果。
+///
+public record BatchExtendResult
+{
+ ///
+ /// 成功数量。
+ ///
+ public int SuccessCount { get; init; }
+
+ ///
+ /// 失败数量。
+ ///
+ public int FailureCount { get; init; }
+
+ ///
+ /// 失败详情列表。
+ ///
+ public IReadOnlyList Failures { get; init; } = Array.Empty();
+}
+
+///
+/// 批量操作失败项。
+///
+public record BatchFailureItem
+{
+ ///
+ /// 订阅ID。
+ ///
+ public long SubscriptionId { get; init; }
+
+ ///
+ /// 失败原因。
+ ///
+ public string Reason { get; init; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/BatchSendReminderCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/BatchSendReminderCommand.cs
new file mode 100644
index 0000000..8c41be6
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/BatchSendReminderCommand.cs
@@ -0,0 +1,45 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
+
+///
+/// 批量发送续费提醒命令。
+///
+public sealed record BatchSendReminderCommand : IRequest
+{
+ ///
+ /// 订阅ID列表。
+ ///
+ [Required]
+ [MinLength(1, ErrorMessage = "至少需要选择一个订阅")]
+ public IReadOnlyList SubscriptionIds { get; init; } = Array.Empty();
+
+ ///
+ /// 提醒内容。
+ ///
+ [Required(ErrorMessage = "提醒内容不能为空")]
+ [MaxLength(1000, ErrorMessage = "提醒内容不能超过1000字符")]
+ public string ReminderContent { get; init; } = string.Empty;
+}
+
+///
+/// 批量发送提醒结果。
+///
+public record BatchSendReminderResult
+{
+ ///
+ /// 成功发送数量。
+ ///
+ public int SuccessCount { get; init; }
+
+ ///
+ /// 发送失败数量。
+ ///
+ public int FailureCount { get; init; }
+
+ ///
+ /// 失败详情列表。
+ ///
+ public IReadOnlyList Failures { get; init; } = Array.Empty();
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ChangeSubscriptionPlanCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ChangeSubscriptionPlanCommand.cs
new file mode 100644
index 0000000..3b85ae5
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ChangeSubscriptionPlanCommand.cs
@@ -0,0 +1,34 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
+
+///
+/// 变更套餐命令。
+///
+public sealed record ChangeSubscriptionPlanCommand : IRequest
+{
+ ///
+ /// 订阅 ID(从路由参数绑定)。
+ ///
+ [Required]
+ public long SubscriptionId { get; init; }
+
+ ///
+ /// 目标套餐 ID。
+ ///
+ [Required]
+ public long TargetPackageId { get; init; }
+
+ ///
+ /// 是否立即生效,否则在下周期生效。
+ ///
+ public bool Immediate { get; init; }
+
+ ///
+ /// 备注信息。
+ ///
+ [MaxLength(500)]
+ public string? Notes { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ExtendSubscriptionCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ExtendSubscriptionCommand.cs
new file mode 100644
index 0000000..6034e01
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ExtendSubscriptionCommand.cs
@@ -0,0 +1,29 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
+
+///
+/// 延期订阅命令。
+///
+public sealed record ExtendSubscriptionCommand : IRequest
+{
+ ///
+ /// 订阅 ID(从路由参数绑定)。
+ ///
+ [Required]
+ public long SubscriptionId { get; init; }
+
+ ///
+ /// 延期时长(月)。
+ ///
+ [Range(1, 120)]
+ public int DurationMonths { get; init; }
+
+ ///
+ /// 备注信息。
+ ///
+ [MaxLength(500)]
+ public string? Notes { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateSubscriptionCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateSubscriptionCommand.cs
new file mode 100644
index 0000000..dd16fc5
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateSubscriptionCommand.cs
@@ -0,0 +1,28 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
+
+///
+/// 更新订阅基础信息命令。
+///
+public sealed record UpdateSubscriptionCommand : IRequest
+{
+ ///
+ /// 订阅 ID(从路由参数绑定)。
+ ///
+ [Required]
+ public long SubscriptionId { get; init; }
+
+ ///
+ /// 是否自动续费。
+ ///
+ public bool? AutoRenew { get; init; }
+
+ ///
+ /// 运营备注信息。
+ ///
+ [MaxLength(500)]
+ public string? Notes { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateSubscriptionStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateSubscriptionStatusCommand.cs
new file mode 100644
index 0000000..4e8240b
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateSubscriptionStatusCommand.cs
@@ -0,0 +1,30 @@
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
+
+///
+/// 更新订阅状态命令。
+///
+public sealed record UpdateSubscriptionStatusCommand : IRequest
+{
+ ///
+ /// 订阅 ID(从路由参数绑定)。
+ ///
+ [Required]
+ public long SubscriptionId { get; init; }
+
+ ///
+ /// 目标状态。
+ ///
+ [Required]
+ public SubscriptionStatus Status { get; init; }
+
+ ///
+ /// 备注信息。
+ ///
+ [MaxLength(500)]
+ public string? Notes { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/QuotaUsageDto.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/QuotaUsageDto.cs
new file mode 100644
index 0000000..0f36ea6
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/QuotaUsageDto.cs
@@ -0,0 +1,52 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
+
+///
+/// 配额使用 DTO。
+///
+public sealed record QuotaUsageDto
+{
+ ///
+ /// 配额 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 配额类型。
+ ///
+ public TenantQuotaType QuotaType { get; init; }
+
+ ///
+ /// 配额上限。
+ ///
+ public decimal LimitValue { get; init; }
+
+ ///
+ /// 已使用量。
+ ///
+ public decimal UsedValue { get; init; }
+
+ ///
+ /// 使用率(百分比)。
+ ///
+ public decimal UsagePercentage => LimitValue > 0 ? Math.Round(UsedValue / LimitValue * 100, 2) : 0;
+
+ ///
+ /// 剩余额度。
+ ///
+ public decimal RemainingValue => Math.Max(0, LimitValue - UsedValue);
+
+ ///
+ /// 重置周期描述。
+ ///
+ public string? ResetCycle { get; init; }
+
+ ///
+ /// 最近一次重置时间。
+ ///
+ public DateTime? LastResetAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/SubscriptionDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/SubscriptionDetailDto.cs
new file mode 100644
index 0000000..2a91720
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/SubscriptionDetailDto.cs
@@ -0,0 +1,106 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Application.App.Tenants.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
+
+///
+/// 订阅详情 DTO。
+///
+public sealed record SubscriptionDetailDto
+{
+ ///
+ /// 订阅 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 租户 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 租户名称。
+ ///
+ public string TenantName { get; init; } = string.Empty;
+
+ ///
+ /// 租户编码。
+ ///
+ public string TenantCode { get; init; } = string.Empty;
+
+ ///
+ /// 当前套餐 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantPackageId { get; init; }
+
+ ///
+ /// 当前套餐信息。
+ ///
+ public TenantPackageDto? Package { get; init; }
+
+ ///
+ /// 排期套餐 ID(下周期生效)。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long? ScheduledPackageId { get; init; }
+
+ ///
+ /// 排期套餐信息。
+ ///
+ public TenantPackageDto? ScheduledPackage { get; init; }
+
+ ///
+ /// 订阅状态。
+ ///
+ public SubscriptionStatus Status { get; init; }
+
+ ///
+ /// 生效时间(UTC)。
+ ///
+ public DateTime EffectiveFrom { get; init; }
+
+ ///
+ /// 到期时间(UTC)。
+ ///
+ public DateTime EffectiveTo { get; init; }
+
+ ///
+ /// 下次计费时间。
+ ///
+ public DateTime? NextBillingDate { get; init; }
+
+ ///
+ /// 是否自动续费。
+ ///
+ public bool AutoRenew { get; init; }
+
+ ///
+ /// 备注信息。
+ ///
+ public string? Notes { get; init; }
+
+ ///
+ /// 配额使用情况列表。
+ ///
+ public IReadOnlyList QuotaUsages { get; init; } = [];
+
+ ///
+ /// 订阅变更历史列表。
+ ///
+ public IReadOnlyList ChangeHistory { get; init; } = [];
+
+ ///
+ /// 创建时间。
+ ///
+ public DateTime CreatedAt { get; init; }
+
+ ///
+ /// 更新时间。
+ ///
+ public DateTime? UpdatedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/SubscriptionHistoryDto.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/SubscriptionHistoryDto.cs
new file mode 100644
index 0000000..c513263
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/SubscriptionHistoryDto.cs
@@ -0,0 +1,80 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
+
+///
+/// 订阅变更历史 DTO。
+///
+public sealed record SubscriptionHistoryDto
+{
+ ///
+ /// 历史记录 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 订阅 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantSubscriptionId { get; init; }
+
+ ///
+ /// 原套餐 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long FromPackageId { get; init; }
+
+ ///
+ /// 原套餐名称。
+ ///
+ public string FromPackageName { get; init; } = string.Empty;
+
+ ///
+ /// 新套餐 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long ToPackageId { get; init; }
+
+ ///
+ /// 新套餐名称。
+ ///
+ public string ToPackageName { get; init; } = string.Empty;
+
+ ///
+ /// 变更类型。
+ ///
+ public SubscriptionChangeType ChangeType { get; init; }
+
+ ///
+ /// 生效时间。
+ ///
+ public DateTime EffectiveFrom { get; init; }
+
+ ///
+ /// 到期时间。
+ ///
+ public DateTime EffectiveTo { get; init; }
+
+ ///
+ /// 相关费用。
+ ///
+ public decimal? Amount { get; init; }
+
+ ///
+ /// 币种。
+ ///
+ public string? Currency { get; init; }
+
+ ///
+ /// 备注。
+ ///
+ public string? Notes { get; init; }
+
+ ///
+ /// 创建时间。
+ ///
+ public DateTime CreatedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/SubscriptionListDto.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/SubscriptionListDto.cs
new file mode 100644
index 0000000..1cb4d05
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Dto/SubscriptionListDto.cs
@@ -0,0 +1,95 @@
+using System.Text.Json.Serialization;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Serialization;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
+
+///
+/// 订阅列表 DTO。
+///
+public sealed record SubscriptionListDto
+{
+ ///
+ /// 订阅 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long Id { get; init; }
+
+ ///
+ /// 租户 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantId { get; init; }
+
+ ///
+ /// 租户名称。
+ ///
+ public string TenantName { get; init; } = string.Empty;
+
+ ///
+ /// 租户编码。
+ ///
+ public string TenantCode { get; init; } = string.Empty;
+
+ ///
+ /// 当前套餐 ID。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long TenantPackageId { get; init; }
+
+ ///
+ /// 当前套餐名称。
+ ///
+ public string PackageName { get; init; } = string.Empty;
+
+ ///
+ /// 排期套餐 ID(下周期生效)。
+ ///
+ [JsonConverter(typeof(SnowflakeIdJsonConverter))]
+ public long? ScheduledPackageId { get; init; }
+
+ ///
+ /// 排期套餐名称。
+ ///
+ public string? ScheduledPackageName { get; init; }
+
+ ///
+ /// 订阅状态。
+ ///
+ public SubscriptionStatus Status { get; init; }
+
+ ///
+ /// 生效时间(UTC)。
+ ///
+ public DateTime EffectiveFrom { get; init; }
+
+ ///
+ /// 到期时间(UTC)。
+ ///
+ public DateTime EffectiveTo { get; init; }
+
+ ///
+ /// 下次计费时间。
+ ///
+ public DateTime? NextBillingDate { get; init; }
+
+ ///
+ /// 是否自动续费。
+ ///
+ public bool AutoRenew { get; init; }
+
+ ///
+ /// 备注信息。
+ ///
+ public string? Notes { get; init; }
+
+ ///
+ /// 创建时间。
+ ///
+ public DateTime CreatedAt { get; init; }
+
+ ///
+ /// 更新时间。
+ ///
+ public DateTime? UpdatedAt { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchExtendSubscriptionsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchExtendSubscriptionsCommandHandler.cs
new file mode 100644
index 0000000..3a63b63
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchExtendSubscriptionsCommandHandler.cs
@@ -0,0 +1,133 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+using TakeoutSaaS.Application.App.Subscriptions.Commands;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
+
+///
+/// 批量延期订阅命令处理器。
+///
+public sealed class BatchExtendSubscriptionsCommandHandler(
+ ISubscriptionRepository subscriptionRepository,
+ IIdGenerator idGenerator,
+ ILogger logger)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(BatchExtendSubscriptionsCommand request, CancellationToken cancellationToken)
+ {
+ var successCount = 0;
+ var failures = new List();
+
+ // 验证参数
+ if (!request.DurationDays.HasValue && !request.DurationMonths.HasValue)
+ {
+ throw new InvalidOperationException("必须指定延期天数或延期月数");
+ }
+
+ // 计算延期时间
+ var extendDays = request.DurationDays ?? 0;
+ var extendMonths = request.DurationMonths ?? 0;
+
+ // 查询所有订阅
+ var subscriptions = await subscriptionRepository.FindByIdsAsync(request.SubscriptionIds, cancellationToken);
+
+ foreach (var subscriptionId in request.SubscriptionIds)
+ {
+ try
+ {
+ var subscription = subscriptions.FirstOrDefault(s => s.Id == subscriptionId);
+ if (subscription == null)
+ {
+ failures.Add(new BatchFailureItem
+ {
+ SubscriptionId = subscriptionId,
+ Reason = "订阅不存在"
+ });
+ continue;
+ }
+
+ // 记录原始到期时间
+ var originalEffectiveTo = subscription.EffectiveTo;
+
+ // 计算新的到期时间
+ var newEffectiveTo = subscription.EffectiveTo;
+ if (extendMonths > 0)
+ {
+ newEffectiveTo = newEffectiveTo.AddMonths(extendMonths);
+ }
+ if (extendDays > 0)
+ {
+ newEffectiveTo = newEffectiveTo.AddDays(extendDays);
+ }
+
+ subscription.EffectiveTo = newEffectiveTo;
+
+ // 更新备注
+ if (!string.IsNullOrWhiteSpace(request.Notes))
+ {
+ subscription.Notes = request.Notes;
+ }
+
+ // 记录变更历史
+ var history = new TenantSubscriptionHistory
+ {
+ Id = idGenerator.NextId(),
+ TenantId = subscription.TenantId,
+ TenantSubscriptionId = subscription.Id,
+ FromPackageId = subscription.TenantPackageId,
+ ToPackageId = subscription.TenantPackageId,
+ ChangeType = SubscriptionChangeType.Renew,
+ EffectiveFrom = originalEffectiveTo,
+ EffectiveTo = newEffectiveTo,
+ Amount = null,
+ Currency = null,
+ Notes = request.Notes ?? $"批量延期: {(extendMonths > 0 ? $"{extendMonths}个月" : "")}{(extendDays > 0 ? $"{extendDays}天" : "")}"
+ };
+
+ await subscriptionRepository.AddHistoryAsync(history, cancellationToken);
+ await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
+ successCount++;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "批量延期订阅失败: SubscriptionId={SubscriptionId}", subscriptionId);
+ failures.Add(new BatchFailureItem
+ {
+ SubscriptionId = subscriptionId,
+ Reason = $"处理失败: {ex.Message}"
+ });
+ }
+ }
+
+ // 记录操作日志
+ var operationLog = new OperationLog
+ {
+ Id = idGenerator.NextId(),
+ OperationType = "BatchExtend",
+ TargetType = "Subscription",
+ TargetIds = JsonSerializer.Serialize(request.SubscriptionIds),
+ Parameters = JsonSerializer.Serialize(new { request.DurationDays, request.DurationMonths, request.Notes }),
+ Result = JsonSerializer.Serialize(new { SuccessCount = successCount, FailureCount = failures.Count }),
+ Success = failures.Count == 0,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ await subscriptionRepository.AddOperationLogAsync(operationLog, cancellationToken);
+
+ // 保存所有更改
+ await subscriptionRepository.SaveChangesAsync(cancellationToken);
+
+ return new BatchExtendResult
+ {
+ SuccessCount = successCount,
+ FailureCount = failures.Count,
+ Failures = failures
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchSendReminderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchSendReminderCommandHandler.cs
new file mode 100644
index 0000000..95f36b1
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/BatchSendReminderCommandHandler.cs
@@ -0,0 +1,104 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+using TakeoutSaaS.Application.App.Subscriptions.Commands;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
+
+///
+/// 批量发送续费提醒命令处理器。
+///
+public sealed class BatchSendReminderCommandHandler(
+ ISubscriptionRepository subscriptionRepository,
+ IIdGenerator idGenerator,
+ ILogger logger)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(BatchSendReminderCommand request, CancellationToken cancellationToken)
+ {
+ var successCount = 0;
+ var failures = new List();
+
+ // 查询所有订阅及租户信息
+ var subscriptions = await subscriptionRepository.FindByIdsWithTenantAsync(
+ request.SubscriptionIds,
+ cancellationToken);
+
+ foreach (var subscriptionId in request.SubscriptionIds)
+ {
+ try
+ {
+ var item = subscriptions.FirstOrDefault(s => s.Subscription.Id == subscriptionId);
+ if (item == null)
+ {
+ failures.Add(new BatchFailureItem
+ {
+ SubscriptionId = subscriptionId,
+ Reason = "订阅不存在"
+ });
+ continue;
+ }
+
+ // 创建通知记录
+ var notification = new TenantNotification
+ {
+ Id = idGenerator.NextId(),
+ TenantId = item.Subscription.TenantId,
+ Title = "续费提醒",
+ Message = request.ReminderContent,
+ Severity = TenantNotificationSeverity.Warning,
+ Channel = TenantNotificationChannel.InApp,
+ SentAt = DateTime.UtcNow,
+ ReadAt = null,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ await subscriptionRepository.AddNotificationAsync(notification, cancellationToken);
+ successCount++;
+
+ logger.LogInformation(
+ "发送续费提醒: SubscriptionId={SubscriptionId}, TenantId={TenantId}, TenantName={TenantName}",
+ subscriptionId, item.Subscription.TenantId, item.Tenant.Name);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "发送续费提醒失败: SubscriptionId={SubscriptionId}", subscriptionId);
+ failures.Add(new BatchFailureItem
+ {
+ SubscriptionId = subscriptionId,
+ Reason = $"发送失败: {ex.Message}"
+ });
+ }
+ }
+
+ // 记录操作日志
+ var operationLog = new OperationLog
+ {
+ Id = idGenerator.NextId(),
+ OperationType = "BatchRemind",
+ TargetType = "Subscription",
+ TargetIds = JsonSerializer.Serialize(request.SubscriptionIds),
+ Parameters = JsonSerializer.Serialize(new { request.ReminderContent }),
+ Result = JsonSerializer.Serialize(new { SuccessCount = successCount, FailureCount = failures.Count }),
+ Success = failures.Count == 0,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ await subscriptionRepository.AddOperationLogAsync(operationLog, cancellationToken);
+
+ // 保存所有更改
+ await subscriptionRepository.SaveChangesAsync(cancellationToken);
+
+ return new BatchSendReminderResult
+ {
+ SuccessCount = successCount,
+ FailureCount = failures.Count,
+ Failures = failures
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangeSubscriptionPlanCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangeSubscriptionPlanCommandHandler.cs
new file mode 100644
index 0000000..5a238a2
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangeSubscriptionPlanCommandHandler.cs
@@ -0,0 +1,93 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Subscriptions.Commands;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+using TakeoutSaaS.Application.App.Subscriptions.Queries;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
+
+///
+/// 变更套餐命令处理器。
+///
+public sealed class ChangeSubscriptionPlanCommandHandler(
+ ISubscriptionRepository subscriptionRepository,
+ IIdGenerator idGenerator,
+ IMediator mediator)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(ChangeSubscriptionPlanCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 查询订阅
+ var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
+
+ if (subscription == null)
+ {
+ return null;
+ }
+
+ // 2. 记录原套餐ID
+ var previousPackageId = subscription.TenantPackageId;
+
+ // 3. 根据是否立即生效更新订阅
+ if (request.Immediate)
+ {
+ // 立即生效:直接更新当前套餐
+ subscription.TenantPackageId = request.TargetPackageId;
+ subscription.ScheduledPackageId = null;
+ }
+ else
+ {
+ // 下周期生效:设置排期套餐
+ subscription.ScheduledPackageId = request.TargetPackageId;
+ }
+
+ // 4. 更新备注
+ if (!string.IsNullOrWhiteSpace(request.Notes))
+ {
+ subscription.Notes = request.Notes;
+ }
+
+ // 5. 判断变更类型(升级或降级)
+ var fromPackage = await subscriptionRepository.FindPackageByIdAsync(previousPackageId, cancellationToken);
+ var toPackage = await subscriptionRepository.FindPackageByIdAsync(request.TargetPackageId, cancellationToken);
+
+ var changeType = SubscriptionChangeType.Upgrade;
+ if (fromPackage != null && toPackage != null)
+ {
+ // 简单根据价格判断升降级
+ if (toPackage.MonthlyPrice < fromPackage.MonthlyPrice)
+ {
+ changeType = SubscriptionChangeType.Downgrade;
+ }
+ }
+
+ // 6. 记录变更历史
+ var history = new TenantSubscriptionHistory
+ {
+ Id = idGenerator.NextId(),
+ TenantId = subscription.TenantId,
+ TenantSubscriptionId = subscription.Id,
+ FromPackageId = previousPackageId,
+ ToPackageId = request.TargetPackageId,
+ ChangeType = changeType,
+ EffectiveFrom = request.Immediate ? DateTime.UtcNow : subscription.EffectiveTo,
+ EffectiveTo = subscription.EffectiveTo,
+ Amount = null,
+ Currency = null,
+ Notes = request.Notes ?? (request.Immediate ? "套餐立即变更" : "套餐排期变更")
+ };
+
+ await subscriptionRepository.AddHistoryAsync(history, cancellationToken);
+
+ // 7. 保存更改
+ await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
+ await subscriptionRepository.SaveChangesAsync(cancellationToken);
+
+ // 8. 返回更新后的详情
+ return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs
new file mode 100644
index 0000000..98fc903
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs
@@ -0,0 +1,67 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Subscriptions.Commands;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+using TakeoutSaaS.Application.App.Subscriptions.Queries;
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Ids;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
+
+///
+/// 延期订阅命令处理器。
+///
+public sealed class ExtendSubscriptionCommandHandler(
+ ISubscriptionRepository subscriptionRepository,
+ IIdGenerator idGenerator,
+ IMediator mediator)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(ExtendSubscriptionCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 查询订阅
+ var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
+
+ if (subscription == null)
+ {
+ return null;
+ }
+
+ // 2. 计算新的到期时间(从当前到期时间延长)
+ var originalEffectiveTo = subscription.EffectiveTo;
+ subscription.EffectiveTo = subscription.EffectiveTo.AddMonths(request.DurationMonths);
+
+ // 3. 更新备注
+ if (!string.IsNullOrWhiteSpace(request.Notes))
+ {
+ subscription.Notes = request.Notes;
+ }
+
+ // 4. 记录变更历史(使用 Renew 类型表示延期)
+ var history = new TenantSubscriptionHistory
+ {
+ Id = idGenerator.NextId(),
+ TenantId = subscription.TenantId,
+ TenantSubscriptionId = subscription.Id,
+ FromPackageId = subscription.TenantPackageId,
+ ToPackageId = subscription.TenantPackageId,
+ ChangeType = SubscriptionChangeType.Renew,
+ EffectiveFrom = originalEffectiveTo,
+ EffectiveTo = subscription.EffectiveTo,
+ Amount = null,
+ Currency = null,
+ Notes = request.Notes ?? $"延期 {request.DurationMonths} 个月"
+ };
+
+ await subscriptionRepository.AddHistoryAsync(history, cancellationToken);
+
+ // 5. 保存更改
+ await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
+ await subscriptionRepository.SaveChangesAsync(cancellationToken);
+
+ // 6. 返回更新后的详情
+ return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs
new file mode 100644
index 0000000..6bb13ab
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs
@@ -0,0 +1,84 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+using TakeoutSaaS.Application.App.Subscriptions.Queries;
+using TakeoutSaaS.Application.App.Tenants;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
+
+///
+/// 订阅详情查询处理器。
+///
+public sealed class GetSubscriptionDetailQueryHandler(ISubscriptionRepository subscriptionRepository)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(GetSubscriptionDetailQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 查询订阅基础信息
+ var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken);
+
+ if (detail == null)
+ {
+ return null;
+ }
+
+ // 2. 查询配额使用情况
+ var quotaUsages = await subscriptionRepository.GetQuotaUsagesAsync(
+ detail.Subscription.TenantId,
+ cancellationToken);
+
+ var quotaUsageDtos = quotaUsages.Select(q => new QuotaUsageDto
+ {
+ Id = q.Id,
+ QuotaType = q.QuotaType,
+ LimitValue = q.LimitValue,
+ UsedValue = q.UsedValue,
+ ResetCycle = q.ResetCycle,
+ LastResetAt = q.LastResetAt
+ }).ToList();
+
+ // 3. 查询订阅变更历史(关联套餐信息)
+ var histories = await subscriptionRepository.GetHistoryAsync(request.SubscriptionId, cancellationToken);
+
+ var historyDtos = histories.Select(h => new SubscriptionHistoryDto
+ {
+ Id = h.History.Id,
+ TenantSubscriptionId = h.History.TenantSubscriptionId,
+ FromPackageId = h.History.FromPackageId,
+ FromPackageName = h.FromPackageName,
+ ToPackageId = h.History.ToPackageId,
+ ToPackageName = h.ToPackageName,
+ ChangeType = h.History.ChangeType,
+ EffectiveFrom = h.History.EffectiveFrom,
+ EffectiveTo = h.History.EffectiveTo,
+ Amount = h.History.Amount,
+ Currency = h.History.Currency,
+ Notes = h.History.Notes,
+ CreatedAt = h.History.CreatedAt
+ }).ToList();
+
+ // 4. 构建返回结果
+ return new SubscriptionDetailDto
+ {
+ Id = detail.Subscription.Id,
+ TenantId = detail.Subscription.TenantId,
+ TenantName = detail.TenantName,
+ TenantCode = detail.TenantCode,
+ TenantPackageId = detail.Subscription.TenantPackageId,
+ Package = detail.Package?.ToDto(),
+ ScheduledPackageId = detail.Subscription.ScheduledPackageId,
+ ScheduledPackage = detail.ScheduledPackage?.ToDto(),
+ Status = detail.Subscription.Status,
+ EffectiveFrom = detail.Subscription.EffectiveFrom,
+ EffectiveTo = detail.Subscription.EffectiveTo,
+ NextBillingDate = detail.Subscription.NextBillingDate,
+ AutoRenew = detail.Subscription.AutoRenew,
+ Notes = detail.Subscription.Notes,
+ QuotaUsages = quotaUsageDtos,
+ ChangeHistory = historyDtos,
+ CreatedAt = detail.Subscription.CreatedAt,
+ UpdatedAt = detail.Subscription.UpdatedAt
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionListQueryHandler.cs
new file mode 100644
index 0000000..75146b7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionListQueryHandler.cs
@@ -0,0 +1,58 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+using TakeoutSaaS.Application.App.Subscriptions.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
+
+///
+/// 订阅分页查询处理器。
+///
+public sealed class GetSubscriptionListQueryHandler(ISubscriptionRepository subscriptionRepository)
+ : IRequestHandler>
+{
+ ///
+ public async Task> Handle(GetSubscriptionListQuery request, CancellationToken cancellationToken)
+ {
+ // 1. 构建查询过滤条件
+ var filter = new SubscriptionSearchFilter
+ {
+ Status = request.Status,
+ TenantPackageId = request.TenantPackageId,
+ TenantId = request.TenantId,
+ TenantKeyword = request.TenantKeyword,
+ ExpiringWithinDays = request.ExpiringWithinDays,
+ AutoRenew = request.AutoRenew,
+ Page = request.Page,
+ PageSize = request.PageSize
+ };
+
+ // 2. 执行分页查询
+ var (items, total) = await subscriptionRepository.SearchPagedAsync(filter, cancellationToken);
+
+ // 3. 映射为 DTO
+ var dtos = items.Select(x => new SubscriptionListDto
+ {
+ Id = x.Subscription.Id,
+ TenantId = x.Subscription.TenantId,
+ TenantName = x.TenantName,
+ TenantCode = x.TenantCode,
+ TenantPackageId = x.Subscription.TenantPackageId,
+ PackageName = x.PackageName,
+ ScheduledPackageId = x.Subscription.ScheduledPackageId,
+ ScheduledPackageName = x.ScheduledPackageName,
+ Status = x.Subscription.Status,
+ EffectiveFrom = x.Subscription.EffectiveFrom,
+ EffectiveTo = x.Subscription.EffectiveTo,
+ NextBillingDate = x.Subscription.NextBillingDate,
+ AutoRenew = x.Subscription.AutoRenew,
+ Notes = x.Subscription.Notes,
+ CreatedAt = x.Subscription.CreatedAt,
+ UpdatedAt = x.Subscription.UpdatedAt
+ }).ToList();
+
+ // 4. 返回分页结果
+ return new PagedResult(dtos, request.Page, request.PageSize, total);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs
new file mode 100644
index 0000000..a3d531f
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs
@@ -0,0 +1,46 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Subscriptions.Commands;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+using TakeoutSaaS.Application.App.Subscriptions.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
+
+///
+/// 更新订阅基础信息命令处理器。
+///
+public sealed class UpdateSubscriptionCommandHandler(
+ ISubscriptionRepository subscriptionRepository,
+ IMediator mediator)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(UpdateSubscriptionCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 查询订阅
+ var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
+
+ if (subscription == null)
+ {
+ return null;
+ }
+
+ // 2. 更新字段
+ if (request.AutoRenew.HasValue)
+ {
+ subscription.AutoRenew = request.AutoRenew.Value;
+ }
+
+ if (request.Notes != null)
+ {
+ subscription.Notes = request.Notes;
+ }
+
+ // 3. 保存更改
+ await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
+ await subscriptionRepository.SaveChangesAsync(cancellationToken);
+
+ // 4. 返回更新后的详情
+ return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionStatusCommandHandler.cs
new file mode 100644
index 0000000..b75e50d
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionStatusCommandHandler.cs
@@ -0,0 +1,44 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Subscriptions.Commands;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+using TakeoutSaaS.Application.App.Subscriptions.Queries;
+using TakeoutSaaS.Domain.Tenants.Repositories;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
+
+///
+/// 更新订阅状态命令处理器。
+///
+public sealed class UpdateSubscriptionStatusCommandHandler(
+ ISubscriptionRepository subscriptionRepository,
+ IMediator mediator)
+ : IRequestHandler
+{
+ ///
+ public async Task Handle(UpdateSubscriptionStatusCommand request, CancellationToken cancellationToken)
+ {
+ // 1. 查询订阅
+ var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
+
+ if (subscription == null)
+ {
+ return null;
+ }
+
+ // 2. 更新状态
+ subscription.Status = request.Status;
+
+ // 3. 更新备注
+ if (!string.IsNullOrWhiteSpace(request.Notes))
+ {
+ subscription.Notes = request.Notes;
+ }
+
+ // 4. 保存更改
+ await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
+ await subscriptionRepository.SaveChangesAsync(cancellationToken);
+
+ // 5. 返回更新后的详情
+ return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/GetSubscriptionDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/GetSubscriptionDetailQuery.cs
new file mode 100644
index 0000000..c69c3e4
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/GetSubscriptionDetailQuery.cs
@@ -0,0 +1,15 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Queries;
+
+///
+/// 查询订阅详情(含套餐信息、配额使用、变更历史)。
+///
+public sealed record GetSubscriptionDetailQuery : IRequest
+{
+ ///
+ /// 订阅 ID。
+ ///
+ public long SubscriptionId { get; init; }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/GetSubscriptionListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/GetSubscriptionListQuery.cs
new file mode 100644
index 0000000..e7a8cd4
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/GetSubscriptionListQuery.cs
@@ -0,0 +1,52 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Subscriptions.Dto;
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Results;
+
+namespace TakeoutSaaS.Application.App.Subscriptions.Queries;
+
+///
+/// 订阅分页查询。
+///
+public sealed record GetSubscriptionListQuery : IRequest>
+{
+ ///
+ /// 订阅状态(精确匹配)。
+ ///
+ public SubscriptionStatus? Status { get; init; }
+
+ ///
+ /// 套餐 ID(精确匹配)。
+ ///
+ public long? TenantPackageId { get; init; }
+
+ ///
+ /// 租户 ID(精确匹配)。
+ ///
+ public long? TenantId { get; init; }
+
+ ///
+ /// 租户关键词(名称或编码模糊匹配)。
+ ///
+ public string? TenantKeyword { get; init; }
+
+ ///
+ /// 到期时间筛选:未来 N 天内到期。
+ ///
+ public int? ExpiringWithinDays { get; init; }
+
+ ///
+ /// 是否自动续费筛选。
+ ///
+ public bool? AutoRenew { get; init; }
+
+ ///
+ /// 页码(从 1 开始)。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页大小。
+ ///
+ public int PageSize { get; init; } = 20;
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/OperationLog.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/OperationLog.cs
new file mode 100644
index 0000000..15131ae
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/OperationLog.cs
@@ -0,0 +1,49 @@
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Tenants.Entities;
+
+///
+/// 运营操作日志。
+///
+public sealed class OperationLog : AuditableEntityBase
+{
+ ///
+ /// 操作类型:BatchExtend, BatchRemind, StatusChange 等。
+ ///
+ public string OperationType { get; set; } = string.Empty;
+
+ ///
+ /// 目标类型:Subscription, Bill 等。
+ ///
+ public string TargetType { get; set; } = string.Empty;
+
+ ///
+ /// 目标ID列表(JSON)。
+ ///
+ public string? TargetIds { get; set; }
+
+ ///
+ /// 操作人ID。
+ ///
+ public string? OperatorId { get; set; }
+
+ ///
+ /// 操作人名称。
+ ///
+ public string? OperatorName { get; set; }
+
+ ///
+ /// 操作参数(JSON)。
+ ///
+ public string? Parameters { get; set; }
+
+ ///
+ /// 操作结果(JSON)。
+ ///
+ public string? Result { get; set; }
+
+ ///
+ /// 是否成功。
+ ///
+ public bool Success { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/QuotaPackage.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/QuotaPackage.cs
new file mode 100644
index 0000000..c480561
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/QuotaPackage.cs
@@ -0,0 +1,45 @@
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Tenants.Entities;
+
+///
+/// 配额包定义(平台提供的可购买配额包)。
+///
+public sealed class QuotaPackage : AuditableEntityBase
+{
+ ///
+ /// 配额包名称。
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// 配额类型。
+ ///
+ public TenantQuotaType QuotaType { get; set; }
+
+ ///
+ /// 配额数值。
+ ///
+ public decimal QuotaValue { get; set; }
+
+ ///
+ /// 价格。
+ ///
+ public decimal Price { get; set; }
+
+ ///
+ /// 是否上架。
+ ///
+ public bool IsActive { get; set; } = true;
+
+ ///
+ /// 排序。
+ ///
+ public int SortOrder { get; set; } = 0;
+
+ ///
+ /// 描述。
+ ///
+ public string? Description { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPayment.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPayment.cs
new file mode 100644
index 0000000..7db207f
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPayment.cs
@@ -0,0 +1,50 @@
+using TakeoutSaaS.Domain.Tenants.Enums;
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Tenants.Entities;
+
+///
+/// 租户支付记录。
+///
+public sealed class TenantPayment : MultiTenantEntityBase
+{
+ ///
+ /// 关联的账单 ID。
+ ///
+ public long BillingStatementId { get; set; }
+
+ ///
+ /// 支付金额。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 支付方式。
+ ///
+ public PaymentMethod Method { get; set; }
+
+ ///
+ /// 支付状态。
+ ///
+ public PaymentStatus Status { get; set; }
+
+ ///
+ /// 交易号。
+ ///
+ public string? TransactionNo { get; set; }
+
+ ///
+ /// 支付凭证 URL。
+ ///
+ public string? ProofUrl { get; set; }
+
+ ///
+ /// 支付时间。
+ ///
+ public DateTime? PaidAt { get; set; }
+
+ ///
+ /// 备注信息。
+ ///
+ public string? Notes { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantQuotaPackagePurchase.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantQuotaPackagePurchase.cs
new file mode 100644
index 0000000..bd3bc51
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantQuotaPackagePurchase.cs
@@ -0,0 +1,39 @@
+using TakeoutSaaS.Shared.Abstractions.Entities;
+
+namespace TakeoutSaaS.Domain.Tenants.Entities;
+
+///
+/// 租户配额包购买记录。
+///
+public sealed class TenantQuotaPackagePurchase : MultiTenantEntityBase
+{
+ ///
+ /// 配额包 ID。
+ ///
+ public long QuotaPackageId { get; set; }
+
+ ///
+ /// 购买时的配额值。
+ ///
+ public decimal QuotaValue { get; set; }
+
+ ///
+ /// 购买价格。
+ ///
+ public decimal Price { get; set; }
+
+ ///
+ /// 购买时间。
+ ///
+ public DateTime PurchasedAt { get; set; }
+
+ ///
+ /// 过期时间(可选)。
+ ///
+ public DateTime? ExpiredAt { get; set; }
+
+ ///
+ /// 备注。
+ ///
+ public string? Notes { get; set; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentMethod.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentMethod.cs
new file mode 100644
index 0000000..e73b78d
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentMethod.cs
@@ -0,0 +1,22 @@
+namespace TakeoutSaaS.Domain.Tenants.Enums;
+
+///
+/// 支付方式。
+///
+public enum PaymentMethod
+{
+ ///
+ /// 线上支付。
+ ///
+ Online = 0,
+
+ ///
+ /// 银行转账。
+ ///
+ BankTransfer = 1,
+
+ ///
+ /// 其他方式。
+ ///
+ Other = 2
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentStatus.cs
new file mode 100644
index 0000000..97b539a
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentStatus.cs
@@ -0,0 +1,27 @@
+namespace TakeoutSaaS.Domain.Tenants.Enums;
+
+///
+/// 支付状态。
+///
+public enum PaymentStatus
+{
+ ///
+ /// 待支付。
+ ///
+ Pending = 0,
+
+ ///
+ /// 支付成功。
+ ///
+ Success = 1,
+
+ ///
+ /// 支付失败。
+ ///
+ Failed = 2,
+
+ ///
+ /// 已退款。
+ ///
+ Refunded = 3
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IQuotaPackageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IQuotaPackageRepository.cs
new file mode 100644
index 0000000..49aa91d
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IQuotaPackageRepository.cs
@@ -0,0 +1,131 @@
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Domain.Tenants.Repositories;
+
+///
+/// 配额包仓储。
+///
+public interface IQuotaPackageRepository
+{
+ #region 配额包定义
+
+ ///
+ /// 按 ID 查找配额包。
+ ///
+ /// 配额包 ID(雪花算法)。
+ /// 取消标记。
+ /// 配额包实体,未找到返回 null。
+ Task FindByIdAsync(long id, CancellationToken cancellationToken = default);
+
+ ///
+ /// 分页查询配额包。
+ ///
+ /// 配额类型,为空不按类型过滤。
+ /// 启用状态,为空不按状态过滤。
+ /// 页码(从 1 开始)。
+ /// 每页大小。
+ /// 取消标记。
+ /// 分页数据与总数。
+ Task<(IReadOnlyList Items, int Total)> SearchPagedAsync(
+ TenantQuotaType? quotaType,
+ bool? isActive,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增配额包。
+ ///
+ /// 配额包实体。
+ /// 取消标记。
+ /// 异步任务。
+ Task AddAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新配额包。
+ ///
+ /// 配额包实体。
+ /// 取消标记。
+ /// 异步任务。
+ Task UpdateAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default);
+
+ ///
+ /// 软删除配额包。
+ ///
+ /// 配额包 ID(雪花算法)。
+ /// 取消标记。
+ /// 删除成功返回 true,未找到返回 false。
+ Task SoftDeleteAsync(long id, CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region 配额包购买记录
+
+ ///
+ /// 分页查询租户配额购买记录(包含配额包信息)。
+ ///
+ /// 租户 ID(雪花算法)。
+ /// 页码(从 1 开始)。
+ /// 每页大小。
+ /// 取消标记。
+ /// 分页数据与总数。
+ Task<(IReadOnlyList<(TenantQuotaPackagePurchase Purchase, QuotaPackage Package)> Items, int Total)> GetPurchasesPagedAsync(
+ long tenantId,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增配额购买记录。
+ ///
+ /// 购买记录实体。
+ /// 取消标记。
+ /// 异步任务。
+ Task AddPurchaseAsync(TenantQuotaPackagePurchase purchase, CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region 配额使用情况
+
+ ///
+ /// 查询租户配额使用情况。
+ ///
+ /// 租户 ID(雪花算法)。
+ /// 配额类型,为空查询全部。
+ /// 取消标记。
+ /// 配额使用情况列表。
+ Task> GetUsageByTenantAsync(
+ long tenantId,
+ TenantQuotaType? quotaType,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 查找特定配额使用记录。
+ ///
+ /// 租户 ID(雪花算法)。
+ /// 配额类型。
+ /// 取消标记。
+ /// 配额使用记录,未找到返回 null。
+ Task FindUsageAsync(
+ long tenantId,
+ TenantQuotaType quotaType,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新配额使用情况。
+ ///
+ /// 配额使用实体。
+ /// 取消标记。
+ /// 异步任务。
+ Task UpdateUsageAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default);
+
+ #endregion
+
+ ///
+ /// 持久化。
+ ///
+ /// 取消标记。
+ /// 异步任务。
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IStatisticsRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IStatisticsRepository.cs
new file mode 100644
index 0000000..806d440
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IStatisticsRepository.cs
@@ -0,0 +1,112 @@
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Domain.Tenants.Repositories;
+
+///
+/// 统计数据仓储接口。
+///
+public interface IStatisticsRepository
+{
+ #region 订阅统计
+
+ ///
+ /// 获取所有订阅(用于统计)。
+ ///
+ /// 取消标记。
+ /// 所有订阅记录。
+ Task> GetAllSubscriptionsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// 获取即将到期的订阅(含租户和套餐信息)。
+ ///
+ /// 到期天数。
+ /// 是否仅查询未开启自动续费的。
+ /// 取消标记。
+ /// 即将到期的订阅信息列表。
+ Task> GetExpiringSubscriptionsAsync(
+ int daysAhead,
+ bool onlyWithoutAutoRenew,
+ CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region 收入统计
+
+ ///
+ /// 获取所有已付款账单(用于收入统计)。
+ ///
+ /// 取消标记。
+ /// 已付款账单列表。
+ Task> GetPaidBillsAsync(CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region 配额使用排行
+
+ ///
+ /// 获取配额使用排行(含租户信息)。
+ ///
+ /// 配额类型。
+ /// 前 N 名。
+ /// 取消标记。
+ /// 配额使用排行列表。
+ Task> GetQuotaUsageRankingAsync(
+ TenantQuotaType quotaType,
+ int topN,
+ CancellationToken cancellationToken = default);
+
+ #endregion
+}
+
+///
+/// 即将到期的订阅信息(含关联数据)。
+///
+public record ExpiringSubscriptionInfo
+{
+ ///
+ /// 订阅实体。
+ ///
+ public required TenantSubscription Subscription { get; init; }
+
+ ///
+ /// 租户名称。
+ ///
+ public required string TenantName { get; init; }
+
+ ///
+ /// 套餐名称。
+ ///
+ public required string PackageName { get; init; }
+}
+
+///
+/// 配额使用排行信息(含租户名称)。
+///
+public record QuotaUsageRankInfo
+{
+ ///
+ /// 租户 ID。
+ ///
+ public long TenantId { get; init; }
+
+ ///
+ /// 租户名称。
+ ///
+ public required string TenantName { get; init; }
+
+ ///
+ /// 已使用值。
+ ///
+ public decimal UsedValue { get; init; }
+
+ ///
+ /// 限制值。
+ ///
+ public decimal LimitValue { get; init; }
+
+ ///
+ /// 使用百分比。
+ ///
+ public decimal UsagePercentage { get; init; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs
new file mode 100644
index 0000000..54ee43d
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs
@@ -0,0 +1,295 @@
+using TakeoutSaaS.Domain.Tenants.Entities;
+using TakeoutSaaS.Domain.Tenants.Enums;
+
+namespace TakeoutSaaS.Domain.Tenants.Repositories;
+
+///
+/// 订阅管理仓储接口。
+///
+public interface ISubscriptionRepository
+{
+ #region 订阅查询
+
+ ///
+ /// 按 ID 查询订阅。
+ ///
+ /// 订阅 ID。
+ /// 取消标记。
+ /// 订阅实体,未找到返回 null。
+ Task FindByIdAsync(long subscriptionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按 ID 列表批量查询订阅。
+ ///
+ /// 订阅 ID 列表。
+ /// 取消标记。
+ /// 订阅实体列表。
+ Task> FindByIdsAsync(
+ IEnumerable subscriptionIds,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 分页查询订阅列表(含关联信息)。
+ ///
+ /// 查询过滤条件。
+ /// 取消标记。
+ /// 分页结果。
+ Task<(IReadOnlyList Items, int Total)> SearchPagedAsync(
+ SubscriptionSearchFilter filter,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 获取订阅详情(含关联信息)。
+ ///
+ /// 订阅 ID。
+ /// 取消标记。
+ /// 订阅详情信息。
+ Task GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按 ID 列表批量查询订阅(含租户信息)。
+ ///
+ /// 订阅 ID 列表。
+ /// 取消标记。
+ /// 订阅与租户信息列表。
+ Task> FindByIdsWithTenantAsync(
+ IEnumerable subscriptionIds,
+ CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region 套餐查询
+
+ ///
+ /// 按 ID 查询套餐。
+ ///
+ /// 套餐 ID。
+ /// 取消标记。
+ /// 套餐实体,未找到返回 null。
+ Task FindPackageByIdAsync(long packageId, CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region 订阅更新
+
+ ///
+ /// 更新订阅。
+ ///
+ /// 订阅实体。
+ /// 取消标记。
+ Task UpdateAsync(TenantSubscription subscription, CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region 订阅历史
+
+ ///
+ /// 添加订阅变更历史。
+ ///
+ /// 历史记录实体。
+ /// 取消标记。
+ Task AddHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default);
+
+ ///
+ /// 获取订阅变更历史(含套餐名称)。
+ ///
+ /// 订阅 ID。
+ /// 取消标记。
+ /// 历史记录列表。
+ Task> GetHistoryAsync(
+ long subscriptionId,
+ CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region 配额使用
+
+ ///
+ /// 获取租户配额使用情况。
+ ///
+ /// 租户 ID。
+ /// 取消标记。
+ /// 配额使用列表。
+ Task> GetQuotaUsagesAsync(
+ long tenantId,
+ CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region 通知
+
+ ///
+ /// 添加租户通知。
+ ///
+ /// 通知实体。
+ /// 取消标记。
+ Task AddNotificationAsync(TenantNotification notification, CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region 操作日志
+
+ ///
+ /// 添加操作日志。
+ ///
+ /// 日志实体。
+ /// 取消标记。
+ Task AddOperationLogAsync(OperationLog log, CancellationToken cancellationToken = default);
+
+ #endregion
+
+ ///
+ /// 保存变更。
+ ///
+ /// 取消标记。
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
+
+#region 查询过滤与结果类型
+
+///
+/// 订阅查询过滤条件。
+///
+public record SubscriptionSearchFilter
+{
+ ///
+ /// 订阅状态。
+ ///
+ public SubscriptionStatus? Status { get; init; }
+
+ ///
+ /// 套餐 ID。
+ ///
+ public long? TenantPackageId { get; init; }
+
+ ///
+ /// 租户 ID。
+ ///
+ public long? TenantId { get; init; }
+
+ ///
+ /// 租户关键词(名称或编码)。
+ ///
+ public string? TenantKeyword { get; init; }
+
+ ///
+ /// 即将到期天数。
+ ///
+ public int? ExpiringWithinDays { get; init; }
+
+ ///
+ /// 自动续费状态。
+ ///
+ public bool? AutoRenew { get; init; }
+
+ ///
+ /// 页码(从 1 开始)。
+ ///
+ public int Page { get; init; } = 1;
+
+ ///
+ /// 每页数量。
+ ///
+ public int PageSize { get; init; } = 20;
+}
+
+///
+/// 订阅及关联信息。
+///
+public record SubscriptionWithRelations
+{
+ ///
+ /// 订阅实体。
+ ///
+ public required TenantSubscription Subscription { get; init; }
+
+ ///
+ /// 租户名称。
+ ///
+ public required string TenantName { get; init; }
+
+ ///
+ /// 租户编码。
+ ///
+ public required string TenantCode { get; init; }
+
+ ///
+ /// 套餐名称。
+ ///
+ public required string PackageName { get; init; }
+
+ ///
+ /// 排期套餐名称(可选)。
+ ///
+ public string? ScheduledPackageName { get; init; }
+}
+
+///
+/// 订阅详情信息。
+///
+public record SubscriptionDetailInfo
+{
+ ///
+ /// 订阅实体。
+ ///
+ public required TenantSubscription Subscription { get; init; }
+
+ ///
+ /// 租户名称。
+ ///
+ public required string TenantName { get; init; }
+
+ ///
+ /// 租户编码。
+ ///
+ public required string TenantCode { get; init; }
+
+ ///
+ /// 当前套餐。
+ ///
+ public TenantPackage? Package { get; init; }
+
+ ///
+ /// 排期套餐。
+ ///
+ public TenantPackage? ScheduledPackage { get; init; }
+}
+
+///
+/// 订阅与租户信息。
+///
+public record SubscriptionWithTenant
+{
+ ///
+ /// 订阅实体。
+ ///
+ public required TenantSubscription Subscription { get; init; }
+
+ ///
+ /// 租户实体。
+ ///
+ public required Tenant Tenant { get; init; }
+}
+
+///
+/// 订阅历史(含套餐名称)。
+///
+public record SubscriptionHistoryWithPackageNames
+{
+ ///
+ /// 历史记录实体。
+ ///
+ public required TenantSubscriptionHistory History { get; init; }
+
+ ///
+ /// 原套餐名称。
+ ///
+ public required string FromPackageName { get; init; }
+
+ ///
+ /// 目标套餐名称。
+ ///
+ public required string ToPackageName { get; init; }
+}
+
+#endregion
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs
index 9edf1a4..81e222d 100644
--- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs
@@ -64,4 +64,34 @@ public interface ITenantBillingRepository
/// 取消标记。
/// 异步任务。
Task SaveChangesAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// 管理员端分页查询账单列表(跨租户)。
+ ///
+ /// 租户 ID 筛选(可选)。
+ /// 账单状态筛选(可选)。
+ /// 开始时间(UTC,可选)。
+ /// 结束时间(UTC,可选)。
+ /// 关键词搜索(账单号或租户名)。
+ /// 页码(从 1 开始)。
+ /// 页大小。
+ /// 取消标记。
+ /// 账单集合与总数。
+ Task<(IReadOnlyList Items, int Total)> SearchPagedAsync(
+ long? tenantId,
+ TenantBillingStatus? status,
+ DateTime? from,
+ DateTime? to,
+ string? keyword,
+ int pageNumber,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// 按 ID 获取账单(不限租户,管理员端使用)。
+ ///
+ /// 账单 ID。
+ /// 取消标记。
+ /// 账单实体或 null。
+ Task FindByIdAsync(long billingId, CancellationToken cancellationToken = default);
}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs
new file mode 100644
index 0000000..bb2214c
--- /dev/null
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs
@@ -0,0 +1,48 @@
+using TakeoutSaaS.Domain.Tenants.Entities;
+
+namespace TakeoutSaaS.Domain.Tenants.Repositories;
+
+///
+/// 租户支付记录仓储。
+///
+public interface ITenantPaymentRepository
+{
+ ///
+ /// 查询指定账单的支付记录列表。
+ ///
+ /// 账单 ID。
+ /// 取消标记。
+ /// 支付记录集合。
+ Task> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 按 ID 获取支付记录。
+ ///
+ /// 支付记录 ID。
+ /// 取消标记。
+ /// 支付记录实体或 null。
+ Task FindByIdAsync(long paymentId, CancellationToken cancellationToken = default);
+
+ ///
+ /// 新增支付记录。
+ ///
+ /// 支付记录实体。
+ /// 取消标记。
+ /// 异步任务。
+ Task AddAsync(TenantPayment payment, CancellationToken cancellationToken = default);
+
+ ///
+ /// 更新支付记录。
+ ///
+ /// 支付记录实体。
+ /// 取消标记。
+ /// 异步任务。
+ Task UpdateAsync(TenantPayment payment, CancellationToken cancellationToken = default);
+
+ ///
+ /// 保存变更。
+ ///
+ /// 取消标记。
+ /// 异步任务。
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs
index 6a1fa4d..c8c6a3f 100644
--- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs
@@ -16,6 +16,14 @@ public interface ITenantRepository
/// 租户实体,未找到返回 null。
Task FindByIdAsync(long tenantId, CancellationToken cancellationToken = default);
+ ///
+ /// 批量获取租户。
+ ///
+ /// 租户 ID 列表。
+ /// 取消标记。
+ /// 租户列表(仅返回找到的租户)。
+ Task> FindByIdsAsync(IReadOnlyCollection tenantIds, CancellationToken cancellationToken = default);
+
///
/// 按状态与关键词查询租户列表。
///
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
index 7333909..ab6cb73 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs
@@ -41,12 +41,16 @@ public static class AppServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddOptions()
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
index 01dd78f..73cf234 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs
@@ -62,6 +62,10 @@ public sealed class TakeoutAppDbContext(
///
public DbSet TenantBillingStatements => Set();
///
+ /// 租户支付记录。
+ ///
+ public DbSet TenantPayments => Set();
+ ///
/// 租户通知。
///
public DbSet TenantNotifications => Set();
@@ -86,6 +90,18 @@ public sealed class TakeoutAppDbContext(
///
public DbSet TenantReviewClaims => Set();
///
+ /// 运营操作日志。
+ ///
+ public DbSet OperationLogs => Set();
+ ///
+ /// 配额包定义。
+ ///
+ public DbSet QuotaPackages => Set();
+ ///
+ /// 租户配额包购买记录。
+ ///
+ public DbSet TenantQuotaPackagePurchases => Set();
+ ///
/// 商户实体。
///
public DbSet Merchants => Set();
@@ -374,12 +390,16 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantSubscriptionHistory(modelBuilder.Entity());
ConfigureTenantQuotaUsage(modelBuilder.Entity