feat: 新增配额包/支付相关实体与迁移
App:新增 operation_logs/quota_packages/tenant_payments/tenant_quota_package_purchases 表 Identity:修正 Avatar 字段类型(varchar(256)->text),保持现有数据不变
This commit is contained in:
79
Document/Completed/后端套餐管理.md
Normal file
79
Document/Completed/后端套餐管理.md
Normal file
@@ -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. 操作审计:谁改了套餐、何时改、改了什么(合规必备)
|
||||
121
src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs
Normal file
121
src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 账单管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/bills")]
|
||||
public sealed class BillingsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询账单列表。
|
||||
/// </summary>
|
||||
/// <returns>账单分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("bill:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<BillDto>>> GetList([FromQuery] GetBillListQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
return ApiResponse<PagedResult<BillDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单详情。
|
||||
/// </summary>
|
||||
/// <param name="id">账单 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单详情。</returns>
|
||||
[HttpGet("{id:long}")]
|
||||
[PermissionAuthorize("bill:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BillDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<BillDetailDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<BillDetailDto>> GetDetail(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetBillDetailQuery { BillId = id }, cancellationToken);
|
||||
|
||||
return result is null
|
||||
? ApiResponse<BillDetailDto>.Error(StatusCodes.Status404NotFound, "账单不存在")
|
||||
: ApiResponse<BillDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动创建账单。
|
||||
/// </summary>
|
||||
/// <param name="command">创建账单命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建的账单信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("bill:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BillDto>> Create([FromBody, Required] CreateBillCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<BillDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单状态。
|
||||
/// </summary>
|
||||
/// <param name="id">账单 ID。</param>
|
||||
/// <param name="command">更新状态命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的账单信息。</returns>
|
||||
[HttpPut("{id:long}/status")]
|
||||
[PermissionAuthorize("bill:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<BillDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<BillDto>> 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<BillDto>.Error(StatusCodes.Status404NotFound, "账单不存在")
|
||||
: ApiResponse<BillDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单支付记录。
|
||||
/// </summary>
|
||||
/// <param name="billId">账单 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录列表。</returns>
|
||||
[HttpGet("{billId:long}/payments")]
|
||||
[PermissionAuthorize("bill:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<PaymentDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<PaymentDto>>> GetPayments(long billId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetTenantPaymentsQuery { BillId = billId }, cancellationToken);
|
||||
return ApiResponse<List<PaymentDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录支付(线下支付确认)。
|
||||
/// </summary>
|
||||
/// <param name="billId">账单 ID。</param>
|
||||
/// <param name="command">记录支付命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录信息。</returns>
|
||||
[HttpPost("{billId:long}/payments")]
|
||||
[PermissionAuthorize("bill:pay")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PaymentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PaymentDto>> RecordPayment(long billId, [FromBody, Required] RecordPaymentCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
command = command with { BillId = billId };
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<PaymentDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 配额包管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/quota-packages")]
|
||||
public sealed class QuotaPackagesController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额包列表。
|
||||
/// </summary>
|
||||
/// <param name="query">查询条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额包分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("quota-package:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<QuotaPackageListDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<QuotaPackageListDto>>> List([FromQuery] GetQuotaPackageListQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询配额包分页
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PagedResult<QuotaPackageListDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建配额包。
|
||||
/// </summary>
|
||||
/// <param name="command">创建命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建后的配额包。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("quota-package:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<QuotaPackageDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<QuotaPackageDto>> Create([FromBody, Required] CreateQuotaPackageCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行创建
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<QuotaPackageDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新配额包。
|
||||
/// </summary>
|
||||
/// <param name="quotaPackageId">配额包 ID。</param>
|
||||
/// <param name="command">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的配额包或未找到。</returns>
|
||||
[HttpPut("{quotaPackageId:long}")]
|
||||
[PermissionAuthorize("quota-package:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<QuotaPackageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<QuotaPackageDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<QuotaPackageDto>> 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<QuotaPackageDto>.Error(StatusCodes.Status404NotFound, "配额包不存在")
|
||||
: ApiResponse<QuotaPackageDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除配额包。
|
||||
/// </summary>
|
||||
/// <param name="quotaPackageId">配额包 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{quotaPackageId:long}")]
|
||||
[PermissionAuthorize("quota-package:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<bool>> Delete(long quotaPackageId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 构建删除命令
|
||||
var command = new DeleteQuotaPackageCommand { QuotaPackageId = quotaPackageId };
|
||||
|
||||
// 2. 执行删除并返回
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<bool>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上架/下架配额包。
|
||||
/// </summary>
|
||||
/// <param name="quotaPackageId">配额包 ID。</param>
|
||||
/// <param name="command">状态更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新结果。</returns>
|
||||
[HttpPut("{quotaPackageId:long}/status")]
|
||||
[PermissionAuthorize("quota-package:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<bool>> 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<bool>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为租户购买配额包。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="command">购买命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>购买记录。</returns>
|
||||
[HttpPost("~/api/admin/v{version:apiVersion}/tenants/{tenantId:long}/quota-packages")]
|
||||
[PermissionAuthorize("tenant:quota:purchase")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantQuotaPurchaseDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantQuotaPurchaseDto>> 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<TenantQuotaPurchaseDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 租户配额使用情况。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="query">查询条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额使用情况列表。</returns>
|
||||
[HttpGet("~/api/admin/v{version:apiVersion}/tenants/{tenantId:long}/quota-usage")]
|
||||
[PermissionAuthorize("tenant:quota:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TenantQuotaUsageDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<TenantQuotaUsageDto>>> 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<IReadOnlyList<TenantQuotaUsageDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 租户配额购买记录。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="query">查询条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>购买记录分页结果。</returns>
|
||||
[HttpGet("~/api/admin/v{version:apiVersion}/tenants/{tenantId:long}/quota-purchases")]
|
||||
[PermissionAuthorize("tenant:quota:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantQuotaPurchaseDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<TenantQuotaPurchaseDto>>> 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<PagedResult<TenantQuotaPurchaseDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/statistics")]
|
||||
public sealed class StatisticsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取订阅概览统计。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>订阅概览数据。</returns>
|
||||
[HttpGet("subscription-overview")]
|
||||
[PermissionAuthorize("statistics:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionOverviewDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<SubscriptionOverviewDto>> GetSubscriptionOverview(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetSubscriptionOverviewQuery(), cancellationToken);
|
||||
return ApiResponse<SubscriptionOverviewDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配额使用排行。
|
||||
/// </summary>
|
||||
/// <param name="quotaType">配额类型。</param>
|
||||
/// <param name="topN">返回前N条记录,默认10。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额使用排行数据。</returns>
|
||||
[HttpGet("quota-ranking")]
|
||||
[PermissionAuthorize("statistics:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<QuotaUsageRankingDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<QuotaUsageRankingDto>> 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<QuotaUsageRankingDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取收入统计。
|
||||
/// </summary>
|
||||
/// <param name="monthsCount">统计月份数量,默认12个月。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>收入统计数据。</returns>
|
||||
[HttpGet("revenue")]
|
||||
[PermissionAuthorize("statistics:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<RevenueStatisticsDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<RevenueStatisticsDto>> GetRevenue(
|
||||
[FromQuery] int monthsCount = 12,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = new GetRevenueStatisticsQuery { MonthsCount = monthsCount };
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
return ApiResponse<RevenueStatisticsDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取即将到期的订阅列表。
|
||||
/// </summary>
|
||||
/// <param name="daysAhead">筛选天数,默认7天内到期。</param>
|
||||
/// <param name="onlyWithoutAutoRenew">是否只返回未开启自动续费的订阅。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>即将到期的订阅列表。</returns>
|
||||
[HttpGet("expiring-subscriptions")]
|
||||
[PermissionAuthorize("statistics:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ExpiringSubscriptionDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ExpiringSubscriptionDto>>> 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<IReadOnlyList<ExpiringSubscriptionDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/subscriptions")]
|
||||
public sealed class SubscriptionsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询订阅列表(支持按状态、套餐、到期时间筛选)。
|
||||
/// </summary>
|
||||
/// <param name="query">查询条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>订阅分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("subscription:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<SubscriptionListDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<SubscriptionListDto>>> List(
|
||||
[FromQuery] GetSubscriptionListQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询订阅分页
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PagedResult<SubscriptionListDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查看订阅详情(含套餐信息、配额使用、变更历史)。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">订阅 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>订阅详情或未找到。</returns>
|
||||
[HttpGet("{subscriptionId:long}")]
|
||||
[PermissionAuthorize("subscription:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<SubscriptionDetailDto>> Detail(
|
||||
long subscriptionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询订阅详情
|
||||
var result = await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscriptionId }, cancellationToken);
|
||||
|
||||
// 2. 返回查询结果或 404
|
||||
return result is null
|
||||
? ApiResponse<SubscriptionDetailDto>.Error(StatusCodes.Status404NotFound, "订阅不存在")
|
||||
: ApiResponse<SubscriptionDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新订阅基础信息(备注、自动续费等)。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">订阅 ID。</param>
|
||||
/// <param name="command">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的订阅详情或未找到。</returns>
|
||||
[HttpPut("{subscriptionId:long}")]
|
||||
[PermissionAuthorize("subscription:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<SubscriptionDetailDto>> 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<SubscriptionDetailDto>.Error(StatusCodes.Status404NotFound, "订阅不存在")
|
||||
: ApiResponse<SubscriptionDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 延期订阅(增加订阅时长)。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">订阅 ID。</param>
|
||||
/// <param name="command">延期命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>延期后的订阅详情或未找到。</returns>
|
||||
[HttpPost("{subscriptionId:long}/extend")]
|
||||
[PermissionAuthorize("subscription:extend")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<SubscriptionDetailDto>> 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<SubscriptionDetailDto>.Error(StatusCodes.Status404NotFound, "订阅不存在")
|
||||
: ApiResponse<SubscriptionDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 变更套餐(支持立即生效或下周期生效)。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">订阅 ID。</param>
|
||||
/// <param name="command">变更套餐命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>变更后的订阅详情或未找到。</returns>
|
||||
[HttpPost("{subscriptionId:long}/change-plan")]
|
||||
[PermissionAuthorize("subscription:change-plan")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<SubscriptionDetailDto>> 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<SubscriptionDetailDto>.Error(StatusCodes.Status404NotFound, "订阅不存在")
|
||||
: ApiResponse<SubscriptionDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 变更订阅状态。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">订阅 ID。</param>
|
||||
/// <param name="command">状态变更命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>变更后的订阅详情或未找到。</returns>
|
||||
[HttpPost("{subscriptionId:long}/status")]
|
||||
[PermissionAuthorize("subscription:update-status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<SubscriptionDetailDto>> 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<SubscriptionDetailDto>.Error(StatusCodes.Status404NotFound, "订阅不存在")
|
||||
: ApiResponse<SubscriptionDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量延期订阅。
|
||||
/// </summary>
|
||||
/// <param name="command">批量延期命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>批量延期结果。</returns>
|
||||
[HttpPost("batch-extend")]
|
||||
[PermissionAuthorize("subscription:batch-extend")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchExtendResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchExtendResult>> BatchExtend(
|
||||
[FromBody, Required] BatchExtendSubscriptionsCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<BatchExtendResult>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量发送续费提醒。
|
||||
/// </summary>
|
||||
/// <param name="command">批量发送提醒命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>批量发送提醒结果。</returns>
|
||||
[HttpPost("batch-remind")]
|
||||
[PermissionAuthorize("subscription:batch-remind")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchSendReminderResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchSendReminderResult>> BatchRemind(
|
||||
[FromBody, Required] BatchSendReminderCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<BatchSendReminderResult>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings;
|
||||
|
||||
/// <summary>
|
||||
/// 账单 DTO 映射助手。
|
||||
/// </summary>
|
||||
internal static class BillingMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 将账单实体映射为账单 DTO。
|
||||
/// </summary>
|
||||
/// <param name="bill">账单实体。</param>
|
||||
/// <param name="tenantName">租户名称。</param>
|
||||
/// <returns>账单 DTO。</returns>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将账单实体与支付记录映射为账单详情 DTO。
|
||||
/// </summary>
|
||||
/// <param name="bill">账单实体。</param>
|
||||
/// <param name="payments">支付记录列表。</param>
|
||||
/// <param name="tenantName">租户名称。</param>
|
||||
/// <returns>账单详情 DTO。</returns>
|
||||
public static BillDetailDto ToDetailDto(
|
||||
this TenantBillingStatement bill,
|
||||
List<TenantPayment> 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()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将支付记录实体映射为支付 DTO。
|
||||
/// </summary>
|
||||
/// <param name="payment">支付记录实体。</param>
|
||||
/// <returns>支付 DTO。</returns>
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单命令。
|
||||
/// </summary>
|
||||
public sealed record CreateBillCommand : IRequest<BillDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 记录支付命令。
|
||||
/// </summary>
|
||||
public sealed record RecordPaymentCommand : IRequest<PaymentDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
/// </summary>
|
||||
public string? TransactionNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付凭证 URL。
|
||||
/// </summary>
|
||||
public string? ProofUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单状态命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateBillStatusCommand : IRequest<BillDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 新状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 账单详情 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string? TenantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单编号。
|
||||
/// </summary>
|
||||
public string StatementNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单明细 JSON。
|
||||
/// </summary>
|
||||
public string? LineItemsJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付记录列表。
|
||||
/// </summary>
|
||||
public List<PaymentDto> Payments { get; init; } = new();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 账单 DTO。
|
||||
/// </summary>
|
||||
public sealed record BillDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string? TenantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单编号。
|
||||
/// </summary>
|
||||
public string StatementNo { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 计费周期结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public TenantBillingStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期日(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 支付记录 DTO。
|
||||
/// </summary>
|
||||
public sealed record PaymentDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 支付记录 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long BillingStatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// </summary>
|
||||
public PaymentStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
/// </summary>
|
||||
public string? TransactionNo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付凭证 URL。
|
||||
/// </summary>
|
||||
public string? ProofUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public DateTime? PaidAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateBillCommandHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<CreateBillCommand, BillDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理创建账单请求。
|
||||
/// </summary>
|
||||
/// <param name="request">创建命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单 DTO。</returns>
|
||||
public async Task<BillDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillDetailQueryHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantPaymentRepository paymentRepository,
|
||||
ITenantRepository tenantRepository)
|
||||
: IRequestHandler<GetBillDetailQuery, BillDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理获取账单详情请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单详情或 null。</returns>
|
||||
public async Task<BillDetailDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetBillListQueryHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantRepository tenantRepository)
|
||||
: IRequestHandler<GetBillListQuery, PagedResult<BillDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理获取账单列表请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页账单列表。</returns>
|
||||
public async Task<PagedResult<BillDto>> 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<BillDto>([], 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<BillDto>(result, request.PageNumber, request.PageSize, total);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户支付记录查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetTenantPaymentsQueryHandler(ITenantPaymentRepository paymentRepository)
|
||||
: IRequestHandler<GetTenantPaymentsQuery, List<PaymentDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理获取支付记录请求。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录列表。</returns>
|
||||
public async Task<List<PaymentDto>> Handle(GetTenantPaymentsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询支付记录
|
||||
var payments = await paymentRepository.GetByBillingIdAsync(request.BillId, cancellationToken);
|
||||
|
||||
// 2. 映射并返回 DTO
|
||||
return payments.Select(p => p.ToDto()).ToList();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 记录支付处理器。
|
||||
/// </summary>
|
||||
public sealed class RecordPaymentCommandHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantPaymentRepository paymentRepository)
|
||||
: IRequestHandler<RecordPaymentCommand, PaymentDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理记录支付请求。
|
||||
/// </summary>
|
||||
/// <param name="request">记录支付命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付 DTO。</returns>
|
||||
public async Task<PaymentDto> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单状态处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateBillStatusCommandHandler(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantRepository tenantRepository)
|
||||
: IRequestHandler<UpdateBillStatusCommand, BillDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理更新账单状态请求。
|
||||
/// </summary>
|
||||
/// <param name="request">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单 DTO 或 null。</returns>
|
||||
public async Task<BillDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单详情查询。
|
||||
/// </summary>
|
||||
public sealed record GetBillDetailQuery : IRequest<BillDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillId { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单列表查询。
|
||||
/// </summary>
|
||||
public sealed record GetBillListQuery : IRequest<PagedResult<BillDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int PageNumber { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 页大小。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID 筛选(可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(可选)。
|
||||
/// </summary>
|
||||
public TenantBillingStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期筛选(可选)。
|
||||
/// </summary>
|
||||
public DateTime? StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期筛选(可选)。
|
||||
/// </summary>
|
||||
public DateTime? EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 搜索关键词(账单号或租户名)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户支付记录查询。
|
||||
/// </summary>
|
||||
public sealed record GetTenantPaymentsQuery : IRequest<List<PaymentDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法)。
|
||||
/// </summary>
|
||||
public long BillId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 创建配额包命令。
|
||||
/// </summary>
|
||||
public sealed record CreateQuotaPackageCommand : IRequest<QuotaPackageDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额包名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额数值。
|
||||
/// </summary>
|
||||
public decimal QuotaValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 价格。
|
||||
/// </summary>
|
||||
public decimal Price { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否上架。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除配额包命令。
|
||||
/// </summary>
|
||||
public sealed record DeleteQuotaPackageCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额包 ID。
|
||||
/// </summary>
|
||||
public long QuotaPackageId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 为租户购买配额包命令。
|
||||
/// </summary>
|
||||
public sealed record PurchaseQuotaPackageCommand : IRequest<TenantQuotaPurchaseDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额包 ID。
|
||||
/// </summary>
|
||||
public long QuotaPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(可选)。
|
||||
/// </summary>
|
||||
public DateTime? ExpiredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新配额包命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateQuotaPackageCommand : IRequest<QuotaPackageDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额包 ID。
|
||||
/// </summary>
|
||||
public long QuotaPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额包名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额数值。
|
||||
/// </summary>
|
||||
public decimal QuotaValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 价格。
|
||||
/// </summary>
|
||||
public decimal Price { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否上架。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新配额包状态命令(上架/下架)。
|
||||
/// </summary>
|
||||
public sealed record UpdateQuotaPackageStatusCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额包 ID。
|
||||
/// </summary>
|
||||
public long QuotaPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否上架。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 配额包 DTO。
|
||||
/// </summary>
|
||||
public sealed record QuotaPackageDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额包 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额包名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额数值。
|
||||
/// </summary>
|
||||
public decimal QuotaValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 价格。
|
||||
/// </summary>
|
||||
public decimal Price { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否上架。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 配额包列表 DTO。
|
||||
/// </summary>
|
||||
public sealed record QuotaPackageListDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额包 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额包名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额数值。
|
||||
/// </summary>
|
||||
public decimal QuotaValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 价格。
|
||||
/// </summary>
|
||||
public decimal Price { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否上架。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 租户配额购买记录 DTO。
|
||||
/// </summary>
|
||||
public sealed record TenantQuotaPurchaseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 购买记录 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额包 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long QuotaPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额包名称。
|
||||
/// </summary>
|
||||
public string QuotaPackageName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 购买时的配额值。
|
||||
/// </summary>
|
||||
public decimal QuotaValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 购买价格。
|
||||
/// </summary>
|
||||
public decimal Price { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 购买时间。
|
||||
/// </summary>
|
||||
public DateTime PurchasedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(可选)。
|
||||
/// </summary>
|
||||
public DateTime? ExpiredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 租户配额使用情况 DTO。
|
||||
/// </summary>
|
||||
public sealed record TenantQuotaUsageDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额上限。
|
||||
/// </summary>
|
||||
public decimal LimitValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已使用值。
|
||||
/// </summary>
|
||||
public decimal UsedValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余值。
|
||||
/// </summary>
|
||||
public decimal RemainingValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额刷新周期。
|
||||
/// </summary>
|
||||
public string? ResetCycle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近一次重置时间。
|
||||
/// </summary>
|
||||
public DateTime? LastResetAt { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 创建配额包命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateQuotaPackageCommandHandler(
|
||||
IQuotaPackageRepository quotaPackageRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<CreateQuotaPackageCommand, QuotaPackageDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<QuotaPackageDto> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 删除配额包命令处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteQuotaPackageCommandHandler(IQuotaPackageRepository quotaPackageRepository)
|
||||
: IRequestHandler<DeleteQuotaPackageCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取配额包列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetQuotaPackageListQueryHandler(IQuotaPackageRepository quotaPackageRepository)
|
||||
: IRequestHandler<GetQuotaPackageListQuery, PagedResult<QuotaPackageListDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<QuotaPackageListDto>> 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<QuotaPackageListDto>(dtos, request.Page, request.PageSize, total);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户配额购买记录查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetTenantQuotaPurchasesQueryHandler(IQuotaPackageRepository quotaPackageRepository)
|
||||
: IRequestHandler<GetTenantQuotaPurchasesQuery, PagedResult<TenantQuotaPurchaseDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<TenantQuotaPurchaseDto>> 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<TenantQuotaPurchaseDto>(dtos, request.Page, request.PageSize, total);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户配额使用情况查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetTenantQuotaUsageQueryHandler(IQuotaPackageRepository quotaPackageRepository)
|
||||
: IRequestHandler<GetTenantQuotaUsageQuery, IReadOnlyList<TenantQuotaUsageDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantQuotaUsageDto>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 购买配额包命令处理器。
|
||||
/// </summary>
|
||||
public sealed class PurchaseQuotaPackageCommandHandler(
|
||||
IQuotaPackageRepository quotaPackageRepository,
|
||||
IIdGenerator idGenerator)
|
||||
: IRequestHandler<PurchaseQuotaPackageCommand, TenantQuotaPurchaseDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantQuotaPurchaseDto> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 更新配额包命令处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateQuotaPackageCommandHandler(IQuotaPackageRepository quotaPackageRepository)
|
||||
: IRequestHandler<UpdateQuotaPackageCommand, QuotaPackageDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<QuotaPackageDto?> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新配额包状态命令处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateQuotaPackageStatusCommandHandler(IQuotaPackageRepository quotaPackageRepository)
|
||||
: IRequestHandler<UpdateQuotaPackageStatusCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取配额包列表查询。
|
||||
/// </summary>
|
||||
public sealed record GetQuotaPackageListQuery : IRequest<PagedResult<QuotaPackageListDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额类型(可选筛选)。
|
||||
/// </summary>
|
||||
public TenantQuotaType? QuotaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(可选筛选)。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页大小。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户配额购买记录查询。
|
||||
/// </summary>
|
||||
public sealed record GetTenantQuotaPurchasesQuery : IRequest<PagedResult<TenantQuotaPurchaseDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页大小。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户配额使用情况查询。
|
||||
/// </summary>
|
||||
public sealed record GetTenantQuotaUsageQuery : IRequest<IReadOnlyList<TenantQuotaUsageDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额类型(可选筛选)。
|
||||
/// </summary>
|
||||
public TenantQuotaType? QuotaType { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Statistics.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 即将到期的订阅项。
|
||||
/// </summary>
|
||||
public record ExpiringSubscriptionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户ID。
|
||||
/// </summary>
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 套餐名称。
|
||||
/// </summary>
|
||||
public string PackageName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅状态。
|
||||
/// </summary>
|
||||
public SubscriptionStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期时间。
|
||||
/// </summary>
|
||||
public DateTime EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余天数。
|
||||
/// </summary>
|
||||
public int DaysRemaining { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启自动续费。
|
||||
/// </summary>
|
||||
public bool AutoRenew { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Statistics.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 配额使用排行。
|
||||
/// </summary>
|
||||
public record QuotaUsageRankingDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排行列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<QuotaUsageRankItem> Rankings { get; init; } = Array.Empty<QuotaUsageRankItem>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配额使用排行项。
|
||||
/// </summary>
|
||||
public record QuotaUsageRankItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户ID。
|
||||
/// </summary>
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 已使用值。
|
||||
/// </summary>
|
||||
public decimal UsedValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 限制值。
|
||||
/// </summary>
|
||||
public decimal LimitValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用百分比。
|
||||
/// </summary>
|
||||
public decimal UsagePercentage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace TakeoutSaaS.Application.App.Statistics.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 收入统计。
|
||||
/// </summary>
|
||||
public record RevenueStatisticsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 总收入。
|
||||
/// </summary>
|
||||
public decimal TotalRevenue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月收入。
|
||||
/// </summary>
|
||||
public decimal MonthlyRevenue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本季度收入。
|
||||
/// </summary>
|
||||
public decimal QuarterlyRevenue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度收入明细。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MonthlyRevenueItem> MonthlyDetails { get; init; } = Array.Empty<MonthlyRevenueItem>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 月度收入项。
|
||||
/// </summary>
|
||||
public record MonthlyRevenueItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 年份。
|
||||
/// </summary>
|
||||
public int Year { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 月份。
|
||||
/// </summary>
|
||||
public int Month { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 收入金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单数量。
|
||||
/// </summary>
|
||||
public int BillCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TakeoutSaaS.Application.App.Statistics.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅概览。
|
||||
/// </summary>
|
||||
public record SubscriptionOverviewDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活跃订阅总数。
|
||||
/// </summary>
|
||||
public int TotalActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 7天内到期数量。
|
||||
/// </summary>
|
||||
public int ExpiringIn7Days { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 3天内到期数量。
|
||||
/// </summary>
|
||||
public int ExpiringIn3Days { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 1天内到期数量。
|
||||
/// </summary>
|
||||
public int ExpiringIn1Day { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已过期数量。
|
||||
/// </summary>
|
||||
public int Expired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 待激活数量。
|
||||
/// </summary>
|
||||
public int Pending { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已暂停数量。
|
||||
/// </summary>
|
||||
public int Suspended { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取即将到期的订阅列表处理器。
|
||||
/// </summary>
|
||||
public sealed class GetExpiringSubscriptionsQueryHandler(IStatisticsRepository statisticsRepository)
|
||||
: IRequestHandler<GetExpiringSubscriptionsQuery, IReadOnlyList<ExpiringSubscriptionDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExpiringSubscriptionDto>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取配额使用排行处理器。
|
||||
/// </summary>
|
||||
public sealed class GetQuotaUsageRankingQueryHandler(IStatisticsRepository statisticsRepository)
|
||||
: IRequestHandler<GetQuotaUsageRankingQuery, QuotaUsageRankingDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<QuotaUsageRankingDto> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取收入统计处理器。
|
||||
/// </summary>
|
||||
public sealed class GetRevenueStatisticsQueryHandler(IStatisticsRepository statisticsRepository)
|
||||
: IRequestHandler<GetRevenueStatisticsQuery, RevenueStatisticsDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<RevenueStatisticsDto> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取季度开始时间。
|
||||
/// </summary>
|
||||
private static DateTime GetQuarterStart(DateTime date)
|
||||
{
|
||||
var quarter = (date.Month - 1) / 3;
|
||||
var quarterStartMonth = quarter * 3 + 1;
|
||||
return new DateTime(date.Year, quarterStartMonth, 1);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 获取订阅概览统计处理器。
|
||||
/// </summary>
|
||||
public sealed class GetSubscriptionOverviewQueryHandler(IStatisticsRepository statisticsRepository)
|
||||
: IRequestHandler<GetSubscriptionOverviewQuery, SubscriptionOverviewDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SubscriptionOverviewDto> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Statistics.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取即将到期的订阅列表。
|
||||
/// </summary>
|
||||
public sealed record GetExpiringSubscriptionsQuery : IRequest<IReadOnlyList<ExpiringSubscriptionDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 筛选天数,默认7天内到期。
|
||||
/// </summary>
|
||||
public int DaysAhead { get; init; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// 是否只返回未开启自动续费的订阅。
|
||||
/// </summary>
|
||||
public bool OnlyWithoutAutoRenew { get; init; } = false;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Statistics.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取配额使用排行。
|
||||
/// </summary>
|
||||
public sealed record GetQuotaUsageRankingQuery : IRequest<QuotaUsageRankingDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回前N条记录,默认前10。
|
||||
/// </summary>
|
||||
public int TopN { get; init; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Statistics.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取收入统计。
|
||||
/// </summary>
|
||||
public sealed record GetRevenueStatisticsQuery : IRequest<RevenueStatisticsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 统计月份数量,默认12个月。
|
||||
/// </summary>
|
||||
public int MonthsCount { get; init; } = 12;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Statistics.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取订阅概览统计。
|
||||
/// </summary>
|
||||
public sealed record GetSubscriptionOverviewQuery : IRequest<SubscriptionOverviewDto>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 批量延期订阅命令。
|
||||
/// </summary>
|
||||
public sealed record BatchExtendSubscriptionsCommand : IRequest<BatchExtendResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅ID列表。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1, ErrorMessage = "至少需要选择一个订阅")]
|
||||
public IReadOnlyList<long> SubscriptionIds { get; init; } = Array.Empty<long>();
|
||||
|
||||
/// <summary>
|
||||
/// 延期时长(天)。
|
||||
/// </summary>
|
||||
[Range(1, 3650, ErrorMessage = "延期天数必须在1-3650天之间")]
|
||||
public int? DurationDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 延期时长(月)。
|
||||
/// </summary>
|
||||
[Range(1, 120, ErrorMessage = "延期月数必须在1-120月之间")]
|
||||
public int? DurationMonths { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量延期结果。
|
||||
/// </summary>
|
||||
public record BatchExtendResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 成功数量。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败数量。
|
||||
/// </summary>
|
||||
public int FailureCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败详情列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<BatchFailureItem> Failures { get; init; } = Array.Empty<BatchFailureItem>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量操作失败项。
|
||||
/// </summary>
|
||||
public record BatchFailureItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅ID。
|
||||
/// </summary>
|
||||
public long SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败原因。
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 批量发送续费提醒命令。
|
||||
/// </summary>
|
||||
public sealed record BatchSendReminderCommand : IRequest<BatchSendReminderResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅ID列表。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1, ErrorMessage = "至少需要选择一个订阅")]
|
||||
public IReadOnlyList<long> SubscriptionIds { get; init; } = Array.Empty<long>();
|
||||
|
||||
/// <summary>
|
||||
/// 提醒内容。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "提醒内容不能为空")]
|
||||
[MaxLength(1000, ErrorMessage = "提醒内容不能超过1000字符")]
|
||||
public string ReminderContent { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量发送提醒结果。
|
||||
/// </summary>
|
||||
public record BatchSendReminderResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 成功发送数量。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送失败数量。
|
||||
/// </summary>
|
||||
public int FailureCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败详情列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<BatchFailureItem> Failures { get; init; } = Array.Empty<BatchFailureItem>();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Subscriptions.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 变更套餐命令。
|
||||
/// </summary>
|
||||
public sealed record ChangeSubscriptionPlanCommand : IRequest<SubscriptionDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅 ID(从路由参数绑定)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标套餐 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long TargetPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否立即生效,否则在下周期生效。
|
||||
/// </summary>
|
||||
public bool Immediate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Subscriptions.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 延期订阅命令。
|
||||
/// </summary>
|
||||
public sealed record ExtendSubscriptionCommand : IRequest<SubscriptionDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅 ID(从路由参数绑定)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 延期时长(月)。
|
||||
/// </summary>
|
||||
[Range(1, 120)]
|
||||
public int DurationMonths { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Subscriptions.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新订阅基础信息命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateSubscriptionCommand : IRequest<SubscriptionDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅 ID(从路由参数绑定)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否自动续费。
|
||||
/// </summary>
|
||||
public bool? AutoRenew { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 运营备注信息。
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 更新订阅状态命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateSubscriptionStatusCommand : IRequest<SubscriptionDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅 ID(从路由参数绑定)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long SubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标状态。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public SubscriptionStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 配额使用 DTO。
|
||||
/// </summary>
|
||||
public sealed record QuotaUsageDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额上限。
|
||||
/// </summary>
|
||||
public decimal LimitValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已使用量。
|
||||
/// </summary>
|
||||
public decimal UsedValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用率(百分比)。
|
||||
/// </summary>
|
||||
public decimal UsagePercentage => LimitValue > 0 ? Math.Round(UsedValue / LimitValue * 100, 2) : 0;
|
||||
|
||||
/// <summary>
|
||||
/// 剩余额度。
|
||||
/// </summary>
|
||||
public decimal RemainingValue => Math.Max(0, LimitValue - UsedValue);
|
||||
|
||||
/// <summary>
|
||||
/// 重置周期描述。
|
||||
/// </summary>
|
||||
public string? ResetCycle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近一次重置时间。
|
||||
/// </summary>
|
||||
public DateTime? LastResetAt { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅详情 DTO。
|
||||
/// </summary>
|
||||
public sealed record SubscriptionDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
public string TenantCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 当前套餐 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前套餐信息。
|
||||
/// </summary>
|
||||
public TenantPackageDto? Package { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排期套餐 ID(下周期生效)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long? ScheduledPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排期套餐信息。
|
||||
/// </summary>
|
||||
public TenantPackageDto? ScheduledPackage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订阅状态。
|
||||
/// </summary>
|
||||
public SubscriptionStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 生效时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次计费时间。
|
||||
/// </summary>
|
||||
public DateTime? NextBillingDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否自动续费。
|
||||
/// </summary>
|
||||
public bool AutoRenew { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额使用情况列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<QuotaUsageDto> QuotaUsages { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 订阅变更历史列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<SubscriptionHistoryDto> ChangeHistory { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅变更历史 DTO。
|
||||
/// </summary>
|
||||
public sealed record SubscriptionHistoryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 历史记录 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订阅 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantSubscriptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 原套餐 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long FromPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 原套餐名称。
|
||||
/// </summary>
|
||||
public string FromPackageName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 新套餐 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long ToPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 新套餐名称。
|
||||
/// </summary>
|
||||
public string ToPackageName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 变更类型。
|
||||
/// </summary>
|
||||
public SubscriptionChangeType ChangeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 生效时间。
|
||||
/// </summary>
|
||||
public DateTime EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期时间。
|
||||
/// </summary>
|
||||
public DateTime EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 相关费用。
|
||||
/// </summary>
|
||||
public decimal? Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 币种。
|
||||
/// </summary>
|
||||
public string? Currency { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅列表 DTO。
|
||||
/// </summary>
|
||||
public sealed record SubscriptionListDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
public string TenantCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 当前套餐 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前套餐名称。
|
||||
/// </summary>
|
||||
public string PackageName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排期套餐 ID(下周期生效)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long? ScheduledPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排期套餐名称。
|
||||
/// </summary>
|
||||
public string? ScheduledPackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订阅状态。
|
||||
/// </summary>
|
||||
public SubscriptionStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 生效时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次计费时间。
|
||||
/// </summary>
|
||||
public DateTime? NextBillingDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否自动续费。
|
||||
/// </summary>
|
||||
public bool AutoRenew { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 批量延期订阅命令处理器。
|
||||
/// </summary>
|
||||
public sealed class BatchExtendSubscriptionsCommandHandler(
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
IIdGenerator idGenerator,
|
||||
ILogger<BatchExtendSubscriptionsCommandHandler> logger)
|
||||
: IRequestHandler<BatchExtendSubscriptionsCommand, BatchExtendResult>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<BatchExtendResult> Handle(BatchExtendSubscriptionsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var successCount = 0;
|
||||
var failures = new List<BatchFailureItem>();
|
||||
|
||||
// 验证参数
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 批量发送续费提醒命令处理器。
|
||||
/// </summary>
|
||||
public sealed class BatchSendReminderCommandHandler(
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
IIdGenerator idGenerator,
|
||||
ILogger<BatchSendReminderCommandHandler> logger)
|
||||
: IRequestHandler<BatchSendReminderCommand, BatchSendReminderResult>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<BatchSendReminderResult> Handle(BatchSendReminderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var successCount = 0;
|
||||
var failures = new List<BatchFailureItem>();
|
||||
|
||||
// 查询所有订阅及租户信息
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 变更套餐命令处理器。
|
||||
/// </summary>
|
||||
public sealed class ChangeSubscriptionPlanCommandHandler(
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
IIdGenerator idGenerator,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<ChangeSubscriptionPlanCommand, SubscriptionDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SubscriptionDetailDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 延期订阅命令处理器。
|
||||
/// </summary>
|
||||
public sealed class ExtendSubscriptionCommandHandler(
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
IIdGenerator idGenerator,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<ExtendSubscriptionCommand, SubscriptionDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SubscriptionDetailDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetSubscriptionDetailQueryHandler(ISubscriptionRepository subscriptionRepository)
|
||||
: IRequestHandler<GetSubscriptionDetailQuery, SubscriptionDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SubscriptionDetailDto?> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetSubscriptionListQueryHandler(ISubscriptionRepository subscriptionRepository)
|
||||
: IRequestHandler<GetSubscriptionListQuery, PagedResult<SubscriptionListDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<SubscriptionListDto>> 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<SubscriptionListDto>(dtos, request.Page, request.PageSize, total);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 更新订阅基础信息命令处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateSubscriptionCommandHandler(
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<UpdateSubscriptionCommand, SubscriptionDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SubscriptionDetailDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 更新订阅状态命令处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateSubscriptionStatusCommandHandler(
|
||||
ISubscriptionRepository subscriptionRepository,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<UpdateSubscriptionStatusCommand, SubscriptionDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SubscriptionDetailDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Subscriptions.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Subscriptions.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询订阅详情(含套餐信息、配额使用、变更历史)。
|
||||
/// </summary>
|
||||
public sealed record GetSubscriptionDetailQuery : IRequest<SubscriptionDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅 ID。
|
||||
/// </summary>
|
||||
public long SubscriptionId { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅分页查询。
|
||||
/// </summary>
|
||||
public sealed record GetSubscriptionListQuery : IRequest<PagedResult<SubscriptionListDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅状态(精确匹配)。
|
||||
/// </summary>
|
||||
public SubscriptionStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 套餐 ID(精确匹配)。
|
||||
/// </summary>
|
||||
public long? TenantPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID(精确匹配)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户关键词(名称或编码模糊匹配)。
|
||||
/// </summary>
|
||||
public string? TenantKeyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 到期时间筛选:未来 N 天内到期。
|
||||
/// </summary>
|
||||
public int? ExpiringWithinDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否自动续费筛选。
|
||||
/// </summary>
|
||||
public bool? AutoRenew { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页大小。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 运营操作日志。
|
||||
/// </summary>
|
||||
public sealed class OperationLog : AuditableEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作类型:BatchExtend, BatchRemind, StatusChange 等。
|
||||
/// </summary>
|
||||
public string OperationType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型:Subscription, Bill 等。
|
||||
/// </summary>
|
||||
public string TargetType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标ID列表(JSON)。
|
||||
/// </summary>
|
||||
public string? TargetIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作人ID。
|
||||
/// </summary>
|
||||
public string? OperatorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作人名称。
|
||||
/// </summary>
|
||||
public string? OperatorName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作参数(JSON)。
|
||||
/// </summary>
|
||||
public string? Parameters { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作结果(JSON)。
|
||||
/// </summary>
|
||||
public string? Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否成功。
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 配额包定义(平台提供的可购买配额包)。
|
||||
/// </summary>
|
||||
public sealed class QuotaPackage : AuditableEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额包名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额数值。
|
||||
/// </summary>
|
||||
public decimal QuotaValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 价格。
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否上架。
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 租户支付记录。
|
||||
/// </summary>
|
||||
public sealed class TenantPayment : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 关联的账单 ID。
|
||||
/// </summary>
|
||||
public long BillingStatementId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public PaymentMethod Method { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// </summary>
|
||||
public PaymentStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 交易号。
|
||||
/// </summary>
|
||||
public string? TransactionNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付凭证 URL。
|
||||
/// </summary>
|
||||
public string? ProofUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public DateTime? PaidAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// </summary>
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 租户配额包购买记录。
|
||||
/// </summary>
|
||||
public sealed class TenantQuotaPackagePurchase : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额包 ID。
|
||||
/// </summary>
|
||||
public long QuotaPackageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 购买时的配额值。
|
||||
/// </summary>
|
||||
public decimal QuotaValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 购买价格。
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 购买时间。
|
||||
/// </summary>
|
||||
public DateTime PurchasedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(可选)。
|
||||
/// </summary>
|
||||
public DateTime? ExpiredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
22
src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentMethod.cs
Normal file
22
src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentMethod.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public enum PaymentMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// 线上支付。
|
||||
/// </summary>
|
||||
Online = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 银行转账。
|
||||
/// </summary>
|
||||
BankTransfer = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 其他方式。
|
||||
/// </summary>
|
||||
Other = 2
|
||||
}
|
||||
27
src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentStatus.cs
Normal file
27
src/Domain/TakeoutSaaS.Domain/Tenants/Enums/PaymentStatus.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// </summary>
|
||||
public enum PaymentStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 待支付。
|
||||
/// </summary>
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 支付成功。
|
||||
/// </summary>
|
||||
Success = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 支付失败。
|
||||
/// </summary>
|
||||
Failed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 已退款。
|
||||
/// </summary>
|
||||
Refunded = 3
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 配额包仓储。
|
||||
/// </summary>
|
||||
public interface IQuotaPackageRepository
|
||||
{
|
||||
#region 配额包定义
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 查找配额包。
|
||||
/// </summary>
|
||||
/// <param name="id">配额包 ID(雪花算法)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额包实体,未找到返回 null。</returns>
|
||||
Task<QuotaPackage?> FindByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询配额包。
|
||||
/// </summary>
|
||||
/// <param name="quotaType">配额类型,为空不按类型过滤。</param>
|
||||
/// <param name="isActive">启用状态,为空不按状态过滤。</param>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页大小。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页数据与总数。</returns>
|
||||
Task<(IReadOnlyList<QuotaPackage> Items, int Total)> SearchPagedAsync(
|
||||
TenantQuotaType? quotaType,
|
||||
bool? isActive,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增配额包。
|
||||
/// </summary>
|
||||
/// <param name="quotaPackage">配额包实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task AddAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新配额包。
|
||||
/// </summary>
|
||||
/// <param name="quotaPackage">配额包实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task UpdateAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 软删除配额包。
|
||||
/// </summary>
|
||||
/// <param name="id">配额包 ID(雪花算法)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>删除成功返回 true,未找到返回 false。</returns>
|
||||
Task<bool> SoftDeleteAsync(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额包购买记录
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询租户配额购买记录(包含配额包信息)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID(雪花算法)。</param>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页大小。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页数据与总数。</returns>
|
||||
Task<(IReadOnlyList<(TenantQuotaPackagePurchase Purchase, QuotaPackage Package)> Items, int Total)> GetPurchasesPagedAsync(
|
||||
long tenantId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增配额购买记录。
|
||||
/// </summary>
|
||||
/// <param name="purchase">购买记录实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task AddPurchaseAsync(TenantQuotaPackagePurchase purchase, CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额使用情况
|
||||
|
||||
/// <summary>
|
||||
/// 查询租户配额使用情况。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID(雪花算法)。</param>
|
||||
/// <param name="quotaType">配额类型,为空查询全部。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额使用情况列表。</returns>
|
||||
Task<IReadOnlyList<TenantQuotaUsage>> GetUsageByTenantAsync(
|
||||
long tenantId,
|
||||
TenantQuotaType? quotaType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查找特定配额使用记录。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID(雪花算法)。</param>
|
||||
/// <param name="quotaType">配额类型。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额使用记录,未找到返回 null。</returns>
|
||||
Task<TenantQuotaUsage?> FindUsageAsync(
|
||||
long tenantId,
|
||||
TenantQuotaType quotaType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新配额使用情况。
|
||||
/// </summary>
|
||||
/// <param name="usage">配额使用实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task UpdateUsageAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// 持久化。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据仓储接口。
|
||||
/// </summary>
|
||||
public interface IStatisticsRepository
|
||||
{
|
||||
#region 订阅统计
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有订阅(用于统计)。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>所有订阅记录。</returns>
|
||||
Task<IReadOnlyList<TenantSubscription>> GetAllSubscriptionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取即将到期的订阅(含租户和套餐信息)。
|
||||
/// </summary>
|
||||
/// <param name="daysAhead">到期天数。</param>
|
||||
/// <param name="onlyWithoutAutoRenew">是否仅查询未开启自动续费的。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>即将到期的订阅信息列表。</returns>
|
||||
Task<IReadOnlyList<ExpiringSubscriptionInfo>> GetExpiringSubscriptionsAsync(
|
||||
int daysAhead,
|
||||
bool onlyWithoutAutoRenew,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 收入统计
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有已付款账单(用于收入统计)。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>已付款账单列表。</returns>
|
||||
Task<IReadOnlyList<TenantBillingStatement>> GetPaidBillsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额使用排行
|
||||
|
||||
/// <summary>
|
||||
/// 获取配额使用排行(含租户信息)。
|
||||
/// </summary>
|
||||
/// <param name="quotaType">配额类型。</param>
|
||||
/// <param name="topN">前 N 名。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额使用排行列表。</returns>
|
||||
Task<IReadOnlyList<QuotaUsageRankInfo>> GetQuotaUsageRankingAsync(
|
||||
TenantQuotaType quotaType,
|
||||
int topN,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 即将到期的订阅信息(含关联数据)。
|
||||
/// </summary>
|
||||
public record ExpiringSubscriptionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅实体。
|
||||
/// </summary>
|
||||
public required TenantSubscription Subscription { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public required string TenantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 套餐名称。
|
||||
/// </summary>
|
||||
public required string PackageName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配额使用排行信息(含租户名称)。
|
||||
/// </summary>
|
||||
public record QuotaUsageRankInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public required string TenantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已使用值。
|
||||
/// </summary>
|
||||
public decimal UsedValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 限制值。
|
||||
/// </summary>
|
||||
public decimal LimitValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用百分比。
|
||||
/// </summary>
|
||||
public decimal UsagePercentage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅管理仓储接口。
|
||||
/// </summary>
|
||||
public interface ISubscriptionRepository
|
||||
{
|
||||
#region 订阅查询
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 查询订阅。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">订阅 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>订阅实体,未找到返回 null。</returns>
|
||||
Task<TenantSubscription?> FindByIdAsync(long subscriptionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 列表批量查询订阅。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionIds">订阅 ID 列表。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>订阅实体列表。</returns>
|
||||
Task<IReadOnlyList<TenantSubscription>> FindByIdsAsync(
|
||||
IEnumerable<long> subscriptionIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询订阅列表(含关联信息)。
|
||||
/// </summary>
|
||||
/// <param name="filter">查询过滤条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页结果。</returns>
|
||||
Task<(IReadOnlyList<SubscriptionWithRelations> Items, int Total)> SearchPagedAsync(
|
||||
SubscriptionSearchFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取订阅详情(含关联信息)。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">订阅 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>订阅详情信息。</returns>
|
||||
Task<SubscriptionDetailInfo?> GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 列表批量查询订阅(含租户信息)。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionIds">订阅 ID 列表。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>订阅与租户信息列表。</returns>
|
||||
Task<IReadOnlyList<SubscriptionWithTenant>> FindByIdsWithTenantAsync(
|
||||
IEnumerable<long> subscriptionIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 套餐查询
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 查询套餐。
|
||||
/// </summary>
|
||||
/// <param name="packageId">套餐 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>套餐实体,未找到返回 null。</returns>
|
||||
Task<TenantPackage?> FindPackageByIdAsync(long packageId, CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 订阅更新
|
||||
|
||||
/// <summary>
|
||||
/// 更新订阅。
|
||||
/// </summary>
|
||||
/// <param name="subscription">订阅实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task UpdateAsync(TenantSubscription subscription, CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 订阅历史
|
||||
|
||||
/// <summary>
|
||||
/// 添加订阅变更历史。
|
||||
/// </summary>
|
||||
/// <param name="history">历史记录实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task AddHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取订阅变更历史(含套餐名称)。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">订阅 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>历史记录列表。</returns>
|
||||
Task<IReadOnlyList<SubscriptionHistoryWithPackageNames>> GetHistoryAsync(
|
||||
long subscriptionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额使用
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户配额使用情况。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额使用列表。</returns>
|
||||
Task<IReadOnlyList<TenantQuotaUsage>> GetQuotaUsagesAsync(
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 通知
|
||||
|
||||
/// <summary>
|
||||
/// 添加租户通知。
|
||||
/// </summary>
|
||||
/// <param name="notification">通知实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task AddNotificationAsync(TenantNotification notification, CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 操作日志
|
||||
|
||||
/// <summary>
|
||||
/// 添加操作日志。
|
||||
/// </summary>
|
||||
/// <param name="log">日志实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task AddOperationLogAsync(OperationLog log, CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// 保存变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region 查询过滤与结果类型
|
||||
|
||||
/// <summary>
|
||||
/// 订阅查询过滤条件。
|
||||
/// </summary>
|
||||
public record SubscriptionSearchFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅状态。
|
||||
/// </summary>
|
||||
public SubscriptionStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 套餐 ID。
|
||||
/// </summary>
|
||||
public long? TenantPackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户关键词(名称或编码)。
|
||||
/// </summary>
|
||||
public string? TenantKeyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 即将到期天数。
|
||||
/// </summary>
|
||||
public int? ExpiringWithinDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 自动续费状态。
|
||||
/// </summary>
|
||||
public bool? AutoRenew { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页数量。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅及关联信息。
|
||||
/// </summary>
|
||||
public record SubscriptionWithRelations
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅实体。
|
||||
/// </summary>
|
||||
public required TenantSubscription Subscription { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public required string TenantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
public required string TenantCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 套餐名称。
|
||||
/// </summary>
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排期套餐名称(可选)。
|
||||
/// </summary>
|
||||
public string? ScheduledPackageName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅详情信息。
|
||||
/// </summary>
|
||||
public record SubscriptionDetailInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅实体。
|
||||
/// </summary>
|
||||
public required TenantSubscription Subscription { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public required string TenantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
public required string TenantCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前套餐。
|
||||
/// </summary>
|
||||
public TenantPackage? Package { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排期套餐。
|
||||
/// </summary>
|
||||
public TenantPackage? ScheduledPackage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅与租户信息。
|
||||
/// </summary>
|
||||
public record SubscriptionWithTenant
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅实体。
|
||||
/// </summary>
|
||||
public required TenantSubscription Subscription { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户实体。
|
||||
/// </summary>
|
||||
public required Tenant Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅历史(含套餐名称)。
|
||||
/// </summary>
|
||||
public record SubscriptionHistoryWithPackageNames
|
||||
{
|
||||
/// <summary>
|
||||
/// 历史记录实体。
|
||||
/// </summary>
|
||||
public required TenantSubscriptionHistory History { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 原套餐名称。
|
||||
/// </summary>
|
||||
public required string FromPackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标套餐名称。
|
||||
/// </summary>
|
||||
public required string ToPackageName { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -64,4 +64,34 @@ public interface ITenantBillingRepository
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 管理员端分页查询账单列表(跨租户)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID 筛选(可选)。</param>
|
||||
/// <param name="status">账单状态筛选(可选)。</param>
|
||||
/// <param name="from">开始时间(UTC,可选)。</param>
|
||||
/// <param name="to">结束时间(UTC,可选)。</param>
|
||||
/// <param name="keyword">关键词搜索(账单号或租户名)。</param>
|
||||
/// <param name="pageNumber">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">页大小。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单集合与总数。</returns>
|
||||
Task<(IReadOnlyList<TenantBillingStatement> Items, int Total)> SearchPagedAsync(
|
||||
long? tenantId,
|
||||
TenantBillingStatus? status,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
string? keyword,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 获取账单(不限租户,管理员端使用)。
|
||||
/// </summary>
|
||||
/// <param name="billingId">账单 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单实体或 null。</returns>
|
||||
Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户支付记录仓储。
|
||||
/// </summary>
|
||||
public interface ITenantPaymentRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询指定账单的支付记录列表。
|
||||
/// </summary>
|
||||
/// <param name="billingStatementId">账单 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录集合。</returns>
|
||||
Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 获取支付记录。
|
||||
/// </summary>
|
||||
/// <param name="paymentId">支付记录 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录实体或 null。</returns>
|
||||
Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增支付记录。
|
||||
/// </summary>
|
||||
/// <param name="payment">支付记录实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task AddAsync(TenantPayment payment, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新支付记录。
|
||||
/// </summary>
|
||||
/// <param name="payment">支付记录实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task UpdateAsync(TenantPayment payment, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 保存变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -16,6 +16,14 @@ public interface ITenantRepository
|
||||
/// <returns>租户实体,未找到返回 null。</returns>
|
||||
Task<Tenant?> FindByIdAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取租户。
|
||||
/// </summary>
|
||||
/// <param name="tenantIds">租户 ID 列表。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>租户列表(仅返回找到的租户)。</returns>
|
||||
Task<IReadOnlyList<Tenant>> FindByIdsAsync(IReadOnlyCollection<long> tenantIds, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按状态与关键词查询租户列表。
|
||||
/// </summary>
|
||||
|
||||
@@ -41,12 +41,16 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||
services.AddScoped<ITenantRepository, EfTenantRepository>();
|
||||
services.AddScoped<ITenantBillingRepository, EfTenantBillingRepository>();
|
||||
services.AddScoped<ITenantPaymentRepository, EfTenantPaymentRepository>();
|
||||
services.AddScoped<ITenantAnnouncementRepository, EfTenantAnnouncementRepository>();
|
||||
services.AddScoped<ITenantAnnouncementReadRepository, EfTenantAnnouncementReadRepository>();
|
||||
services.AddScoped<ITenantNotificationRepository, EfTenantNotificationRepository>();
|
||||
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
|
||||
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
|
||||
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
|
||||
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
|
||||
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();
|
||||
services.AddScoped<ISubscriptionRepository, EfSubscriptionRepository>();
|
||||
|
||||
services.AddOptions<AppSeedOptions>()
|
||||
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
|
||||
|
||||
@@ -62,6 +62,10 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<TenantBillingStatement> TenantBillingStatements => Set<TenantBillingStatement>();
|
||||
/// <summary>
|
||||
/// 租户支付记录。
|
||||
/// </summary>
|
||||
public DbSet<TenantPayment> TenantPayments => Set<TenantPayment>();
|
||||
/// <summary>
|
||||
/// 租户通知。
|
||||
/// </summary>
|
||||
public DbSet<TenantNotification> TenantNotifications => Set<TenantNotification>();
|
||||
@@ -86,6 +90,18 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<TenantReviewClaim> TenantReviewClaims => Set<TenantReviewClaim>();
|
||||
/// <summary>
|
||||
/// 运营操作日志。
|
||||
/// </summary>
|
||||
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
||||
/// <summary>
|
||||
/// 配额包定义。
|
||||
/// </summary>
|
||||
public DbSet<QuotaPackage> QuotaPackages => Set<QuotaPackage>();
|
||||
/// <summary>
|
||||
/// 租户配额包购买记录。
|
||||
/// </summary>
|
||||
public DbSet<TenantQuotaPackagePurchase> TenantQuotaPackagePurchases => Set<TenantQuotaPackagePurchase>();
|
||||
/// <summary>
|
||||
/// 商户实体。
|
||||
/// </summary>
|
||||
public DbSet<Merchant> Merchants => Set<Merchant>();
|
||||
@@ -374,12 +390,16 @@ public sealed class TakeoutAppDbContext(
|
||||
ConfigureTenantSubscriptionHistory(modelBuilder.Entity<TenantSubscriptionHistory>());
|
||||
ConfigureTenantQuotaUsage(modelBuilder.Entity<TenantQuotaUsage>());
|
||||
ConfigureTenantBilling(modelBuilder.Entity<TenantBillingStatement>());
|
||||
ConfigureTenantPayment(modelBuilder.Entity<TenantPayment>());
|
||||
ConfigureTenantNotification(modelBuilder.Entity<TenantNotification>());
|
||||
ConfigureTenantAnnouncement(modelBuilder.Entity<TenantAnnouncement>());
|
||||
ConfigureTenantAnnouncementRead(modelBuilder.Entity<TenantAnnouncementRead>());
|
||||
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
|
||||
ConfigureTenantAuditLog(modelBuilder.Entity<TenantAuditLog>());
|
||||
ConfigureTenantReviewClaim(modelBuilder.Entity<TenantReviewClaim>());
|
||||
ConfigureOperationLog(modelBuilder.Entity<OperationLog>());
|
||||
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
|
||||
ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity<TenantQuotaPackagePurchase>());
|
||||
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
|
||||
ConfigureMerchantContract(modelBuilder.Entity<MerchantContract>());
|
||||
ConfigureMerchantStaff(modelBuilder.Entity<MerchantStaff>());
|
||||
@@ -511,6 +531,22 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => x.TenantId).IsUnique().HasFilter("\"ReleasedAt\" IS NULL AND \"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
private static void ConfigureOperationLog(EntityTypeBuilder<OperationLog> builder)
|
||||
{
|
||||
builder.ToTable("operation_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.OperationType).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.TargetType).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.TargetIds).HasColumnType("text");
|
||||
builder.Property(x => x.OperatorId).HasMaxLength(64);
|
||||
builder.Property(x => x.OperatorName).HasMaxLength(128);
|
||||
builder.Property(x => x.Parameters).HasColumnType("text");
|
||||
builder.Property(x => x.Result).HasColumnType("text");
|
||||
builder.Property(x => x.Success).IsRequired();
|
||||
builder.HasIndex(x => new { x.OperationType, x.CreatedAt });
|
||||
builder.HasIndex(x => x.CreatedAt);
|
||||
}
|
||||
|
||||
private static void ConfigureTenantSubscriptionHistory(EntityTypeBuilder<TenantSubscriptionHistory> builder)
|
||||
{
|
||||
builder.ToTable("tenant_subscription_histories");
|
||||
@@ -736,6 +772,20 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureTenantPayment(EntityTypeBuilder<TenantPayment> builder)
|
||||
{
|
||||
builder.ToTable("tenant_payments");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.BillingStatementId).IsRequired();
|
||||
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.Method).HasConversion<int>();
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.TransactionNo).HasMaxLength(64);
|
||||
builder.Property(x => x.ProofUrl).HasMaxLength(512);
|
||||
builder.Property(x => x.Notes).HasMaxLength(512);
|
||||
builder.HasIndex(x => new { x.TenantId, x.BillingStatementId });
|
||||
}
|
||||
|
||||
private static void ConfigureTenantNotification(EntityTypeBuilder<TenantNotification> builder)
|
||||
{
|
||||
builder.ToTable("tenant_notifications");
|
||||
@@ -1413,4 +1463,31 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.Property(x => x.NotificationChannels).HasMaxLength(256);
|
||||
builder.HasIndex(x => new { x.TenantId, x.MetricDefinitionId, x.Severity });
|
||||
}
|
||||
|
||||
private static void ConfigureQuotaPackage(EntityTypeBuilder<QuotaPackage> builder)
|
||||
{
|
||||
builder.ToTable("quota_packages");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.QuotaType).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.QuotaValue).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.Price).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.IsActive).IsRequired();
|
||||
builder.Property(x => x.SortOrder).HasDefaultValue(0);
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.HasIndex(x => new { x.QuotaType, x.IsActive, x.SortOrder });
|
||||
}
|
||||
|
||||
private static void ConfigureTenantQuotaPackagePurchase(EntityTypeBuilder<TenantQuotaPackagePurchase> builder)
|
||||
{
|
||||
builder.ToTable("tenant_quota_package_purchases");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.QuotaPackageId).IsRequired();
|
||||
builder.Property(x => x.QuotaValue).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.Price).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.PurchasedAt).IsRequired();
|
||||
builder.Property(x => x.Notes).HasMaxLength(512);
|
||||
builder.HasIndex(x => new { x.TenantId, x.QuotaPackageId, x.PurchasedAt });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF 配额包仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfQuotaPackageRepository(TakeoutAppDbContext context) : IQuotaPackageRepository
|
||||
{
|
||||
#region 配额包定义
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<QuotaPackage?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.QuotaPackages
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<QuotaPackage> Items, int Total)> SearchPagedAsync(
|
||||
TenantQuotaType? quotaType,
|
||||
bool? isActive,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.QuotaPackages.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null);
|
||||
|
||||
if (quotaType.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.QuotaType == quotaType.Value);
|
||||
}
|
||||
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.IsActive == isActive.Value);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.QuotaPackages.AddAsync(quotaPackage, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.QuotaPackages.Update(quotaPackage);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SoftDeleteAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quotaPackage = await context.QuotaPackages
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
if (quotaPackage == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
quotaPackage.DeletedAt = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额包购买记录
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<(TenantQuotaPackagePurchase Purchase, QuotaPackage Package)> Items, int Total)> GetPurchasesPagedAsync(
|
||||
long tenantId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.TenantQuotaPackagePurchases.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.PurchasedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Join(context.QuotaPackages.AsNoTracking(),
|
||||
purchase => purchase.QuotaPackageId,
|
||||
package => package.Id,
|
||||
(purchase, package) => new { Purchase = purchase, Package = package })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items.Select(x => (x.Purchase, x.Package)).ToList(), total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddPurchaseAsync(TenantQuotaPackagePurchase purchase, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantQuotaPackagePurchases.AddAsync(purchase, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额使用情况
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantQuotaUsage>> GetUsageByTenantAsync(
|
||||
long tenantId,
|
||||
TenantQuotaType? quotaType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.TenantQuotaUsages.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (quotaType.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.QuotaType == quotaType.Value);
|
||||
}
|
||||
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantQuotaUsage?> FindUsageAsync(
|
||||
long tenantId,
|
||||
TenantQuotaType quotaType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantQuotaUsages
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateUsageAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantQuotaUsages.Update(usage);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfStatisticsRepository(TakeoutAppDbContext dbContext) : IStatisticsRepository
|
||||
{
|
||||
#region 订阅统计
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> GetAllSubscriptionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExpiringSubscriptionInfo>> GetExpiringSubscriptionsAsync(
|
||||
int daysAhead,
|
||||
bool onlyWithoutAutoRenew,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var targetDate = now.AddDays(daysAhead);
|
||||
|
||||
// 构建基础查询
|
||||
var query = dbContext.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& s.EffectiveTo >= now
|
||||
&& s.EffectiveTo <= targetDate);
|
||||
|
||||
// 如果只查询未开启自动续费的
|
||||
if (onlyWithoutAutoRenew)
|
||||
{
|
||||
query = query.Where(s => !s.AutoRenew);
|
||||
}
|
||||
|
||||
// 连接租户和套餐信息
|
||||
var result = await query
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
sub => sub.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(sub, tenant) => new { Subscription = sub, Tenant = tenant }
|
||||
)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
combined => combined.Subscription.TenantPackageId,
|
||||
package => package.Id,
|
||||
(combined, package) => new ExpiringSubscriptionInfo
|
||||
{
|
||||
Subscription = combined.Subscription,
|
||||
TenantName = combined.Tenant.Name,
|
||||
PackageName = package.Name
|
||||
}
|
||||
)
|
||||
.OrderBy(x => x.Subscription.EffectiveTo)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 收入统计
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantBillingStatement>> GetPaidBillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Status == TenantBillingStatus.Paid)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额使用排行
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<QuotaUsageRankInfo>> GetQuotaUsageRankingAsync(
|
||||
TenantQuotaType quotaType,
|
||||
int topN,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantQuotaUsages
|
||||
.AsNoTracking()
|
||||
.Where(q => q.QuotaType == quotaType && q.LimitValue > 0)
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
quota => quota.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(quota, tenant) => new QuotaUsageRankInfo
|
||||
{
|
||||
TenantId = quota.TenantId,
|
||||
TenantName = tenant.Name,
|
||||
UsedValue = quota.UsedValue,
|
||||
LimitValue = quota.LimitValue,
|
||||
UsagePercentage = quota.LimitValue > 0 ? (quota.UsedValue / quota.LimitValue * 100) : 0
|
||||
}
|
||||
)
|
||||
.OrderByDescending(x => x.UsagePercentage)
|
||||
.Take(topN)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅管理仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext) : ISubscriptionRepository
|
||||
{
|
||||
#region 订阅查询
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantSubscription?> FindByIdAsync(long subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantSubscriptions
|
||||
.FirstOrDefaultAsync(s => s.Id == subscriptionId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> FindByIdsAsync(
|
||||
IEnumerable<long> subscriptionIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = subscriptionIds.ToList();
|
||||
return await dbContext.TenantSubscriptions
|
||||
.Where(s => ids.Contains(s.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<SubscriptionWithRelations> Items, int Total)> SearchPagedAsync(
|
||||
SubscriptionSearchFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
sub => sub.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(sub, tenant) => new { Subscription = sub, Tenant = tenant }
|
||||
)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
combined => combined.Subscription.TenantPackageId,
|
||||
package => package.Id,
|
||||
(combined, package) => new { combined.Subscription, combined.Tenant, Package = package }
|
||||
)
|
||||
.GroupJoin(
|
||||
dbContext.TenantPackages,
|
||||
combined => combined.Subscription.ScheduledPackageId,
|
||||
scheduledPackage => scheduledPackage.Id,
|
||||
(combined, scheduledPackages) => new { combined.Subscription, combined.Tenant, combined.Package, ScheduledPackage = scheduledPackages.FirstOrDefault() }
|
||||
);
|
||||
|
||||
// 2. 应用过滤条件
|
||||
if (filter.Status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Subscription.Status == filter.Status.Value);
|
||||
}
|
||||
|
||||
if (filter.TenantPackageId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Subscription.TenantPackageId == filter.TenantPackageId.Value);
|
||||
}
|
||||
|
||||
if (filter.TenantId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Subscription.TenantId == filter.TenantId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.TenantKeyword))
|
||||
{
|
||||
var keyword = filter.TenantKeyword.Trim().ToLower();
|
||||
query = query.Where(x => x.Tenant.Name.ToLower().Contains(keyword) || x.Tenant.Code.ToLower().Contains(keyword));
|
||||
}
|
||||
|
||||
if (filter.ExpiringWithinDays.HasValue)
|
||||
{
|
||||
var expiryDate = DateTime.UtcNow.AddDays(filter.ExpiringWithinDays.Value);
|
||||
query = query.Where(x => x.Subscription.EffectiveTo <= expiryDate && x.Subscription.EffectiveTo >= DateTime.UtcNow);
|
||||
}
|
||||
|
||||
if (filter.AutoRenew.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Subscription.AutoRenew == filter.AutoRenew.Value);
|
||||
}
|
||||
|
||||
// 3. 获取总数
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
// 4. 排序和分页
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.Subscription.CreatedAt)
|
||||
.Skip((filter.Page - 1) * filter.PageSize)
|
||||
.Take(filter.PageSize)
|
||||
.Select(x => new SubscriptionWithRelations
|
||||
{
|
||||
Subscription = x.Subscription,
|
||||
TenantName = x.Tenant.Name,
|
||||
TenantCode = x.Tenant.Code,
|
||||
PackageName = x.Package.Name,
|
||||
ScheduledPackageName = x.ScheduledPackage != null ? x.ScheduledPackage.Name : null
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SubscriptionDetailInfo?> GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await dbContext.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Id == subscriptionId)
|
||||
.Select(s => new
|
||||
{
|
||||
Subscription = s,
|
||||
Tenant = dbContext.Tenants.FirstOrDefault(t => t.Id == s.TenantId),
|
||||
Package = dbContext.TenantPackages.FirstOrDefault(p => p.Id == s.TenantPackageId),
|
||||
ScheduledPackage = s.ScheduledPackageId.HasValue
|
||||
? dbContext.TenantPackages.FirstOrDefault(p => p.Id == s.ScheduledPackageId)
|
||||
: null
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SubscriptionDetailInfo
|
||||
{
|
||||
Subscription = result.Subscription,
|
||||
TenantName = result.Tenant?.Name ?? "",
|
||||
TenantCode = result.Tenant?.Code ?? "",
|
||||
Package = result.Package,
|
||||
ScheduledPackage = result.ScheduledPackage
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SubscriptionWithTenant>> FindByIdsWithTenantAsync(
|
||||
IEnumerable<long> subscriptionIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = subscriptionIds.ToList();
|
||||
return await dbContext.TenantSubscriptions
|
||||
.Where(s => ids.Contains(s.Id))
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
sub => sub.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(sub, tenant) => new SubscriptionWithTenant
|
||||
{
|
||||
Subscription = sub,
|
||||
Tenant = tenant
|
||||
}
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 套餐查询
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantPackage?> FindPackageByIdAsync(long packageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantPackages
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Id == packageId, cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 订阅更新
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.TenantSubscriptions.Update(subscription);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 订阅历史
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.TenantSubscriptionHistories.Add(history);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SubscriptionHistoryWithPackageNames>> GetHistoryAsync(
|
||||
long subscriptionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantSubscriptionHistories
|
||||
.AsNoTracking()
|
||||
.Where(h => h.TenantSubscriptionId == subscriptionId)
|
||||
.OrderByDescending(h => h.CreatedAt)
|
||||
.Select(h => new SubscriptionHistoryWithPackageNames
|
||||
{
|
||||
History = h,
|
||||
FromPackageName = dbContext.TenantPackages
|
||||
.Where(p => p.Id == h.FromPackageId)
|
||||
.Select(p => p.Name)
|
||||
.FirstOrDefault() ?? "",
|
||||
ToPackageName = dbContext.TenantPackages
|
||||
.Where(p => p.Id == h.ToPackageId)
|
||||
.Select(p => p.Name)
|
||||
.FirstOrDefault() ?? ""
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额使用
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantQuotaUsage>> GetQuotaUsagesAsync(
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantQuotaUsages
|
||||
.AsNoTracking()
|
||||
.Where(q => q.TenantId == tenantId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 通知
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddNotificationAsync(TenantNotification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.TenantNotifications.Add(notification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 操作日志
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddOperationLogAsync(OperationLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.Set<OperationLog>().Add(log);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,69 @@ public sealed class EfTenantBillingRepository(TakeoutAppDbContext context) : ITe
|
||||
.ContinueWith(t => (IReadOnlyList<TenantBillingStatement>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<TenantBillingStatement> Items, int Total)> SearchPagedAsync(
|
||||
long? tenantId,
|
||||
TenantBillingStatus? status,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
string? keyword,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.TenantBillingStatements.AsNoTracking();
|
||||
|
||||
// 1. 按租户过滤(可选)
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.TenantId == tenantId.Value);
|
||||
}
|
||||
|
||||
// 2. 按状态过滤
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
// 3. 按日期范围过滤
|
||||
if (from.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.PeriodStart >= from.Value);
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.PeriodEnd <= to.Value);
|
||||
}
|
||||
|
||||
// 4. 按关键字过滤(账单编号)
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalizedKeyword = keyword.Trim();
|
||||
query = query.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalizedKeyword}%"));
|
||||
}
|
||||
|
||||
// 5. 统计总数
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
// 6. 分页查询
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantBillingStatements.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == billingId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantBillingStatement?> FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF 租户支付记录仓储。
|
||||
/// </summary>
|
||||
public sealed class EfTenantPaymentRepository(TakeoutAppDbContext context) : ITenantPaymentRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantPayments.AsNoTracking()
|
||||
.Where(x => x.BillingStatementId == billingStatementId)
|
||||
.OrderByDescending(x => x.PaidAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantPayments.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == paymentId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(TenantPayment payment, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantPayments.AddAsync(payment, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(TenantPayment payment, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantPayments.Update(payment);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,20 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep
|
||||
.FirstOrDefaultAsync(x => x.Id == tenantId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Tenant>> FindByIdsAsync(IReadOnlyCollection<long> tenantIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<Tenant>();
|
||||
}
|
||||
|
||||
return await context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => tenantIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Tenant>> SearchAsync(
|
||||
TenantStatus? status,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 自动续费后台服务。
|
||||
/// 定期检查开启自动续费的订阅,在到期前自动生成续费账单。
|
||||
/// </summary>
|
||||
public sealed class AutoRenewalService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AutoRenewalService> _logger;
|
||||
private readonly AutoRenewalOptions _options;
|
||||
|
||||
public AutoRenewalService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AutoRenewalService> logger,
|
||||
IOptions<AutoRenewalOptions> options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("自动续费服务已启动");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 计算下次执行时间(每天执行)
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRun = now.Date.AddDays(1).AddHours(_options.ExecuteHour);
|
||||
var delay = nextRun - now;
|
||||
|
||||
_logger.LogInformation("自动续费服务将在 {NextRun} 执行,等待 {Delay}", nextRun, delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
await ProcessAutoRenewalsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "自动续费服务执行异常");
|
||||
// 出错后等待一段时间再重试
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("自动续费服务已停止");
|
||||
}
|
||||
|
||||
private async Task ProcessAutoRenewalsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("开始处理自动续费");
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var idGenerator = scope.ServiceProvider.GetRequiredService<IIdGenerator>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var renewalThreshold = now.AddDays(_options.RenewalDaysBeforeExpiry);
|
||||
var billsCreated = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// 查询开启自动续费且即将到期的活跃订阅
|
||||
var autoRenewSubscriptions = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& s.AutoRenew
|
||||
&& s.EffectiveTo <= renewalThreshold
|
||||
&& s.EffectiveTo > now)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
sub => sub.TenantPackageId,
|
||||
package => package.Id,
|
||||
(sub, package) => new { Subscription = sub, Package = package }
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in autoRenewSubscriptions)
|
||||
{
|
||||
// 检查是否已为本次到期生成过账单
|
||||
var existingBill = await dbContext.TenantBillingStatements
|
||||
.AnyAsync(b => b.TenantId == item.Subscription.TenantId
|
||||
&& b.PeriodStart >= item.Subscription.EffectiveTo
|
||||
&& b.Status != TenantBillingStatus.Cancelled,
|
||||
cancellationToken);
|
||||
|
||||
if (existingBill)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} 已存在续费账单,跳过",
|
||||
item.Subscription.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成续费账单
|
||||
var billNo = $"BILL-{DateTime.UtcNow:yyyyMMddHHmmss}-{item.Subscription.TenantId}";
|
||||
var periodStart = item.Subscription.EffectiveTo;
|
||||
|
||||
// 从当前订阅计算续费周期(月数)
|
||||
var currentDurationMonths = ((item.Subscription.EffectiveTo.Year - item.Subscription.EffectiveFrom.Year) * 12)
|
||||
+ item.Subscription.EffectiveTo.Month - item.Subscription.EffectiveFrom.Month;
|
||||
if (currentDurationMonths <= 0) currentDurationMonths = 1; // 至少1个月
|
||||
|
||||
var periodEnd = periodStart.AddMonths(currentDurationMonths);
|
||||
|
||||
// 根据续费周期计算价格(年付优惠)
|
||||
var renewalPrice = currentDurationMonths >= 12
|
||||
? (item.Package.YearlyPrice ?? item.Package.MonthlyPrice * 12 ?? 0)
|
||||
: (item.Package.MonthlyPrice ?? 0) * currentDurationMonths;
|
||||
|
||||
var bill = new TenantBillingStatement
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = item.Subscription.TenantId,
|
||||
StatementNo = billNo,
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
AmountDue = renewalPrice,
|
||||
AmountPaid = 0,
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = periodStart.AddDays(-1), // 到期前一天为付款截止日
|
||||
LineItemsJson = $"{{\"套餐名称\":\"{item.Package.Name}\",\"续费周期\":\"{currentDurationMonths}个月\"}}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.TenantBillingStatements.Add(bill);
|
||||
billsCreated++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"为订阅 {SubscriptionId} (租户 {TenantId}) 生成自动续费账单 {BillNo},金额 {Amount}",
|
||||
item.Subscription.Id, item.Subscription.TenantId, billNo, renewalPrice);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("自动续费处理完成,共生成 {Count} 张账单", billsCreated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "自动续费处理失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 自动续费配置选项。
|
||||
/// </summary>
|
||||
public sealed class AutoRenewalOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行时间(小时,UTC时间),默认凌晨1点。
|
||||
/// </summary>
|
||||
public int ExecuteHour { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 在到期前N天生成续费账单,默认3天。
|
||||
/// </summary>
|
||||
public int RenewalDaysBeforeExpiry { get; set; } = 3;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 续费提醒后台服务。
|
||||
/// 定期检查即将到期的订阅,发送续费提醒通知。
|
||||
/// </summary>
|
||||
public sealed class RenewalReminderService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<RenewalReminderService> _logger;
|
||||
private readonly RenewalReminderOptions _options;
|
||||
|
||||
public RenewalReminderService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<RenewalReminderService> logger,
|
||||
IOptions<RenewalReminderOptions> options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("续费提醒服务已启动");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 计算下次执行时间(每天执行)
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRun = now.Date.AddDays(1).AddHours(_options.ExecuteHour);
|
||||
var delay = nextRun - now;
|
||||
|
||||
_logger.LogInformation("续费提醒服务将在 {NextRun} 执行,等待 {Delay}", nextRun, delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
await SendRenewalRemindersAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "续费提醒服务执行异常");
|
||||
// 出错后等待一段时间再重试
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("续费提醒服务已停止");
|
||||
}
|
||||
|
||||
private async Task SendRenewalRemindersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("开始发送续费提醒");
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var idGenerator = scope.ServiceProvider.GetRequiredService<IIdGenerator>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var remindersSent = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// 遍历配置的提醒时间点(例如:到期前7天、3天、1天)
|
||||
foreach (var daysBeforeExpiry in _options.ReminderDaysBeforeExpiry)
|
||||
{
|
||||
var targetDate = now.AddDays(daysBeforeExpiry);
|
||||
var startOfDay = targetDate.Date;
|
||||
var endOfDay = startOfDay.AddDays(1);
|
||||
|
||||
// 查询即将到期的活跃订阅(且未开启自动续费)
|
||||
var expiringSubscriptions = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& !s.AutoRenew
|
||||
&& s.EffectiveTo >= startOfDay
|
||||
&& s.EffectiveTo < endOfDay)
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
sub => sub.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(sub, tenant) => new { Subscription = sub, Tenant = tenant }
|
||||
)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
combined => combined.Subscription.TenantPackageId,
|
||||
package => package.Id,
|
||||
(combined, package) => new { combined.Subscription, combined.Tenant, Package = package }
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in expiringSubscriptions)
|
||||
{
|
||||
// 检查是否已发送过相同天数的提醒(避免重复发送)
|
||||
var alreadySent = await dbContext.TenantNotifications
|
||||
.AnyAsync(n => n.TenantId == item.Subscription.TenantId
|
||||
&& n.Message.Contains($"{daysBeforeExpiry}天内到期")
|
||||
&& n.SentAt >= now.AddHours(-24), // 24小时内已发送过
|
||||
cancellationToken);
|
||||
|
||||
if (alreadySent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建续费提醒通知
|
||||
var notification = new TenantNotification
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = item.Subscription.TenantId,
|
||||
Title = "订阅续费提醒",
|
||||
Message = $"您的订阅套餐「{item.Package.Name}」将在 {daysBeforeExpiry} 天内到期(到期时间:{item.Subscription.EffectiveTo:yyyy-MM-dd HH:mm}),请及时续费以免影响使用。",
|
||||
Severity = daysBeforeExpiry <= 1
|
||||
? TenantNotificationSeverity.Critical
|
||||
: TenantNotificationSeverity.Warning,
|
||||
Channel = TenantNotificationChannel.InApp,
|
||||
SentAt = DateTime.UtcNow,
|
||||
ReadAt = null,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.TenantNotifications.Add(notification);
|
||||
remindersSent++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"发送续费提醒: 租户 {TenantName} ({TenantId}), 套餐 {PackageName}, 剩余 {Days} 天",
|
||||
item.Tenant.Name, item.Subscription.TenantId, item.Package.Name, daysBeforeExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("续费提醒发送完成,共发送 {Count} 条提醒", remindersSent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "发送续费提醒失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 续费提醒配置选项。
|
||||
/// </summary>
|
||||
public sealed class RenewalReminderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行时间(小时,UTC时间),默认上午10点。
|
||||
/// </summary>
|
||||
public int ExecuteHour { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 提醒时间点(到期前N天),默认7天、3天、1天。
|
||||
/// </summary>
|
||||
public int[] ReminderDaysBeforeExpiry { get; set; } = { 7, 3, 1 };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期检查后台服务。
|
||||
/// 每天凌晨执行,检查即将到期和已到期的订阅,自动更新状态。
|
||||
/// </summary>
|
||||
public sealed class SubscriptionExpiryCheckService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<SubscriptionExpiryCheckService> _logger;
|
||||
private readonly SubscriptionExpiryCheckOptions _options;
|
||||
|
||||
public SubscriptionExpiryCheckService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SubscriptionExpiryCheckService> logger,
|
||||
IOptions<SubscriptionExpiryCheckOptions> options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("订阅到期检查服务已启动");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 计算下次执行时间(每天凌晨)
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRun = now.Date.AddDays(1).AddHours(_options.ExecuteHour);
|
||||
var delay = nextRun - now;
|
||||
|
||||
_logger.LogInformation("订阅到期检查服务将在 {NextRun} 执行,等待 {Delay}", nextRun, delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
await CheckExpiringSubscriptionsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "订阅到期检查服务执行异常");
|
||||
// 出错后等待一段时间再重试
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("订阅到期检查服务已停止");
|
||||
}
|
||||
|
||||
private async Task CheckExpiringSubscriptionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("开始执行订阅到期检查");
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var gracePeriodDays = _options.GracePeriodDays;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 检查活跃订阅中已到期的,转为宽限期
|
||||
var expiredActive = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var subscription in expiredActive)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.GracePeriod;
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} (租户 {TenantId}) 已到期,进入宽限期",
|
||||
subscription.Id, subscription.TenantId);
|
||||
}
|
||||
|
||||
// 2. 检查宽限期订阅中超过宽限期的,转为暂停
|
||||
var gracePeriodExpired = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.GracePeriod
|
||||
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var subscription in gracePeriodExpired)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.Suspended;
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} (租户 {TenantId}) 宽限期已结束,已暂停",
|
||||
subscription.Id, subscription.TenantId);
|
||||
}
|
||||
|
||||
// 3. 保存更改
|
||||
var changedCount = await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"订阅到期检查完成,共更新 {Count} 条记录 (到期转宽限期: {ExpiredCount}, 宽限期转暂停: {SuspendedCount})",
|
||||
changedCount, expiredActive.Count, gracePeriodExpired.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "订阅到期检查失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期检查配置选项。
|
||||
/// </summary>
|
||||
public sealed class SubscriptionExpiryCheckOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行时间(小时,UTC时间),默认凌晨2点。
|
||||
/// </summary>
|
||||
public int ExecuteHour { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// 宽限期天数,默认7天。
|
||||
/// </summary>
|
||||
public int GracePeriodDays { get; set; } = 7;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"BackgroundServices": {
|
||||
"SubscriptionExpiryCheck": {
|
||||
"ExecuteHour": 2,
|
||||
"GracePeriodDays": 7
|
||||
},
|
||||
"RenewalReminder": {
|
||||
"ExecuteHour": 10,
|
||||
"ReminderDaysBeforeExpiry": [7, 3, 1]
|
||||
},
|
||||
"AutoRenewal": {
|
||||
"ExecuteHour": 1,
|
||||
"RenewalDaysBeforeExpiry": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQuotaPackagesAndPayments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "operation_logs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
OperationType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "操作类型:BatchExtend, BatchRemind, StatusChange 等。"),
|
||||
TargetType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false, comment: "目标类型:Subscription, Bill 等。"),
|
||||
TargetIds = table.Column<string>(type: "text", nullable: true, comment: "目标ID列表(JSON)。"),
|
||||
OperatorId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "操作人ID。"),
|
||||
OperatorName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true, comment: "操作人名称。"),
|
||||
Parameters = table.Column<string>(type: "text", nullable: true, comment: "操作参数(JSON)。"),
|
||||
Result = table.Column<string>(type: "text", nullable: true, comment: "操作结果(JSON)。"),
|
||||
Success = table.Column<bool>(type: "boolean", nullable: false, comment: "是否成功。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_operation_logs", x => x.Id);
|
||||
},
|
||||
comment: "运营操作日志。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "quota_packages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false, comment: "配额包名称。"),
|
||||
QuotaType = table.Column<int>(type: "integer", nullable: false, comment: "配额类型。"),
|
||||
QuotaValue = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "配额数值。"),
|
||||
Price = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "价格。"),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false, comment: "是否上架。"),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false, defaultValue: 0, comment: "排序。"),
|
||||
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "描述。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_quota_packages", x => x.Id);
|
||||
},
|
||||
comment: "配额包定义(平台提供的可购买配额包)。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_payments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
BillingStatementId = table.Column<long>(type: "bigint", nullable: false, comment: "关联的账单 ID。"),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "支付金额。"),
|
||||
Method = table.Column<int>(type: "integer", nullable: false, comment: "支付方式。"),
|
||||
Status = table.Column<int>(type: "integer", nullable: false, comment: "支付状态。"),
|
||||
TransactionNo = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true, comment: "交易号。"),
|
||||
ProofUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "支付凭证 URL。"),
|
||||
PaidAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "支付时间。"),
|
||||
Notes = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "备注信息。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tenant_payments", x => x.Id);
|
||||
},
|
||||
comment: "租户支付记录。");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_quota_package_purchases",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
QuotaPackageId = table.Column<long>(type: "bigint", nullable: false, comment: "配额包 ID。"),
|
||||
QuotaValue = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "购买时的配额值。"),
|
||||
Price = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "购买价格。"),
|
||||
PurchasedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "购买时间。"),
|
||||
ExpiredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "过期时间(可选)。"),
|
||||
Notes = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "备注。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tenant_quota_package_purchases", x => x.Id);
|
||||
},
|
||||
comment: "租户配额包购买记录。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_operation_logs_CreatedAt",
|
||||
table: "operation_logs",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_operation_logs_OperationType_CreatedAt",
|
||||
table: "operation_logs",
|
||||
columns: new[] { "OperationType", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_quota_packages_QuotaType_IsActive_SortOrder",
|
||||
table: "quota_packages",
|
||||
columns: new[] { "QuotaType", "IsActive", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tenant_payments_TenantId_BillingStatementId",
|
||||
table: "tenant_payments",
|
||||
columns: new[] { "TenantId", "BillingStatementId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tenant_quota_package_purchases_TenantId_QuotaPackageId_Purc~",
|
||||
table: "tenant_quota_package_purchases",
|
||||
columns: new[] { "TenantId", "QuotaPackageId", "PurchasedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "operation_logs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "quota_packages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_payments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_quota_package_purchases");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb
|
||||
{
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20251217092230_FixIdentitySchemaMismatch")]
|
||||
partial class FixIdentitySchemaMismatch
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.IdentityUser", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("登录账号。");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text")
|
||||
.HasComment("头像地址。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("展示名称。");
|
||||
|
||||
b.Property<long?>("MerchantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属商户(平台管理员为空)。");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("密码哈希。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "Account")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("identity_users", null, t =>
|
||||
{
|
||||
t.HasComment("管理后台账户实体(平台管理员、租户管理员或商户员工)。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MenuDefinition", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AuthListJson")
|
||||
.HasColumnType("text")
|
||||
.HasComment("按钮权限列表 JSON(存储 MenuAuthItemDto 数组)。");
|
||||
|
||||
b.Property<string>("Component")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("组件路径(不含 .vue)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("图标标识。");
|
||||
|
||||
b.Property<bool>("IsIframe")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否 iframe。");
|
||||
|
||||
b.Property<bool>("KeepAlive")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否缓存。");
|
||||
|
||||
b.Property<string>("Link")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("外链或 iframe 地址。");
|
||||
|
||||
b.Property<string>("MetaPermissions")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasComment("Meta.permissions(逗号分隔)。");
|
||||
|
||||
b.Property<string>("MetaRoles")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasComment("Meta.roles(逗号分隔)。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("菜单名称(前端路由 name)。");
|
||||
|
||||
b.Property<long>("ParentId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("父级菜单 ID,根节点为 0。");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("路由路径。");
|
||||
|
||||
b.Property<string>("RequiredPermissions")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasComment("访问该菜单所需的权限集合(逗号分隔)。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("排序。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("标题。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "ParentId", "SortOrder");
|
||||
|
||||
b.ToTable("menu_definitions", null, t =>
|
||||
{
|
||||
t.HasComment("管理端菜单定义。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MiniUser", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text")
|
||||
.HasComment("头像地址。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("昵称。");
|
||||
|
||||
b.Property<string>("OpenId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("微信 OpenId。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<string>("UnionId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("微信 UnionId,可能为空。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "OpenId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("mini_users", null, t =>
|
||||
{
|
||||
t.HasComment("小程序用户实体。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Permission", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("权限编码(租户内唯一)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("描述。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("权限名称。");
|
||||
|
||||
b.Property<long>("ParentId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("父级权限 ID,根节点为 0。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("排序值,值越小越靠前。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasComment("权限类型(group/leaf)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "Code")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId", "ParentId", "SortOrder");
|
||||
|
||||
b.ToTable("permissions", null, t =>
|
||||
{
|
||||
t.HasComment("权限定义。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Role", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("角色编码(租户内唯一)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("描述。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("角色名称。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("roles", null, t =>
|
||||
{
|
||||
t.HasComment("角色定义。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RolePermission", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<long>("PermissionId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("权限 ID。");
|
||||
|
||||
b.Property<long>("RoleId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("角色 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "RoleId", "PermissionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("role_permissions", null, t =>
|
||||
{
|
||||
t.HasComment("角色-权限关系。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplate", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("模板描述。");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("模板名称。");
|
||||
|
||||
b.Property<string>("TemplateCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("模板编码(唯一)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TemplateCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("role_templates", null, t =>
|
||||
{
|
||||
t.HasComment("角色模板定义(平台级)。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplatePermission", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("PermissionCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("权限编码。");
|
||||
|
||||
b.Property<long>("RoleTemplateId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("模板 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleTemplateId", "PermissionCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("role_template_permissions", null, t =>
|
||||
{
|
||||
t.HasComment("角色模板-权限关系(平台级)。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.UserRole", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<long>("RoleId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("角色 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("用户 ID。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "RoleId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("user_roles", null, t =>
|
||||
{
|
||||
t.HasComment("用户-角色关系。");
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user