feat(admin): 新增管理员角色、账单、订阅、套餐管理功能

- 新增 AdminRolesController 实现角色 CRUD 和权限管理
- 新增 BillingsController 实现账单查询功能
- 新增 SubscriptionsController 实现订阅管理功能
- 新增 TenantPackagesController 实现套餐管理功能
- 新增租户详情、配额使用、账单列表等查询功能
- 新增 TenantPackage、TenantSubscription 等领域实体
- 新增相关枚举:SubscriptionStatus、TenantPackageType 等
- 更新 appsettings 配置文件
- 更新权限授权策略提供者

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MSuMshk
2026-02-02 09:11:44 +08:00
parent 54feee53b8
commit 0f900e108d
97 changed files with 7047 additions and 12 deletions

View File

@@ -0,0 +1,197 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.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}/roles")]
public sealed class AdminRolesController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 获取平台角色列表。
/// </summary>
/// <param name="keyword">关键字(角色名称/编码)。</param>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色分页列表。</returns>
[HttpGet]
[PermissionAuthorize("identity:role:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<RoleDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<RoleDto>>> List(
[FromQuery] string? keyword,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new ListAdminRolesQuery
{
Keyword = keyword,
Page = page,
PageSize = pageSize
};
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回分页结果
return ApiResponse<PagedResult<RoleDto>>.Ok(result);
}
/// <summary>
/// 创建平台角色。
/// </summary>
/// <param name="command">创建命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>创建后的角色。</returns>
[HttpPost]
[PermissionAuthorize("identity:role:create")]
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<RoleDto>> Create(
[FromBody, Required] CreateAdminRoleCommand command,
CancellationToken cancellationToken)
{
// 1. 执行创建
var result = await mediator.Send(command, cancellationToken);
// 2. 返回创建结果
return ApiResponse<RoleDto>.Ok(result);
}
/// <summary>
/// 更新平台角色。
/// </summary>
/// <param name="roleId">角色 ID。</param>
/// <param name="command">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新后的角色。</returns>
[HttpPut("{roleId:long}")]
[PermissionAuthorize("identity:role:update")]
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<RoleDto>> Update(
long roleId,
[FromBody, Required] UpdateAdminRoleCommand command,
CancellationToken cancellationToken)
{
// 1. 绑定路由参数
command = command with { RoleId = roleId };
// 2. 执行更新
var result = await mediator.Send(command, cancellationToken);
// 3. 返回更新结果或 404
return result is null
? ApiResponse<RoleDto>.Error(StatusCodes.Status404NotFound, "角色不存在")
: ApiResponse<RoleDto>.Ok(result);
}
/// <summary>
/// 获取平台角色权限列表。
/// </summary>
/// <param name="roleId">角色 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限集合。</returns>
[HttpGet("{roleId:long}/permissions")]
[PermissionAuthorize("identity:role:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<PermissionDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<PermissionDto>>> GetPermissions(
long roleId,
CancellationToken cancellationToken)
{
// 1. 构造查询
var query = new GetAdminRolePermissionsQuery { RoleId = roleId };
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回权限集合
return ApiResponse<IReadOnlyList<PermissionDto>>.Ok(result);
}
/// <summary>
/// 更新平台角色权限。
/// </summary>
/// <param name="roleId">角色 ID。</param>
/// <param name="command">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新结果。</returns>
[HttpPut("{roleId:long}/permissions")]
[PermissionAuthorize("identity:role:bind-permission")]
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
public async Task<ApiResponse<bool>> UpdatePermissions(
long roleId,
[FromBody, Required] UpdateAdminRolePermissionsCommand command,
CancellationToken cancellationToken)
{
// 1. 绑定路由参数
command = command with { RoleId = roleId };
// 2. 执行更新
var result = await mediator.Send(command, cancellationToken);
// 3. 返回更新结果
return ApiResponse<bool>.Ok(result);
}
/// <summary>
/// 删除平台角色。
/// </summary>
/// <param name="roleId">角色 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>删除结果。</returns>
[HttpDelete("{roleId:long}")]
[PermissionAuthorize("identity:role:delete")]
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
public async Task<ApiResponse<bool>> Delete(
long roleId,
CancellationToken cancellationToken)
{
// 1. 构造命令
var command = new DeleteAdminRoleCommand { RoleId = roleId };
// 2. 执行删除
var result = await mediator.Send(command, cancellationToken);
// 3. 返回删除结果
return ApiResponse<bool>.Ok(result);
}
/// <summary>
/// 克隆平台角色。
/// </summary>
/// <param name="roleId">源角色 ID。</param>
/// <param name="command">克隆命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>克隆后的新角色。</returns>
[HttpPost("{roleId:long}/clone")]
[PermissionAuthorize("identity:role:create")]
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<RoleDto>> Clone(
long roleId,
[FromBody, Required] CloneAdminRoleCommand command,
CancellationToken cancellationToken)
{
// 1. 绑定路由参数
command = command with { SourceRoleId = roleId };
// 2. 执行克隆
var result = await mediator.Send(command, cancellationToken);
// 3. 返回克隆结果
return ApiResponse<RoleDto>.Ok(result);
}
}

View File

@@ -0,0 +1,147 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Application.App.Billings.Contracts;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
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}/billings")]
public sealed class BillingsController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 获取账单列表(分页)。
/// </summary>
/// <param name="TenantId">租户 ID。</param>
/// <param name="Status">账单状态。</param>
/// <param name="BillingType">账单类型。</param>
/// <param name="StartDate">开始日期。</param>
/// <param name="EndDate">结束日期。</param>
/// <param name="MinAmount">最小金额。</param>
/// <param name="MaxAmount">最大金额。</param>
/// <param name="Keyword">关键词(账单号、租户名)。</param>
/// <param name="SortBy">排序字段。</param>
/// <param name="SortDesc">是否降序。</param>
/// <param name="PageNumber">页码(从 1 开始)。</param>
/// <param name="PageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单分页列表。</returns>
[HttpGet]
[PermissionAuthorize("tenant:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillingListDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<BillingListDto>>> List(
[FromQuery] long? TenantId,
[FromQuery] TenantBillingStatus? Status,
[FromQuery] TenantBillingType? BillingType,
[FromQuery] DateTime? StartDate,
[FromQuery] DateTime? EndDate,
[FromQuery] decimal? MinAmount,
[FromQuery] decimal? MaxAmount,
[FromQuery] string? Keyword,
[FromQuery] string? SortBy,
[FromQuery] bool? SortDesc,
[FromQuery] int PageNumber = 1,
[FromQuery] int PageSize = 10,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new ListBillingsQuery
{
TenantId = TenantId,
Status = Status,
BillingType = BillingType,
StartDate = StartDate,
EndDate = EndDate,
MinAmount = MinAmount,
MaxAmount = MaxAmount,
Keyword = Keyword,
SortBy = SortBy,
SortDesc = SortDesc,
PageNumber = PageNumber,
PageSize = PageSize
};
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回账单分页列表
return ApiResponse<PagedResult<BillingListDto>>.Ok(result);
}
/// <summary>
/// 获取账单详情。
/// </summary>
/// <param name="id">账单 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单详情。</returns>
[HttpGet("{id:long}")]
[PermissionAuthorize("tenant:read")]
[ProducesResponseType(typeof(ApiResponse<BillingDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<BillingDetailDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<BillingDetailDto>> GetDetail(
[FromRoute] long id,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new GetBillingDetailQuery { BillingId = id };
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 如果不存在,返回 404
if (result is null)
{
return ApiResponse<BillingDetailDto>.Error(
ErrorCodes.NotFound,
"账单不存在");
}
// 4. 返回账单详情
return ApiResponse<BillingDetailDto>.Ok(result);
}
/// <summary>
/// 一键确认收款(记录支付 + 立即审核通过 + 同步更新账单状态)。
/// </summary>
/// <param name="id">账单 ID。</param>
/// <param name="command">确认收款命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录。</returns>
[HttpPost("{id:long}/payments/confirm")]
[PermissionAuthorize("tenant:read")]
[ProducesResponseType(typeof(ApiResponse<PaymentRecordDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PaymentRecordDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<PaymentRecordDto>> ConfirmPayment(
[FromRoute] long id,
[FromBody] ConfirmPaymentCommand command,
CancellationToken cancellationToken = default)
{
// 1. 构造命令(使用路由中的 ID
var cmd = command with { BillingId = id };
// 2. 执行命令
var result = await mediator.Send(cmd, cancellationToken);
// 3. 如果不存在,返回 404
if (result is null)
{
return ApiResponse<PaymentRecordDto>.Error(
ErrorCodes.NotFound,
"账单不存在");
}
// 4. 返回支付记录
return ApiResponse<PaymentRecordDto>.Ok(result);
}
}

View File

@@ -0,0 +1,243 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
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="Status">订阅状态。</param>
/// <param name="TenantPackageId">套餐 ID。</param>
/// <param name="TenantId">租户 ID。</param>
/// <param name="TenantKeyword">租户关键词(名称或编码)。</param>
/// <param name="ExpiringWithinDays">即将到期天数筛选。</param>
/// <param name="AutoRenew">是否自动续费。</param>
/// <param name="ExpireFrom">到期时间范围开始。</param>
/// <param name="ExpireTo">到期时间范围结束。</param>
/// <param name="Page">页码(从 1 开始)。</param>
/// <param name="PageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>订阅分页列表。</returns>
[HttpGet]
[PermissionAuthorize("tenant:subscription")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<SubscriptionListDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<SubscriptionListDto>>> List(
[FromQuery] SubscriptionStatus? Status,
[FromQuery] long? TenantPackageId,
[FromQuery] long? TenantId,
[FromQuery] string? TenantKeyword,
[FromQuery] int? ExpiringWithinDays,
[FromQuery] bool? AutoRenew,
[FromQuery] DateTime? ExpireFrom,
[FromQuery] DateTime? ExpireTo,
[FromQuery] int Page = 1,
[FromQuery] int PageSize = 10,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new ListSubscriptionsQuery
{
Status = Status,
TenantPackageId = TenantPackageId,
TenantId = TenantId,
TenantKeyword = TenantKeyword,
ExpiringWithinDays = ExpiringWithinDays,
AutoRenew = AutoRenew,
ExpireFrom = ExpireFrom,
ExpireTo = ExpireTo,
Page = Page,
PageSize = PageSize
};
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回订阅分页列表
return ApiResponse<PagedResult<SubscriptionListDto>>.Ok(result);
}
/// <summary>
/// 获取订阅详情。
/// </summary>
/// <param name="id">订阅 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>订阅详情。</returns>
[HttpGet("{id:long}")]
[PermissionAuthorize("tenant:subscription")]
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<SubscriptionDetailDto>> GetDetail(
[FromRoute] long id,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new GetSubscriptionDetailQuery { SubscriptionId = id };
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 如果不存在,返回 404
if (result is null)
{
return ApiResponse<SubscriptionDetailDto>.Error(
ErrorCodes.NotFound,
"订阅不存在");
}
// 4. 返回订阅详情
return ApiResponse<SubscriptionDetailDto>.Ok(result);
}
/// <summary>
/// 更新订阅。
/// </summary>
/// <param name="id">订阅 ID。</param>
/// <param name="command">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新后的订阅信息。</returns>
[HttpPut("{id:long}")]
[PermissionAuthorize("tenant:subscription")]
[ProducesResponseType(typeof(ApiResponse<SubscriptionListDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<SubscriptionListDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<SubscriptionListDto>> Update(
[FromRoute] long id,
[FromBody] UpdateSubscriptionCommand command,
CancellationToken cancellationToken = default)
{
// 1. 构造命令(使用路由中的 ID
var cmd = command with { SubscriptionId = id };
// 2. 执行命令
var result = await mediator.Send(cmd, cancellationToken);
// 3. 如果不存在,返回 404
if (result is null)
{
return ApiResponse<SubscriptionListDto>.Error(
ErrorCodes.NotFound,
"订阅不存在");
}
// 4. 返回更新后的订阅信息
return ApiResponse<SubscriptionListDto>.Ok(result);
}
/// <summary>
/// 延期订阅。
/// </summary>
/// <param name="id">订阅 ID。</param>
/// <param name="command">延期命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>延期后的订阅信息。</returns>
[HttpPost("{id:long}/extend")]
[PermissionAuthorize("tenant:subscription")]
[ProducesResponseType(typeof(ApiResponse<SubscriptionListDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<SubscriptionListDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<SubscriptionListDto>> Extend(
[FromRoute] long id,
[FromBody] ExtendSubscriptionCommand command,
CancellationToken cancellationToken = default)
{
// 1. 构造命令(使用路由中的 ID
var cmd = command with { SubscriptionId = id };
// 2. 执行命令
var result = await mediator.Send(cmd, cancellationToken);
// 3. 如果不存在,返回 404
if (result is null)
{
return ApiResponse<SubscriptionListDto>.Error(
ErrorCodes.NotFound,
"订阅不存在");
}
// 4. 返回延期后的订阅信息
return ApiResponse<SubscriptionListDto>.Ok(result);
}
/// <summary>
/// 变更套餐。
/// </summary>
/// <param name="id">订阅 ID。</param>
/// <param name="command">变更套餐命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>变更后的订阅信息。</returns>
[HttpPost("{id:long}/change-plan")]
[PermissionAuthorize("tenant:subscription")]
[ProducesResponseType(typeof(ApiResponse<SubscriptionListDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<SubscriptionListDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<SubscriptionListDto>> ChangePlan(
[FromRoute] long id,
[FromBody] ChangePlanCommand command,
CancellationToken cancellationToken = default)
{
// 1. 构造命令(使用路由中的 ID
var cmd = command with { SubscriptionId = id };
// 2. 执行命令
var result = await mediator.Send(cmd, cancellationToken);
// 3. 如果不存在,返回 404
if (result is null)
{
return ApiResponse<SubscriptionListDto>.Error(
ErrorCodes.NotFound,
"订阅不存在");
}
// 4. 返回变更后的订阅信息
return ApiResponse<SubscriptionListDto>.Ok(result);
}
/// <summary>
/// 更新订阅状态。
/// </summary>
/// <param name="id">订阅 ID。</param>
/// <param name="command">更新状态命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新后的订阅信息。</returns>
[HttpPost("{id:long}/status")]
[PermissionAuthorize("tenant:subscription")]
[ProducesResponseType(typeof(ApiResponse<SubscriptionListDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<SubscriptionListDto>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<SubscriptionListDto>> UpdateStatus(
[FromRoute] long id,
[FromBody] UpdateStatusCommand command,
CancellationToken cancellationToken = default)
{
// 1. 构造命令(使用路由中的 ID
var cmd = command with { SubscriptionId = id };
// 2. 执行命令
var result = await mediator.Send(cmd, cancellationToken);
// 3. 如果不存在,返回 404
if (result is null)
{
return ApiResponse<SubscriptionListDto>.Error(
ErrorCodes.NotFound,
"订阅不存在");
}
// 4. 返回更新后的订阅信息
return ApiResponse<SubscriptionListDto>.Ok(result);
}
}

View File

@@ -0,0 +1,238 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.TenantPackages.Commands;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Application.App.TenantPackages.Queries;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
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}/tenant-packages")]
public sealed class TenantPackagesController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 获取租户套餐列表(分页)。
/// </summary>
/// <param name="Keyword">关键字(套餐名称)。</param>
/// <param name="IsActive">是否启用。</param>
/// <param name="Page">页码(从 1 开始)。</param>
/// <param name="PageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>套餐分页列表。</returns>
[HttpGet]
[PermissionAuthorize("tenant-package:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantPackageListDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<TenantPackageListDto>>> List(
[FromQuery] string? Keyword,
[FromQuery] bool? IsActive,
[FromQuery] int Page = 1,
[FromQuery] int PageSize = 10,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new ListTenantPackagesQuery
{
Keyword = Keyword,
IsActive = IsActive,
Page = Page,
PageSize = PageSize
};
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回套餐分页列表
return ApiResponse<PagedResult<TenantPackageListDto>>.Ok(result);
}
/// <summary>
/// 获取租户套餐详情。
/// </summary>
/// <param name="tenantPackageId">套餐 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>套餐详情。</returns>
[HttpGet("{tenantPackageId:long}")]
[PermissionAuthorize("tenant-package:read")]
[ProducesResponseType(typeof(ApiResponse<TenantPackageListDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<TenantPackageListDto>> GetDetail(
[FromRoute] long tenantPackageId,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new GetTenantPackageDetailQuery
{
TenantPackageId = tenantPackageId
};
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 如果不存在,返回 404
if (result is null)
{
return ApiResponse<TenantPackageListDto>.Error(ErrorCodes.NotFound, "套餐不存在");
}
// 4. 返回套餐详情
return ApiResponse<TenantPackageListDto>.Ok(result);
}
/// <summary>
/// 获取租户套餐使用统计。
/// </summary>
/// <param name="tenantPackageIds">套餐 ID 列表。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>套餐使用统计列表。</returns>
[HttpGet("usages")]
[PermissionAuthorize("tenant-package:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TenantPackageUsageDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<TenantPackageUsageDto>>> GetUsages(
[FromQuery] long[] tenantPackageIds,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new GetTenantPackageUsagesQuery
{
TenantPackageIds = tenantPackageIds
};
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回套餐使用统计
return ApiResponse<IReadOnlyList<TenantPackageUsageDto>>.Ok(result);
}
/// <summary>
/// 创建租户套餐。
/// </summary>
/// <param name="command">创建命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>创建的套餐。</returns>
[HttpPost]
[PermissionAuthorize("tenant-package:create")]
[ProducesResponseType(typeof(ApiResponse<TenantPackageListDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TenantPackageListDto>> Create(
[FromBody] CreateTenantPackageCommand command,
CancellationToken cancellationToken = default)
{
// 1. 执行命令
var result = await mediator.Send(command, cancellationToken);
// 2. 返回创建的套餐
return ApiResponse<TenantPackageListDto>.Ok(result);
}
/// <summary>
/// 更新租户套餐。
/// </summary>
/// <param name="tenantPackageId">套餐 ID。</param>
/// <param name="command">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新后的套餐。</returns>
[HttpPut("{tenantPackageId:long}")]
[PermissionAuthorize("tenant-package:update")]
[ProducesResponseType(typeof(ApiResponse<TenantPackageListDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<TenantPackageListDto>> Update(
[FromRoute] long tenantPackageId,
[FromBody] UpdateTenantPackageCommand command,
CancellationToken cancellationToken = default)
{
// 1. 确保路由 ID 与命令 ID 一致
var updatedCommand = command with { TenantPackageId = tenantPackageId };
// 2. 执行命令
var result = await mediator.Send(updatedCommand, cancellationToken);
// 3. 如果不存在,返回 404
if (result is null)
{
return ApiResponse<TenantPackageListDto>.Error(ErrorCodes.NotFound, "套餐不存在");
}
// 4. 返回更新后的套餐
return ApiResponse<TenantPackageListDto>.Ok(result);
}
/// <summary>
/// 删除租户套餐(软删除)。
/// </summary>
/// <param name="tenantPackageId">套餐 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>是否删除成功。</returns>
[HttpDelete("{tenantPackageId:long}")]
[PermissionAuthorize("tenant-package:delete")]
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<bool>> Delete(
[FromRoute] long tenantPackageId,
CancellationToken cancellationToken = default)
{
// 1. 构造命令
var command = new DeleteTenantPackageCommand
{
TenantPackageId = tenantPackageId
};
// 2. 执行命令
var result = await mediator.Send(command, cancellationToken);
// 3. 如果不存在,返回 404
if (!result)
{
return ApiResponse<bool>.Error(ErrorCodes.NotFound, "套餐不存在");
}
// 4. 返回成功
return ApiResponse<bool>.Ok(true);
}
/// <summary>
/// 获取套餐当前使用租户列表(按有效订阅口径)。
/// </summary>
/// <param name="tenantPackageId">套餐 ID。</param>
/// <param name="Keyword">关键字(租户名称或编码)。</param>
/// <param name="ExpiringWithinDays">即将到期天数筛选。</param>
/// <param name="Page">页码(从 1 开始)。</param>
/// <param name="PageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>租户分页列表。</returns>
[HttpGet("{tenantPackageId:long}/tenants")]
[PermissionAuthorize("tenant-package:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantPackageTenantDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<TenantPackageTenantDto>>> GetTenants(
[FromRoute] long tenantPackageId,
[FromQuery] string? Keyword,
[FromQuery] int? ExpiringWithinDays,
[FromQuery] int Page = 1,
[FromQuery] int PageSize = 20,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new GetTenantPackageTenantsQuery
{
TenantPackageId = tenantPackageId,
Keyword = Keyword,
ExpiringWithinDays = ExpiringWithinDays,
Page = Page,
PageSize = PageSize
};
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回租户分页列表
return ApiResponse<PagedResult<TenantPackageTenantDto>>.Ok(result);
}
}

View File

@@ -48,4 +48,86 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
// 3. 返回租户分页列表
return ApiResponse<PagedResult<TenantListItemDto>>.Ok(result);
}
/// <summary>
/// 获取租户详情(包含认证、订阅、套餐信息)。
/// </summary>
/// <param name="id">租户 ID雪花算法。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>租户详情。</returns>
[HttpGet("{id:long}")]
[PermissionAuthorize("tenant:read")]
[ProducesResponseType(typeof(ApiResponse<TenantDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetDetail(long id, CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new GetTenantDetailQuery { TenantId = id };
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回结果
if (result is null)
{
return NotFound(ApiResponse<object>.Error(404, "租户不存在"));
}
return Ok(ApiResponse<TenantDetailDto>.Ok(result));
}
/// <summary>
/// 获取租户配额使用情况。
/// </summary>
/// <param name="id">租户 ID雪花算法。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>配额使用情况列表。</returns>
[HttpGet("{id:long}/quota-usage")]
[PermissionAuthorize("tenant:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TenantQuotaUsageDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<TenantQuotaUsageDto>>> GetQuotaUsage(
long id,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new GetTenantQuotaUsageQuery { TenantId = id };
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回配额使用情况列表
return ApiResponse<IReadOnlyList<TenantQuotaUsageDto>>.Ok(result);
}
/// <summary>
/// 获取租户账单列表(分页)。
/// </summary>
/// <param name="id">租户 ID雪花算法。</param>
/// <param name="Page">页码(从 1 开始)。</param>
/// <param name="PageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单分页列表。</returns>
[HttpGet("{id:long}/billings")]
[PermissionAuthorize("tenant:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantBillingListDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<TenantBillingListDto>>> GetBillings(
long id,
[FromQuery] int Page = 1,
[FromQuery] int PageSize = 10,
CancellationToken cancellationToken = default)
{
// 1. 构造查询
var query = new GetTenantBillingsQuery
{
TenantId = id,
Page = Page,
PageSize = PageSize
};
// 2. 执行查询
var result = await mediator.Send(query, cancellationToken);
// 3. 返回账单分页列表
return ApiResponse<PagedResult<TenantBillingListDto>>.Ok(result);
}
}

View File

@@ -165,8 +165,8 @@
"RabbitMQ": {
"Host": "49.232.6.45",
"Port": 5672,
"Username": "Admin",
"Password": "MsuMshk112233",
"Username": "admin",
"Password": "msumshk112233",
"VirtualHost": "/",
"Exchange": "takeout.events",
"ExchangeType": "topic",

View File

@@ -165,8 +165,8 @@
"RabbitMQ": {
"Host": "49.232.6.45",
"Port": 5672,
"Username": "Admin",
"Password": "MsuMshk112233",
"Username": "admin",
"Password": "msumshk112233",
"VirtualHost": "/",
"Exchange": "takeout.events",
"ExchangeType": "topic",

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Contracts;
using TakeoutSaaS.Domain.Billings.Enums;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 确认收款命令(记录支付 + 立即审核通过 + 同步更新账单状态)。
/// </summary>
public sealed record ConfirmPaymentCommand : IRequest<PaymentRecordDto?>
{
/// <summary>
/// 账单 ID。
/// </summary>
public long BillingId { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public TenantPaymentMethod 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; }
}

View File

@@ -0,0 +1,197 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Contracts;
/// <summary>
/// 账单详情 DTO。
/// </summary>
public sealed record BillingDetailDto
{
/// <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>
/// 账单类型。
/// </summary>
public TenantBillingType BillingType { get; init; }
/// <summary>
/// 账单周期开始时间。
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 账单周期结束时间。
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 货币代码。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日期。
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 账单明细 JSON。
/// </summary>
public string? LineItemsJson { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; init; }
/// <summary>
/// 提醒发送时间。
/// </summary>
public DateTime? ReminderSentAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
/// <summary>
/// 支付记录列表。
/// </summary>
public IReadOnlyList<PaymentRecordDto> Payments { get; init; } = [];
}
/// <summary>
/// 支付记录 DTO。
/// </summary>
public sealed record PaymentRecordDto
{
/// <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 int Method { get; init; }
/// <summary>
/// 支付状态。
/// </summary>
public int 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>
/// 审核人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? VerifiedBy { get; init; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? VerifiedAt { get; init; }
/// <summary>
/// 退款原因。
/// </summary>
public string? RefundReason { get; init; }
/// <summary>
/// 退款时间。
/// </summary>
public DateTime? RefundedAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,98 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Contracts;
/// <summary>
/// 账单列表项 DTO。
/// </summary>
public sealed record BillingListDto
{
/// <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>
/// 账单类型。
/// </summary>
public TenantBillingType BillingType { get; init; }
/// <summary>
/// 账单周期开始时间。
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 账单周期结束时间。
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 货币代码。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日期。
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,92 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Application.App.Billings.Contracts;
using TakeoutSaaS.Domain.Billings.Entities;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Domain.Billings.Repositories;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 确认收款命令处理器。
/// </summary>
public sealed class ConfirmPaymentCommandHandler(
IBillingRepository billingRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ConfirmPaymentCommand, PaymentRecordDto?>
{
/// <inheritdoc />
public async Task<PaymentRecordDto?> Handle(
ConfirmPaymentCommand request,
CancellationToken cancellationToken)
{
// 1. 获取账单(带跟踪)
var billing = await billingRepository.GetByIdForUpdateAsync(request.BillingId, cancellationToken);
// 2. 如果不存在,返回 null
if (billing is null)
{
return null;
}
// 3. 获取当前用户 ID
var currentUserId = currentUserAccessor.UserId;
var now = DateTime.UtcNow;
// 4. 创建支付记录(已审核通过状态)
var payment = new TenantPayment
{
TenantId = billing.TenantId,
BillingStatementId = billing.Id,
Amount = request.Amount,
Method = request.Method,
Status = TenantPaymentStatus.Success,
TransactionNo = request.TransactionNo,
ProofUrl = request.ProofUrl,
Notes = request.Notes,
PaidAt = now,
VerifiedBy = currentUserId,
VerifiedAt = now,
CreatedAt = now,
CreatedBy = currentUserId
};
// 5. 添加支付记录
await billingRepository.AddPaymentAsync(payment, cancellationToken);
// 6. 更新账单已付金额
billing.AmountPaid += request.Amount;
// 7. 如果已付金额 >= 应付金额,更新账单状态为已支付
if (billing.AmountPaid >= billing.AmountDue)
{
billing.Status = TenantBillingStatus.Paid;
}
// 8. 更新账单时间戳
billing.UpdatedAt = now;
// 9. 保存变更
await billingRepository.SaveChangesAsync(cancellationToken);
// 10. 返回支付记录 DTO
return new PaymentRecordDto
{
Id = payment.Id,
BillingStatementId = payment.BillingStatementId,
Amount = payment.Amount,
Method = (int)payment.Method,
Status = (int)payment.Status,
TransactionNo = payment.TransactionNo,
ProofUrl = payment.ProofUrl,
PaidAt = payment.PaidAt,
Notes = payment.Notes,
VerifiedBy = payment.VerifiedBy,
VerifiedAt = payment.VerifiedAt,
RefundReason = payment.RefundReason,
RefundedAt = payment.RefundedAt,
CreatedAt = payment.CreatedAt
};
}
}

View File

@@ -0,0 +1,54 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Contracts;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Billings.Repositories;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 获取账单详情查询处理器。
/// </summary>
public sealed class GetBillingDetailQueryHandler(IBillingRepository billingRepository)
: IRequestHandler<GetBillingDetailQuery, BillingDetailDto?>
{
/// <inheritdoc />
public async Task<BillingDetailDto?> Handle(
GetBillingDetailQuery request,
CancellationToken cancellationToken)
{
// 1. 查询账单详情
var detail = await billingRepository.GetDetailAsync(request.BillingId, cancellationToken);
// 2. 如果不存在,返回 null
if (detail is null)
{
return null;
}
// 3. 映射为 DTO 并返回(支付记录暂时返回空列表)
return new BillingDetailDto
{
Id = detail.Id,
TenantId = detail.TenantId,
TenantName = detail.TenantName,
StatementNo = detail.StatementNo,
BillingType = detail.BillingType,
PeriodStart = detail.PeriodStart,
PeriodEnd = detail.PeriodEnd,
AmountDue = detail.AmountDue,
AmountPaid = detail.AmountPaid,
DiscountAmount = detail.DiscountAmount,
TaxAmount = detail.TaxAmount,
Currency = detail.Currency,
Status = detail.Status,
DueDate = detail.DueDate,
LineItemsJson = detail.LineItemsJson,
Notes = detail.Notes,
OverdueNotifiedAt = detail.OverdueNotifiedAt,
ReminderSentAt = detail.ReminderSentAt,
CreatedAt = detail.CreatedAt,
UpdatedAt = detail.UpdatedAt,
Payments = []
};
}
}

View File

@@ -0,0 +1,61 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Contracts;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Billings.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 获取账单列表查询处理器。
/// </summary>
public sealed class ListBillingsQueryHandler(IBillingRepository billingRepository)
: IRequestHandler<ListBillingsQuery, PagedResult<BillingListDto>>
{
/// <inheritdoc />
public async Task<PagedResult<BillingListDto>> Handle(
ListBillingsQuery request,
CancellationToken cancellationToken)
{
// 1. 查询账单列表
var (items, totalCount) = await billingRepository.GetListAsync(
request.TenantId,
request.Status,
request.BillingType,
request.StartDate,
request.EndDate,
request.MinAmount,
request.MaxAmount,
request.Keyword,
request.SortBy,
request.SortDesc,
request.PageNumber,
request.PageSize,
cancellationToken);
// 2. 映射为 DTO
var dtos = items.Select(b => new BillingListDto
{
Id = b.Id,
TenantId = b.TenantId,
TenantName = b.TenantName,
StatementNo = b.StatementNo,
BillingType = b.BillingType,
PeriodStart = b.PeriodStart,
PeriodEnd = b.PeriodEnd,
AmountDue = b.AmountDue,
AmountPaid = b.AmountPaid,
DiscountAmount = b.DiscountAmount,
TaxAmount = b.TaxAmount,
Currency = b.Currency,
Status = b.Status,
DueDate = b.DueDate,
OverdueNotifiedAt = b.OverdueNotifiedAt,
CreatedAt = b.CreatedAt,
UpdatedAt = b.UpdatedAt
}).ToList();
// 3. 返回分页结果
return new PagedResult<BillingListDto>(dtos, totalCount, request.PageNumber, request.PageSize);
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Contracts;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 获取账单详情查询。
/// </summary>
public sealed record GetBillingDetailQuery : IRequest<BillingDetailDto?>
{
/// <summary>
/// 账单 ID。
/// </summary>
public long BillingId { get; init; }
}

View File

@@ -0,0 +1,72 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Contracts;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 获取账单列表查询。
/// </summary>
public sealed record ListBillingsQuery : IRequest<PagedResult<BillingListDto>>
{
/// <summary>
/// 租户 ID。
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus? Status { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public TenantBillingType? BillingType { get; init; }
/// <summary>
/// 开始日期。
/// </summary>
public DateTime? StartDate { get; init; }
/// <summary>
/// 结束日期。
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// 最小金额。
/// </summary>
public decimal? MinAmount { get; init; }
/// <summary>
/// 最大金额。
/// </summary>
public decimal? MaxAmount { get; init; }
/// <summary>
/// 关键词(账单号、租户名)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 排序字段。
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否降序。
/// </summary>
public bool? SortDesc { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int PageNumber { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 变更套餐命令。
/// </summary>
public sealed record ChangePlanCommand : IRequest<SubscriptionListDto?>
{
/// <summary>
/// 订阅 ID。
/// </summary>
public long SubscriptionId { get; init; }
/// <summary>
/// 目标套餐 ID。
/// </summary>
public long TargetPackageId { get; init; }
/// <summary>
/// 是否立即生效。
/// </summary>
public bool Immediate { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 延期订阅命令。
/// </summary>
public sealed record ExtendSubscriptionCommand : IRequest<SubscriptionListDto?>
{
/// <summary>
/// 订阅 ID。
/// </summary>
public long SubscriptionId { get; init; }
/// <summary>
/// 延期月数。
/// </summary>
public int DurationMonths { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 更新订阅状态命令。
/// </summary>
public sealed record UpdateStatusCommand : IRequest<SubscriptionListDto?>
{
/// <summary>
/// 订阅 ID。
/// </summary>
public long SubscriptionId { get; init; }
/// <summary>
/// 目标状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 更新订阅命令。
/// </summary>
public sealed record UpdateSubscriptionCommand : IRequest<SubscriptionListDto?>
{
/// <summary>
/// 订阅 ID。
/// </summary>
public long SubscriptionId { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool? AutoRenew { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,222 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Subscriptions.Contracts;
/// <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 string PackageName { get; init; } = string.Empty;
/// <summary>
/// 排期套餐 ID下周期生效雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? ScheduledPackageId { get; init; }
/// <summary>
/// 排期套餐名称。
/// </summary>
public string? ScheduledPackageName { get; init; }
/// <summary>
/// 订阅状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间。
/// </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; }
/// <summary>
/// 当前套餐信息。
/// </summary>
public TenantPackageListDto? Package { get; init; }
/// <summary>
/// 排期套餐信息。
/// </summary>
public TenantPackageListDto? ScheduledPackage { get; init; }
/// <summary>
/// 配额使用情况列表。
/// </summary>
public IReadOnlyList<SubscriptionQuotaUsageDto> QuotaUsages { get; init; } = [];
/// <summary>
/// 订阅变更历史列表。
/// </summary>
public IReadOnlyList<SubscriptionHistoryDto> ChangeHistory { get; init; } = [];
}
/// <summary>
/// 订阅配额使用情况 DTO。
/// </summary>
public sealed record SubscriptionQuotaUsageDto
{
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 配额名称。
/// </summary>
public string QuotaName { get; init; } = string.Empty;
/// <summary>
/// 配额上限。
/// </summary>
public int? Limit { get; init; }
/// <summary>
/// 已使用量。
/// </summary>
public int Used { get; init; }
/// <summary>
/// 剩余量。
/// </summary>
public int? Remaining { get; init; }
/// <summary>
/// 使用百分比。
/// </summary>
public decimal? UsagePercentage { get; init; }
}
/// <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 SubscriptionId { get; init; }
/// <summary>
/// 变更类型。
/// </summary>
public string ChangeType { get; init; } = string.Empty;
/// <summary>
/// 变更前套餐 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? PreviousPackageId { get; init; }
/// <summary>
/// 变更前套餐名称。
/// </summary>
public string? PreviousPackageName { get; init; }
/// <summary>
/// 变更后套餐 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? NewPackageId { get; init; }
/// <summary>
/// 变更后套餐名称。
/// </summary>
public string? NewPackageName { get; init; }
/// <summary>
/// 变更前到期时间。
/// </summary>
public DateTime? PreviousEffectiveTo { get; init; }
/// <summary>
/// 变更后到期时间。
/// </summary>
public DateTime? NewEffectiveTo { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 创建人。
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,95 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Subscriptions.Contracts;
/// <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(NullableSnowflakeIdJsonConverter))]
public long? ScheduledPackageId { get; init; }
/// <summary>
/// 排期套餐名称。
/// </summary>
public string? ScheduledPackageName { get; init; }
/// <summary>
/// 订阅状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间。
/// </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; }
}

View File

@@ -0,0 +1,91 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 变更套餐命令处理器。
/// </summary>
public sealed class ChangePlanCommandHandler(ISubscriptionRepository subscriptionRepository)
: IRequestHandler<ChangePlanCommand, SubscriptionListDto?>
{
/// <inheritdoc />
public async Task<SubscriptionListDto?> Handle(
ChangePlanCommand request,
CancellationToken cancellationToken)
{
// 1. 获取订阅(带跟踪)
var subscription = await subscriptionRepository.GetByIdForUpdateAsync(request.SubscriptionId, cancellationToken);
// 2. 如果不存在,返回 null
if (subscription is null)
{
return null;
}
// 3. 根据是否立即生效处理套餐变更
if (request.Immediate)
{
// 3.1 立即生效:直接更新当前套餐
subscription.TenantPackageId = request.TargetPackageId;
// 3.2 清除排期套餐(如果有)
subscription.ScheduledPackageId = null;
}
else
{
// 3.3 下周期生效:设置排期套餐
subscription.ScheduledPackageId = request.TargetPackageId;
}
// 4. 更新备注(如果提供)
if (!string.IsNullOrWhiteSpace(request.Notes))
{
var existingNotes = subscription.Notes ?? string.Empty;
var changeNote = request.Immediate
? $"[立即变更套餐] {request.Notes}"
: $"[排期变更套餐] {request.Notes}";
subscription.Notes = string.IsNullOrWhiteSpace(existingNotes)
? changeNote
: $"{existingNotes}\n{changeNote}";
}
// 5. 更新时间戳
subscription.UpdatedAt = DateTime.UtcNow;
// 6. 保存变更
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 7. 获取更新后的订阅信息
var result = await subscriptionRepository.GetListResultByIdAsync(request.SubscriptionId, cancellationToken);
// 8. 如果不存在,返回 null
if (result is null)
{
return null;
}
// 9. 映射为 DTO 并返回
return new SubscriptionListDto
{
Id = result.Id,
TenantId = result.TenantId,
TenantName = result.TenantName,
TenantCode = result.TenantCode,
TenantPackageId = result.TenantPackageId,
PackageName = result.PackageName,
ScheduledPackageId = result.ScheduledPackageId,
ScheduledPackageName = result.ScheduledPackageName,
Status = result.Status,
EffectiveFrom = result.EffectiveFrom,
EffectiveTo = result.EffectiveTo,
NextBillingDate = result.NextBillingDate,
AutoRenew = result.AutoRenew,
Notes = result.Notes,
CreatedAt = result.CreatedAt,
UpdatedAt = result.UpdatedAt
};
}
}

View File

@@ -0,0 +1,83 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 延期订阅命令处理器。
/// </summary>
public sealed class ExtendSubscriptionCommandHandler(ISubscriptionRepository subscriptionRepository)
: IRequestHandler<ExtendSubscriptionCommand, SubscriptionListDto?>
{
/// <inheritdoc />
public async Task<SubscriptionListDto?> Handle(
ExtendSubscriptionCommand request,
CancellationToken cancellationToken)
{
// 1. 获取订阅(带跟踪)
var subscription = await subscriptionRepository.GetByIdForUpdateAsync(request.SubscriptionId, cancellationToken);
// 2. 如果不存在,返回 null
if (subscription is null)
{
return null;
}
// 3. 延期到期时间
subscription.EffectiveTo = subscription.EffectiveTo.AddMonths(request.DurationMonths);
// 4. 更新下次计费时间(如果有)
if (subscription.NextBillingDate.HasValue)
{
subscription.NextBillingDate = subscription.NextBillingDate.Value.AddMonths(request.DurationMonths);
}
// 5. 更新备注(如果提供)
if (!string.IsNullOrWhiteSpace(request.Notes))
{
var existingNotes = subscription.Notes ?? string.Empty;
var extendNote = $"[延期 {request.DurationMonths} 个月] {request.Notes}";
subscription.Notes = string.IsNullOrWhiteSpace(existingNotes)
? extendNote
: $"{existingNotes}\n{extendNote}";
}
// 6. 更新时间戳
subscription.UpdatedAt = DateTime.UtcNow;
// 7. 保存变更
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 8. 获取更新后的订阅信息
var result = await subscriptionRepository.GetListResultByIdAsync(request.SubscriptionId, cancellationToken);
// 9. 如果不存在,返回 null
if (result is null)
{
return null;
}
// 10. 映射为 DTO 并返回
return new SubscriptionListDto
{
Id = result.Id,
TenantId = result.TenantId,
TenantName = result.TenantName,
TenantCode = result.TenantCode,
TenantPackageId = result.TenantPackageId,
PackageName = result.PackageName,
ScheduledPackageId = result.ScheduledPackageId,
ScheduledPackageName = result.ScheduledPackageName,
Status = result.Status,
EffectiveFrom = result.EffectiveFrom,
EffectiveTo = result.EffectiveTo,
NextBillingDate = result.NextBillingDate,
AutoRenew = result.AutoRenew,
Notes = result.Notes,
CreatedAt = result.CreatedAt,
UpdatedAt = result.UpdatedAt
};
}
}

View File

@@ -0,0 +1,152 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 获取订阅详情查询处理器。
/// </summary>
public sealed class GetSubscriptionDetailQueryHandler(
ISubscriptionRepository subscriptionRepository,
ITenantRepository tenantRepository)
: IRequestHandler<GetSubscriptionDetailQuery, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(
GetSubscriptionDetailQuery request,
CancellationToken cancellationToken)
{
// 1. 查询订阅详情
var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken);
// 2. 如果不存在,返回 null
if (detail is null)
{
return null;
}
// 3. 查询变更历史
var histories = await subscriptionRepository.GetHistoriesAsync(request.SubscriptionId, cancellationToken);
// 4. 查询配额使用情况
var quotaUsages = await tenantRepository.GetQuotaUsagesAsync(detail.TenantId, cancellationToken);
// 5. 映射套餐信息
var packageDto = MapToPackageDto(detail.Package);
var scheduledPackageDto = detail.ScheduledPackage is not null
? MapToPackageDto(detail.ScheduledPackage)
: null;
// 6. 映射配额使用情况
var quotaUsageDtos = quotaUsages.Select(u =>
{
var limit = (int?)u.LimitValue;
var used = (int)u.UsedValue;
var remaining = limit.HasValue ? Math.Max(0, limit.Value - used) : (int?)null;
var usagePercentage = limit.HasValue && limit.Value > 0
? Math.Round((decimal)used / limit.Value * 100, 2)
: (decimal?)null;
return new SubscriptionQuotaUsageDto
{
QuotaType = u.QuotaType,
QuotaName = GetQuotaTypeName(u.QuotaType),
Limit = limit,
Used = used,
Remaining = remaining,
UsagePercentage = usagePercentage
};
}).ToList();
// 7. 映射变更历史
var historyDtos = histories.Select(h => new SubscriptionHistoryDto
{
Id = h.Id,
SubscriptionId = h.SubscriptionId,
ChangeType = h.ChangeType.ToString(),
PreviousPackageId = h.FromPackageId,
PreviousPackageName = h.FromPackageName,
NewPackageId = h.ToPackageId,
NewPackageName = h.ToPackageName,
PreviousEffectiveTo = h.EffectiveFrom,
NewEffectiveTo = h.EffectiveTo,
Notes = h.Notes,
CreatedAt = h.CreatedAt,
CreatedBy = h.CreatedBy?.ToString()
}).ToList();
// 8. 返回订阅详情 DTO
return new SubscriptionDetailDto
{
Id = detail.Id,
TenantId = detail.TenantId,
TenantName = detail.TenantName,
TenantCode = detail.TenantCode,
TenantPackageId = detail.TenantPackageId,
PackageName = detail.PackageName,
ScheduledPackageId = detail.ScheduledPackageId,
ScheduledPackageName = detail.ScheduledPackageName,
Status = detail.Status,
EffectiveFrom = detail.EffectiveFrom,
EffectiveTo = detail.EffectiveTo,
NextBillingDate = detail.NextBillingDate,
AutoRenew = detail.AutoRenew,
Notes = detail.Notes,
CreatedAt = detail.CreatedAt,
UpdatedAt = detail.UpdatedAt,
Package = packageDto,
ScheduledPackage = scheduledPackageDto,
QuotaUsages = quotaUsageDtos,
ChangeHistory = historyDtos
};
}
/// <summary>
/// 映射套餐实体为 DTO。
/// </summary>
private static TenantPackageListDto MapToPackageDto(TakeoutSaaS.Domain.Tenants.Entities.TenantPackage package)
{
return new TenantPackageListDto
{
Id = package.Id,
Name = package.Name,
Description = package.Description,
PackageType = package.PackageType,
MonthlyPrice = package.MonthlyPrice,
YearlyPrice = package.YearlyPrice,
MaxStoreCount = package.MaxStoreCount,
MaxAccountCount = package.MaxAccountCount,
MaxStorageGb = package.MaxStorageGb,
MaxSmsCredits = package.MaxSmsCredits,
MaxDeliveryOrders = package.MaxDeliveryOrders,
FeaturePoliciesJson = package.FeaturePoliciesJson,
IsActive = package.IsActive,
IsPublicVisible = package.IsPublicVisible,
IsAllowNewTenantPurchase = package.IsAllowNewTenantPurchase,
PublishStatus = package.PublishStatus,
IsRecommended = package.IsRecommended,
Tags = package.Tags ?? [],
SortOrder = package.SortOrder
};
}
/// <summary>
/// 获取配额类型名称。
/// </summary>
private static string GetQuotaTypeName(TenantQuotaType quotaType)
{
return quotaType switch
{
TenantQuotaType.Store => "门店数量",
TenantQuotaType.Account => "账号数量",
TenantQuotaType.StorageGb => "存储空间(GB)",
TenantQuotaType.SmsCredits => "短信额度",
TenantQuotaType.DeliveryOrders => "配送订单数",
_ => quotaType.ToString()
};
}
}

View File

@@ -0,0 +1,58 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
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 ListSubscriptionsQueryHandler(ISubscriptionRepository subscriptionRepository)
: IRequestHandler<ListSubscriptionsQuery, PagedResult<SubscriptionListDto>>
{
/// <inheritdoc />
public async Task<PagedResult<SubscriptionListDto>> Handle(
ListSubscriptionsQuery request,
CancellationToken cancellationToken)
{
// 1. 查询订阅列表
var (items, totalCount) = await subscriptionRepository.GetListAsync(
request.Status,
request.TenantPackageId,
request.TenantId,
request.TenantKeyword,
request.ExpiringWithinDays,
request.AutoRenew,
request.ExpireFrom,
request.ExpireTo,
request.Page,
request.PageSize,
cancellationToken);
// 2. 映射为 DTO
var dtos = items.Select(s => new SubscriptionListDto
{
Id = s.Id,
TenantId = s.TenantId,
TenantName = s.TenantName,
TenantCode = s.TenantCode,
TenantPackageId = s.TenantPackageId,
PackageName = s.PackageName,
ScheduledPackageId = s.ScheduledPackageId,
ScheduledPackageName = s.ScheduledPackageName,
Status = s.Status,
EffectiveFrom = s.EffectiveFrom,
EffectiveTo = s.EffectiveTo,
NextBillingDate = s.NextBillingDate,
AutoRenew = s.AutoRenew,
Notes = s.Notes,
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt
}).ToList();
// 3. 返回分页结果
return new PagedResult<SubscriptionListDto>(dtos, totalCount, request.Page, request.PageSize);
}
}

View File

@@ -0,0 +1,78 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 更新订阅状态命令处理器。
/// </summary>
public sealed class UpdateStatusCommandHandler(ISubscriptionRepository subscriptionRepository)
: IRequestHandler<UpdateStatusCommand, SubscriptionListDto?>
{
/// <inheritdoc />
public async Task<SubscriptionListDto?> Handle(
UpdateStatusCommand request,
CancellationToken cancellationToken)
{
// 1. 获取订阅(带跟踪)
var subscription = await subscriptionRepository.GetByIdForUpdateAsync(request.SubscriptionId, cancellationToken);
// 2. 如果不存在,返回 null
if (subscription is null)
{
return null;
}
// 3. 更新状态
var oldStatus = subscription.Status;
subscription.Status = request.Status;
// 4. 更新备注(如果提供)
if (!string.IsNullOrWhiteSpace(request.Notes))
{
var existingNotes = subscription.Notes ?? string.Empty;
var statusNote = $"[状态变更: {oldStatus} -> {request.Status}] {request.Notes}";
subscription.Notes = string.IsNullOrWhiteSpace(existingNotes)
? statusNote
: $"{existingNotes}\n{statusNote}";
}
// 5. 更新时间戳
subscription.UpdatedAt = DateTime.UtcNow;
// 6. 保存变更
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 7. 获取更新后的订阅信息
var result = await subscriptionRepository.GetListResultByIdAsync(request.SubscriptionId, cancellationToken);
// 8. 如果不存在,返回 null
if (result is null)
{
return null;
}
// 9. 映射为 DTO 并返回
return new SubscriptionListDto
{
Id = result.Id,
TenantId = result.TenantId,
TenantName = result.TenantName,
TenantCode = result.TenantCode,
TenantPackageId = result.TenantPackageId,
PackageName = result.PackageName,
ScheduledPackageId = result.ScheduledPackageId,
ScheduledPackageName = result.ScheduledPackageName,
Status = result.Status,
EffectiveFrom = result.EffectiveFrom,
EffectiveTo = result.EffectiveTo,
NextBillingDate = result.NextBillingDate,
AutoRenew = result.AutoRenew,
Notes = result.Notes,
CreatedAt = result.CreatedAt,
UpdatedAt = result.UpdatedAt
};
}
}

View File

@@ -0,0 +1,75 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 更新订阅命令处理器。
/// </summary>
public sealed class UpdateSubscriptionCommandHandler(ISubscriptionRepository subscriptionRepository)
: IRequestHandler<UpdateSubscriptionCommand, SubscriptionListDto?>
{
/// <inheritdoc />
public async Task<SubscriptionListDto?> Handle(
UpdateSubscriptionCommand request,
CancellationToken cancellationToken)
{
// 1. 获取订阅(带跟踪)
var subscription = await subscriptionRepository.GetByIdForUpdateAsync(request.SubscriptionId, cancellationToken);
// 2. 如果不存在,返回 null
if (subscription is null)
{
return null;
}
// 3. 更新字段
if (request.AutoRenew.HasValue)
{
subscription.AutoRenew = request.AutoRenew.Value;
}
if (request.Notes is not null)
{
subscription.Notes = request.Notes;
}
// 4. 更新时间戳
subscription.UpdatedAt = DateTime.UtcNow;
// 5. 保存变更
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 6. 获取更新后的订阅信息
var result = await subscriptionRepository.GetListResultByIdAsync(request.SubscriptionId, cancellationToken);
// 7. 如果不存在,返回 null
if (result is null)
{
return null;
}
// 8. 映射为 DTO 并返回
return new SubscriptionListDto
{
Id = result.Id,
TenantId = result.TenantId,
TenantName = result.TenantName,
TenantCode = result.TenantCode,
TenantPackageId = result.TenantPackageId,
PackageName = result.PackageName,
ScheduledPackageId = result.ScheduledPackageId,
ScheduledPackageName = result.ScheduledPackageName,
Status = result.Status,
EffectiveFrom = result.EffectiveFrom,
EffectiveTo = result.EffectiveTo,
NextBillingDate = result.NextBillingDate,
AutoRenew = result.AutoRenew,
Notes = result.Notes,
CreatedAt = result.CreatedAt,
UpdatedAt = result.UpdatedAt
};
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
namespace TakeoutSaaS.Application.App.Subscriptions.Queries;
/// <summary>
/// 获取订阅详情查询。
/// </summary>
public sealed record GetSubscriptionDetailQuery : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID。
/// </summary>
public long SubscriptionId { get; init; }
}

View File

@@ -0,0 +1,62 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Subscriptions.Queries;
/// <summary>
/// 获取订阅列表查询。
/// </summary>
public sealed record ListSubscriptionsQuery : 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>
/// 到期时间范围开始。
/// </summary>
public DateTime? ExpireFrom { get; init; }
/// <summary>
/// 到期时间范围结束。
/// </summary>
public DateTime? ExpireTo { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,101 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.TenantPackages.Commands;
/// <summary>
/// 创建租户套餐命令。
/// </summary>
public sealed record CreateTenantPackageCommand : IRequest<TenantPackageListDto>
{
/// <summary>
/// 套餐名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 套餐描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 套餐类型。
/// </summary>
public TenantPackageType PackageType { get; init; }
/// <summary>
/// 月付价格。
/// </summary>
public decimal? MonthlyPrice { get; init; }
/// <summary>
/// 年付价格。
/// </summary>
public decimal? YearlyPrice { get; init; }
/// <summary>
/// 最大门店数。
/// </summary>
public int? MaxStoreCount { get; init; }
/// <summary>
/// 最大账号数。
/// </summary>
public int? MaxAccountCount { get; init; }
/// <summary>
/// 最大存储空间GB
/// </summary>
public int? MaxStorageGb { get; init; }
/// <summary>
/// 最大短信额度。
/// </summary>
public int? MaxSmsCredits { get; init; }
/// <summary>
/// 最大配送订单数。
/// </summary>
public int? MaxDeliveryOrders { get; init; }
/// <summary>
/// 功能策略 JSON。
/// </summary>
public string? FeaturePoliciesJson { get; init; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsActive { get; init; }
/// <summary>
/// 是否公开可见。
/// </summary>
public bool IsPublicVisible { get; init; }
/// <summary>
/// 是否允许新租户购买。
/// </summary>
public bool IsAllowNewTenantPurchase { get; init; }
/// <summary>
/// 发布状态。
/// </summary>
public TenantPackagePublishStatus PublishStatus { get; init; }
/// <summary>
/// 是否推荐。
/// </summary>
public bool IsRecommended { get; init; }
/// <summary>
/// 标签列表。
/// </summary>
public string[] Tags { get; init; } = [];
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; init; }
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace TakeoutSaaS.Application.App.TenantPackages.Commands;
/// <summary>
/// 删除租户套餐命令(软删除)。
/// </summary>
public sealed record DeleteTenantPackageCommand : IRequest<bool>
{
/// <summary>
/// 套餐 ID。
/// </summary>
public long TenantPackageId { get; init; }
}

View File

@@ -0,0 +1,106 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.TenantPackages.Commands;
/// <summary>
/// 更新租户套餐命令。
/// </summary>
public sealed record UpdateTenantPackageCommand : IRequest<TenantPackageListDto?>
{
/// <summary>
/// 套餐 ID。
/// </summary>
public long TenantPackageId { get; init; }
/// <summary>
/// 套餐名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 套餐描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 套餐类型。
/// </summary>
public TenantPackageType PackageType { get; init; }
/// <summary>
/// 月付价格。
/// </summary>
public decimal? MonthlyPrice { get; init; }
/// <summary>
/// 年付价格。
/// </summary>
public decimal? YearlyPrice { get; init; }
/// <summary>
/// 最大门店数。
/// </summary>
public int? MaxStoreCount { get; init; }
/// <summary>
/// 最大账号数。
/// </summary>
public int? MaxAccountCount { get; init; }
/// <summary>
/// 最大存储空间GB
/// </summary>
public int? MaxStorageGb { get; init; }
/// <summary>
/// 最大短信额度。
/// </summary>
public int? MaxSmsCredits { get; init; }
/// <summary>
/// 最大配送订单数。
/// </summary>
public int? MaxDeliveryOrders { get; init; }
/// <summary>
/// 功能策略 JSON。
/// </summary>
public string? FeaturePoliciesJson { get; init; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsActive { get; init; }
/// <summary>
/// 是否公开可见。
/// </summary>
public bool IsPublicVisible { get; init; }
/// <summary>
/// 是否允许新租户购买。
/// </summary>
public bool IsAllowNewTenantPurchase { get; init; }
/// <summary>
/// 发布状态。
/// </summary>
public TenantPackagePublishStatus PublishStatus { get; init; }
/// <summary>
/// 是否推荐。
/// </summary>
public bool IsRecommended { get; init; }
/// <summary>
/// 标签列表。
/// </summary>
public string[] Tags { get; init; } = [];
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; init; }
}

View File

@@ -0,0 +1,107 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.TenantPackages.Contracts;
/// <summary>
/// 租户套餐列表项 DTO。
/// </summary>
public sealed record TenantPackageListDto
{
/// <summary>
/// 套餐 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 套餐名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 套餐描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 套餐类型。
/// </summary>
public TenantPackageType PackageType { get; init; }
/// <summary>
/// 月付价格。
/// </summary>
public decimal? MonthlyPrice { get; init; }
/// <summary>
/// 年付价格。
/// </summary>
public decimal? YearlyPrice { get; init; }
/// <summary>
/// 最大门店数。
/// </summary>
public int? MaxStoreCount { get; init; }
/// <summary>
/// 最大账号数。
/// </summary>
public int? MaxAccountCount { get; init; }
/// <summary>
/// 最大存储空间GB
/// </summary>
public int? MaxStorageGb { get; init; }
/// <summary>
/// 最大短信额度。
/// </summary>
public int? MaxSmsCredits { get; init; }
/// <summary>
/// 最大配送订单数。
/// </summary>
public int? MaxDeliveryOrders { get; init; }
/// <summary>
/// 功能策略 JSON。
/// </summary>
public string? FeaturePoliciesJson { get; init; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsActive { get; init; }
/// <summary>
/// 是否公开可见。
/// </summary>
public bool IsPublicVisible { get; init; }
/// <summary>
/// 是否允许新租户购买。
/// </summary>
public bool IsAllowNewTenantPurchase { get; init; }
/// <summary>
/// 发布状态。
/// </summary>
public TenantPackagePublishStatus PublishStatus { get; init; }
/// <summary>
/// 是否推荐。
/// </summary>
public bool IsRecommended { get; init; }
/// <summary>
/// 标签列表。
/// </summary>
public string[] Tags { get; init; } = [];
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; init; }
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.TenantPackages.Contracts;
/// <summary>
/// 套餐当前使用租户 DTO。
/// </summary>
public sealed record TenantPackageTenantDto
{
/// <summary>
/// 租户 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 租户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 租户状态。
/// </summary>
public TenantStatus Status { get; init; }
/// <summary>
/// 联系人姓名。
/// </summary>
public string? ContactName { get; init; }
/// <summary>
/// 联系人电话。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 订阅生效时间。
/// </summary>
public DateTime SubscriptionEffectiveFrom { get; init; }
/// <summary>
/// 订阅到期时间。
/// </summary>
public DateTime SubscriptionEffectiveTo { get; init; }
}

View File

@@ -0,0 +1,56 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.TenantPackages.Contracts;
/// <summary>
/// 租户套餐使用统计 DTO。
/// </summary>
public sealed record TenantPackageUsageDto
{
/// <summary>
/// 套餐 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantPackageId { get; init; }
/// <summary>
/// 活跃订阅数。
/// </summary>
public int ActiveSubscriptionCount { get; init; }
/// <summary>
/// 活跃租户数。
/// </summary>
public int ActiveTenantCount { get; init; }
/// <summary>
/// 总订阅数。
/// </summary>
public int TotalSubscriptionCount { get; init; }
/// <summary>
/// 月度经常性收入MRR
/// </summary>
public decimal Mrr { get; init; }
/// <summary>
/// 年度经常性收入ARR
/// </summary>
public decimal Arr { get; init; }
/// <summary>
/// 7 天内到期租户数。
/// </summary>
public int ExpiringTenantCount7Days { get; init; }
/// <summary>
/// 15 天内到期租户数。
/// </summary>
public int ExpiringTenantCount15Days { get; init; }
/// <summary>
/// 30 天内到期租户数。
/// </summary>
public int ExpiringTenantCount30Days { get; init; }
}

View File

@@ -0,0 +1,79 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Commands;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.TenantPackages.Handlers;
/// <summary>
/// 创建租户套餐命令处理器。
/// </summary>
public sealed class CreateTenantPackageCommandHandler(ITenantPackageRepository tenantPackageRepository)
: IRequestHandler<CreateTenantPackageCommand, TenantPackageListDto>
{
/// <inheritdoc />
public async Task<TenantPackageListDto> Handle(
CreateTenantPackageCommand request,
CancellationToken cancellationToken)
{
// 1. 校验必填字段
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new BusinessException(ErrorCodes.BadRequest, "套餐名称不能为空");
}
// 2. 构建套餐实体
var package = new TenantPackage
{
Name = request.Name.Trim(),
Description = request.Description,
PackageType = request.PackageType,
MonthlyPrice = request.MonthlyPrice,
YearlyPrice = request.YearlyPrice,
MaxStoreCount = request.MaxStoreCount,
MaxAccountCount = request.MaxAccountCount,
MaxStorageGb = request.MaxStorageGb,
MaxSmsCredits = request.MaxSmsCredits,
MaxDeliveryOrders = request.MaxDeliveryOrders,
FeaturePoliciesJson = request.FeaturePoliciesJson,
IsActive = request.IsActive,
IsPublicVisible = request.IsPublicVisible,
IsAllowNewTenantPurchase = request.IsAllowNewTenantPurchase,
PublishStatus = request.PublishStatus,
IsRecommended = request.IsRecommended,
Tags = request.Tags,
SortOrder = request.SortOrder
};
// 3. 持久化
await tenantPackageRepository.AddAsync(package, cancellationToken);
await tenantPackageRepository.SaveChangesAsync(cancellationToken);
// 4. 返回 DTO
return new TenantPackageListDto
{
Id = package.Id,
Name = package.Name,
Description = package.Description,
PackageType = package.PackageType,
MonthlyPrice = package.MonthlyPrice,
YearlyPrice = package.YearlyPrice,
MaxStoreCount = package.MaxStoreCount,
MaxAccountCount = package.MaxAccountCount,
MaxStorageGb = package.MaxStorageGb,
MaxSmsCredits = package.MaxSmsCredits,
MaxDeliveryOrders = package.MaxDeliveryOrders,
FeaturePoliciesJson = package.FeaturePoliciesJson,
IsActive = package.IsActive,
IsPublicVisible = package.IsPublicVisible,
IsAllowNewTenantPurchase = package.IsAllowNewTenantPurchase,
PublishStatus = package.PublishStatus,
IsRecommended = package.IsRecommended,
Tags = package.Tags,
SortOrder = package.SortOrder
};
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.TenantPackages.Handlers;
/// <summary>
/// 删除租户套餐命令处理器(软删除)。
/// </summary>
public sealed class DeleteTenantPackageCommandHandler(ITenantPackageRepository tenantPackageRepository)
: IRequestHandler<DeleteTenantPackageCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(
DeleteTenantPackageCommand request,
CancellationToken cancellationToken)
{
// 1. 查询套餐(带跟踪用于更新)
var package = await tenantPackageRepository.GetByIdForUpdateAsync(request.TenantPackageId, cancellationToken);
// 2. 如果不存在,返回 false
if (package is null)
{
return false;
}
// 3. 软删除
await tenantPackageRepository.SoftDeleteAsync(package, cancellationToken);
// 4. 保存变更
await tenantPackageRepository.SaveChangesAsync(cancellationToken);
// 5. 返回成功
return true;
}
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Application.App.TenantPackages.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.TenantPackages.Handlers;
/// <summary>
/// 获取租户套餐详情查询处理器。
/// </summary>
public sealed class GetTenantPackageDetailQueryHandler(ITenantPackageRepository tenantPackageRepository)
: IRequestHandler<GetTenantPackageDetailQuery, TenantPackageListDto?>
{
/// <inheritdoc />
public async Task<TenantPackageListDto?> Handle(
GetTenantPackageDetailQuery request,
CancellationToken cancellationToken)
{
// 1. 查询套餐详情
var package = await tenantPackageRepository.GetByIdAsync(request.TenantPackageId, cancellationToken);
// 2. 如果不存在,返回 null
if (package is null)
{
return null;
}
// 3. 映射为 DTO
return new TenantPackageListDto
{
Id = package.Id,
Name = package.Name,
Description = package.Description,
PackageType = package.PackageType,
MonthlyPrice = package.MonthlyPrice,
YearlyPrice = package.YearlyPrice,
MaxStoreCount = package.MaxStoreCount,
MaxAccountCount = package.MaxAccountCount,
MaxStorageGb = package.MaxStorageGb,
MaxSmsCredits = package.MaxSmsCredits,
MaxDeliveryOrders = package.MaxDeliveryOrders,
FeaturePoliciesJson = package.FeaturePoliciesJson,
IsActive = package.IsActive,
IsPublicVisible = package.IsPublicVisible,
IsAllowNewTenantPurchase = package.IsAllowNewTenantPurchase,
PublishStatus = package.PublishStatus,
IsRecommended = package.IsRecommended,
Tags = package.Tags,
SortOrder = package.SortOrder
};
}
}

View File

@@ -0,0 +1,45 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Application.App.TenantPackages.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.TenantPackages.Handlers;
/// <summary>
/// 获取套餐当前使用租户列表查询处理器。
/// </summary>
public sealed class GetTenantPackageTenantsQueryHandler(ITenantPackageRepository tenantPackageRepository)
: IRequestHandler<GetTenantPackageTenantsQuery, PagedResult<TenantPackageTenantDto>>
{
/// <inheritdoc />
public async Task<PagedResult<TenantPackageTenantDto>> Handle(
GetTenantPackageTenantsQuery request,
CancellationToken cancellationToken)
{
// 1. 查询套餐使用租户列表
var (items, totalCount) = await tenantPackageRepository.GetTenantsAsync(
request.TenantPackageId,
request.Keyword,
request.ExpiringWithinDays,
request.Page,
request.PageSize,
cancellationToken);
// 2. 映射为 DTO
var dtos = items.Select(t => new TenantPackageTenantDto
{
TenantId = t.TenantId,
Code = t.Code,
Name = t.Name,
Status = t.Status,
ContactName = t.ContactName,
ContactPhone = t.ContactPhone,
SubscriptionEffectiveFrom = t.SubscriptionEffectiveFrom,
SubscriptionEffectiveTo = t.SubscriptionEffectiveTo
}).ToList();
// 3. 返回分页结果
return new PagedResult<TenantPackageTenantDto>(dtos, totalCount, request.Page, request.PageSize);
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Application.App.TenantPackages.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.TenantPackages.Handlers;
/// <summary>
/// 获取租户套餐使用统计查询处理器。
/// </summary>
public sealed class GetTenantPackageUsagesQueryHandler(ITenantPackageRepository tenantPackageRepository)
: IRequestHandler<GetTenantPackageUsagesQuery, IReadOnlyList<TenantPackageUsageDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPackageUsageDto>> Handle(
GetTenantPackageUsagesQuery request,
CancellationToken cancellationToken)
{
// 1. 查询套餐使用统计
var usages = await tenantPackageRepository.GetUsagesAsync(request.TenantPackageIds, cancellationToken);
// 2. 映射为 DTO
return usages.Select(u => new TenantPackageUsageDto
{
TenantPackageId = u.TenantPackageId,
ActiveSubscriptionCount = u.ActiveSubscriptionCount,
ActiveTenantCount = u.ActiveTenantCount,
TotalSubscriptionCount = u.TotalSubscriptionCount,
Mrr = u.Mrr,
Arr = u.Arr,
ExpiringTenantCount7Days = u.ExpiringTenantCount7Days,
ExpiringTenantCount15Days = u.ExpiringTenantCount15Days,
ExpiringTenantCount30Days = u.ExpiringTenantCount30Days
}).ToList();
}
}

View File

@@ -0,0 +1,55 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Application.App.TenantPackages.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.TenantPackages.Handlers;
/// <summary>
/// 获取租户套餐列表查询处理器。
/// </summary>
public sealed class ListTenantPackagesQueryHandler(ITenantPackageRepository tenantPackageRepository)
: IRequestHandler<ListTenantPackagesQuery, PagedResult<TenantPackageListDto>>
{
/// <inheritdoc />
public async Task<PagedResult<TenantPackageListDto>> Handle(
ListTenantPackagesQuery request,
CancellationToken cancellationToken)
{
// 1. 查询租户套餐(分页)
var (packages, totalCount) = await tenantPackageRepository.GetListAsync(
request.Keyword,
request.IsActive,
request.Page,
request.PageSize,
cancellationToken);
// 2. 映射为 DTO
var items = packages.Select(p => new TenantPackageListDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
PackageType = p.PackageType,
MonthlyPrice = p.MonthlyPrice,
YearlyPrice = p.YearlyPrice,
MaxStoreCount = p.MaxStoreCount,
MaxAccountCount = p.MaxAccountCount,
MaxStorageGb = p.MaxStorageGb,
MaxSmsCredits = p.MaxSmsCredits,
MaxDeliveryOrders = p.MaxDeliveryOrders,
FeaturePoliciesJson = p.FeaturePoliciesJson,
IsActive = p.IsActive,
IsPublicVisible = p.IsPublicVisible,
IsAllowNewTenantPurchase = p.IsAllowNewTenantPurchase,
PublishStatus = p.PublishStatus,
IsRecommended = p.IsRecommended,
Tags = p.Tags,
SortOrder = p.SortOrder
}).ToList();
// 3. 返回分页结果
return new PagedResult<TenantPackageListDto>(items, totalCount, request.Page, request.PageSize);
}
}

View File

@@ -0,0 +1,75 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Commands;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.TenantPackages.Handlers;
/// <summary>
/// 更新租户套餐命令处理器。
/// </summary>
public sealed class UpdateTenantPackageCommandHandler(ITenantPackageRepository tenantPackageRepository)
: IRequestHandler<UpdateTenantPackageCommand, TenantPackageListDto?>
{
/// <inheritdoc />
public async Task<TenantPackageListDto?> Handle(
UpdateTenantPackageCommand request,
CancellationToken cancellationToken)
{
// 1. 查询套餐(带跟踪用于更新)
var package = await tenantPackageRepository.GetByIdForUpdateAsync(request.TenantPackageId, cancellationToken);
// 2. 如果不存在,返回 null
if (package is null)
{
return null;
}
// 3. 更新套餐属性
package.Name = request.Name.Trim();
package.Description = request.Description;
package.PackageType = request.PackageType;
package.MonthlyPrice = request.MonthlyPrice;
package.YearlyPrice = request.YearlyPrice;
package.MaxStoreCount = request.MaxStoreCount;
package.MaxAccountCount = request.MaxAccountCount;
package.MaxStorageGb = request.MaxStorageGb;
package.MaxSmsCredits = request.MaxSmsCredits;
package.MaxDeliveryOrders = request.MaxDeliveryOrders;
package.FeaturePoliciesJson = request.FeaturePoliciesJson;
package.IsActive = request.IsActive;
package.IsPublicVisible = request.IsPublicVisible;
package.IsAllowNewTenantPurchase = request.IsAllowNewTenantPurchase;
package.PublishStatus = request.PublishStatus;
package.IsRecommended = request.IsRecommended;
package.Tags = request.Tags;
package.SortOrder = request.SortOrder;
// 4. 保存变更
await tenantPackageRepository.SaveChangesAsync(cancellationToken);
// 5. 返回 DTO
return new TenantPackageListDto
{
Id = package.Id,
Name = package.Name,
Description = package.Description,
PackageType = package.PackageType,
MonthlyPrice = package.MonthlyPrice,
YearlyPrice = package.YearlyPrice,
MaxStoreCount = package.MaxStoreCount,
MaxAccountCount = package.MaxAccountCount,
MaxStorageGb = package.MaxStorageGb,
MaxSmsCredits = package.MaxSmsCredits,
MaxDeliveryOrders = package.MaxDeliveryOrders,
FeaturePoliciesJson = package.FeaturePoliciesJson,
IsActive = package.IsActive,
IsPublicVisible = package.IsPublicVisible,
IsAllowNewTenantPurchase = package.IsAllowNewTenantPurchase,
PublishStatus = package.PublishStatus,
IsRecommended = package.IsRecommended,
Tags = package.Tags,
SortOrder = package.SortOrder
};
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
namespace TakeoutSaaS.Application.App.TenantPackages.Queries;
/// <summary>
/// 获取租户套餐详情查询。
/// </summary>
public sealed record GetTenantPackageDetailQuery : IRequest<TenantPackageListDto?>
{
/// <summary>
/// 套餐 ID。
/// </summary>
public long TenantPackageId { get; init; }
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.TenantPackages.Queries;
/// <summary>
/// 获取套餐当前使用租户列表查询。
/// </summary>
public sealed record GetTenantPackageTenantsQuery : IRequest<PagedResult<TenantPackageTenantDto>>
{
/// <summary>
/// 套餐 ID。
/// </summary>
public long TenantPackageId { get; init; }
/// <summary>
/// 关键字(租户名称或编码)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 即将到期天数筛选N 天内到期)。
/// </summary>
public int? ExpiringWithinDays { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
namespace TakeoutSaaS.Application.App.TenantPackages.Queries;
/// <summary>
/// 获取租户套餐使用统计查询。
/// </summary>
public sealed record GetTenantPackageUsagesQuery : IRequest<IReadOnlyList<TenantPackageUsageDto>>
{
/// <summary>
/// 套餐 ID 列表。
/// </summary>
public IReadOnlyList<long> TenantPackageIds { get; init; } = [];
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.TenantPackages.Contracts;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.TenantPackages.Queries;
/// <summary>
/// 获取租户套餐列表查询。
/// </summary>
public sealed record ListTenantPackagesQuery : IRequest<PagedResult<TenantPackageListDto>>
{
/// <summary>
/// 关键字(套餐名称)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 是否启用。
/// </summary>
public bool? IsActive { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,98 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Contracts;
/// <summary>
/// 租户账单列表项 DTO。
/// </summary>
public sealed record TenantBillingListDto
{
/// <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>
/// 账单类型。
/// </summary>
public TenantBillingType BillingType { get; init; }
/// <summary>
/// 账单周期开始时间。
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 账单周期结束时间。
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 货币代码。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日期。
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,365 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Contracts;
/// <summary>
/// 租户详情 DTO包含认证、订阅、套餐信息
/// </summary>
public sealed record TenantDetailDto
{
/// <summary>
/// 租户基本信息。
/// </summary>
public required TenantDto Tenant { get; init; }
/// <summary>
/// 认证信息。
/// </summary>
public TenantVerificationDto? Verification { get; init; }
/// <summary>
/// 订阅信息。
/// </summary>
public TenantSubscriptionDto? Subscription { get; init; }
/// <summary>
/// 套餐信息。
/// </summary>
public TenantPackageDto? Package { get; init; }
}
/// <summary>
/// 租户基本信息 DTO。
/// </summary>
public sealed record TenantDto
{
/// <summary>
/// 租户 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 租户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 租户简称。
/// </summary>
public string? ShortName { get; init; }
/// <summary>
/// 所属行业。
/// </summary>
public string? Industry { get; init; }
/// <summary>
/// 联系人姓名。
/// </summary>
public string? ContactName { get; init; }
/// <summary>
/// 联系人电话。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 联系人邮箱。
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 租户状态。
/// </summary>
public TenantStatus Status { get; init; }
/// <summary>
/// 认证状态。
/// </summary>
public TenantVerificationStatus VerificationStatus { get; init; }
/// <summary>
/// 经营模式。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
/// <summary>
/// 当前套餐 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? CurrentPackageId { get; init; }
/// <summary>
/// 服务生效时间。
/// </summary>
public DateTime? EffectiveFrom { get; init; }
/// <summary>
/// 服务到期时间。
/// </summary>
public DateTime? EffectiveTo { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
}
/// <summary>
/// 租户认证信息 DTO。
/// </summary>
public sealed record TenantVerificationDto
{
/// <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 TenantVerificationStatus Status { get; init; }
/// <summary>
/// 营业执照号。
/// </summary>
public string? BusinessLicenseNumber { get; init; }
/// <summary>
/// 营业执照图片 URL。
/// </summary>
public string? BusinessLicenseUrl { get; init; }
/// <summary>
/// 法人姓名。
/// </summary>
public string? LegalPersonName { get; init; }
/// <summary>
/// 法人身份证号。
/// </summary>
public string? LegalPersonIdNumber { get; init; }
/// <summary>
/// 法人身份证正面 URL。
/// </summary>
public string? LegalPersonIdFrontUrl { get; init; }
/// <summary>
/// 法人身份证背面 URL。
/// </summary>
public string? LegalPersonIdBackUrl { get; init; }
/// <summary>
/// 银行账户名。
/// </summary>
public string? BankAccountName { get; init; }
/// <summary>
/// 银行账号。
/// </summary>
public string? BankAccountNumber { get; init; }
/// <summary>
/// 开户银行。
/// </summary>
public string? BankName { get; init; }
/// <summary>
/// 附加数据 JSON。
/// </summary>
public string? AdditionalDataJson { get; init; }
/// <summary>
/// 提交时间。
/// </summary>
public DateTime? SubmittedAt { get; init; }
/// <summary>
/// 审核人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? ReviewedBy { get; init; }
/// <summary>
/// 审核备注。
/// </summary>
public string? ReviewRemarks { get; init; }
/// <summary>
/// 审核人姓名。
/// </summary>
public string? ReviewedByName { get; init; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? ReviewedAt { get; init; }
}
/// <summary>
/// 租户订阅信息 DTO。
/// </summary>
public sealed record TenantSubscriptionDto
{
/// <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 TenantPackageId { get; init; }
/// <summary>
/// 订阅状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间。
/// </summary>
public DateTime EffectiveTo { get; init; }
/// <summary>
/// 下次计费日期。
/// </summary>
public DateTime? NextBillingDate { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
}
/// <summary>
/// 租户套餐信息 DTO。
/// </summary>
public sealed record TenantPackageDto
{
/// <summary>
/// 套餐 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 套餐名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 套餐描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 套餐类型。
/// </summary>
public TenantPackageType PackageType { get; init; }
/// <summary>
/// 月付价格。
/// </summary>
public decimal? MonthlyPrice { get; init; }
/// <summary>
/// 年付价格。
/// </summary>
public decimal? YearlyPrice { get; init; }
/// <summary>
/// 最大门店数。
/// </summary>
public int? MaxStoreCount { get; init; }
/// <summary>
/// 最大账号数。
/// </summary>
public int? MaxAccountCount { get; init; }
/// <summary>
/// 最大存储空间GB
/// </summary>
public int? MaxStorageGb { get; init; }
/// <summary>
/// 最大短信额度。
/// </summary>
public int? MaxSmsCredits { get; init; }
/// <summary>
/// 最大配送订单数。
/// </summary>
public int? MaxDeliveryOrders { get; init; }
/// <summary>
/// 功能策略 JSON。
/// </summary>
public string? FeaturePoliciesJson { get; init; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsActive { get; init; }
/// <summary>
/// 是否公开可见。
/// </summary>
public bool IsPublicVisible { get; init; }
/// <summary>
/// 是否允许新租户购买。
/// </summary>
public bool IsAllowNewTenantPurchase { get; init; }
/// <summary>
/// 发布状态。
/// </summary>
public TenantPackagePublishStatus PublishStatus { get; init; }
/// <summary>
/// 是否推荐。
/// </summary>
public bool IsRecommended { get; init; }
/// <summary>
/// 标签列表。
/// </summary>
public string[] Tags { get; init; } = [];
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; init; }
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Contracts;
/// <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>
/// 重置周期(如 monthly、yearly
/// </summary>
public string? ResetCycle { get; init; }
/// <summary>
/// 上次重置时间。
/// </summary>
public DateTime? LastResetAt { get; init; }
}

View File

@@ -0,0 +1,56 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 获取租户账单列表查询处理器。
/// </summary>
public sealed class GetTenantBillingsQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantBillingsQuery, PagedResult<TenantBillingListDto>>
{
/// <inheritdoc />
public async Task<PagedResult<TenantBillingListDto>> Handle(
GetTenantBillingsQuery request,
CancellationToken cancellationToken)
{
// 1. 查询租户账单(分页)
var (billings, totalCount) = await tenantRepository.GetBillingsAsync(
request.TenantId,
request.Page,
request.PageSize,
cancellationToken);
// 2. 获取租户名称
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
var tenantName = tenant?.Name;
// 3. 映射为 DTO
var items = billings.Select(b => new TenantBillingListDto
{
Id = b.Id,
TenantId = b.TenantId,
TenantName = tenantName,
StatementNo = b.StatementNo,
BillingType = b.BillingType,
PeriodStart = b.PeriodStart,
PeriodEnd = b.PeriodEnd,
AmountDue = b.AmountDue,
AmountPaid = b.AmountPaid,
DiscountAmount = b.DiscountAmount,
TaxAmount = b.TaxAmount,
Currency = b.Currency,
Status = b.Status,
DueDate = b.DueDate,
OverdueNotifiedAt = b.OverdueNotifiedAt,
CreatedAt = b.CreatedAt,
UpdatedAt = b.UpdatedAt
}).ToList();
// 4. 返回分页结果
return new PagedResult<TenantBillingListDto>(items, totalCount, request.Page, request.PageSize);
}
}

View File

@@ -0,0 +1,128 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 获取租户详情查询处理器。
/// </summary>
public sealed class GetTenantDetailQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantDetailQuery, TenantDetailDto?>
{
/// <inheritdoc />
public async Task<TenantDetailDto?> Handle(GetTenantDetailQuery request, CancellationToken cancellationToken)
{
// 1. 查询租户详情
var result = await tenantRepository.GetDetailAsync(request.TenantId, cancellationToken);
if (result is null)
{
return null;
}
var (tenant, verification, subscription, package) = result;
// 2. 映射租户 DTO
var tenantDto = new Contracts.TenantDto
{
Id = tenant.Id,
Code = tenant.Code,
Name = tenant.Name,
ShortName = tenant.ShortName,
Industry = tenant.Industry,
ContactName = tenant.ContactName,
ContactPhone = tenant.ContactPhone,
ContactEmail = tenant.ContactEmail,
Status = tenant.Status,
VerificationStatus = verification?.Status ?? TenantVerificationStatus.Draft,
OperatingMode = tenant.OperatingMode,
CurrentPackageId = subscription?.TenantPackageId,
EffectiveFrom = tenant.EffectiveFrom,
EffectiveTo = tenant.EffectiveTo,
AutoRenew = subscription?.AutoRenew ?? false
};
// 3. 映射认证 DTO
Contracts.TenantVerificationDto? verificationDto = null;
if (verification is not null)
{
verificationDto = new Contracts.TenantVerificationDto
{
Id = verification.Id,
TenantId = verification.TenantId,
Status = verification.Status,
BusinessLicenseNumber = verification.BusinessLicenseNumber,
BusinessLicenseUrl = verification.BusinessLicenseUrl,
LegalPersonName = verification.LegalPersonName,
LegalPersonIdNumber = verification.LegalPersonIdNumber,
LegalPersonIdFrontUrl = verification.LegalPersonIdFrontUrl,
LegalPersonIdBackUrl = verification.LegalPersonIdBackUrl,
BankAccountName = verification.BankAccountName,
BankAccountNumber = verification.BankAccountNumber,
BankName = verification.BankName,
AdditionalDataJson = verification.AdditionalDataJson,
SubmittedAt = verification.SubmittedAt,
ReviewedBy = verification.ReviewedBy,
ReviewRemarks = verification.ReviewRemarks,
ReviewedByName = verification.ReviewedByName,
ReviewedAt = verification.ReviewedAt
};
}
// 4. 映射订阅 DTO
Contracts.TenantSubscriptionDto? subscriptionDto = null;
if (subscription is not null)
{
subscriptionDto = new Contracts.TenantSubscriptionDto
{
Id = subscription.Id,
TenantId = subscription.TenantId,
TenantPackageId = subscription.TenantPackageId,
Status = subscription.Status,
EffectiveFrom = subscription.EffectiveFrom,
EffectiveTo = subscription.EffectiveTo,
NextBillingDate = subscription.NextBillingDate,
AutoRenew = subscription.AutoRenew
};
}
// 5. 映射套餐 DTO
Contracts.TenantPackageDto? packageDto = null;
if (package is not null)
{
packageDto = new Contracts.TenantPackageDto
{
Id = package.Id,
Name = package.Name,
Description = package.Description,
PackageType = package.PackageType,
MonthlyPrice = package.MonthlyPrice,
YearlyPrice = package.YearlyPrice,
MaxStoreCount = package.MaxStoreCount,
MaxAccountCount = package.MaxAccountCount,
MaxStorageGb = package.MaxStorageGb,
MaxSmsCredits = package.MaxSmsCredits,
MaxDeliveryOrders = package.MaxDeliveryOrders,
FeaturePoliciesJson = package.FeaturePoliciesJson,
IsActive = package.IsActive,
IsPublicVisible = package.IsPublicVisible,
IsAllowNewTenantPurchase = package.IsAllowNewTenantPurchase,
PublishStatus = package.PublishStatus,
IsRecommended = package.IsRecommended,
Tags = package.Tags,
SortOrder = package.SortOrder
};
}
// 6. 组装返回结果
return new TenantDetailDto
{
Tenant = tenantDto,
Verification = verificationDto,
Subscription = subscriptionDto,
Package = packageDto
};
}
}

View File

@@ -0,0 +1,34 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 获取租户配额使用情况查询处理器。
/// </summary>
public sealed class GetTenantQuotaUsageQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantQuotaUsageQuery, IReadOnlyList<TenantQuotaUsageDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<TenantQuotaUsageDto>> Handle(
GetTenantQuotaUsageQuery request,
CancellationToken cancellationToken)
{
// 1. 查询租户配额使用情况
var usages = await tenantRepository.GetQuotaUsagesAsync(request.TenantId, cancellationToken);
// 2. 映射为 DTO
return usages.Select(u => new TenantQuotaUsageDto
{
TenantId = u.TenantId,
QuotaType = u.QuotaType,
LimitValue = u.LimitValue,
UsedValue = u.UsedValue,
RemainingValue = Math.Max(0, u.LimitValue - u.UsedValue),
ResetCycle = u.ResetCycle,
LastResetAt = u.LastResetAt
}).ToList();
}
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Queries;
/// <summary>
/// 获取租户账单列表查询。
/// </summary>
public sealed record GetTenantBillingsQuery : IRequest<PagedResult<TenantBillingListDto>>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public required long TenantId { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
namespace TakeoutSaaS.Application.App.Tenants.Queries;
/// <summary>
/// 获取租户详情查询。
/// </summary>
public sealed record GetTenantDetailQuery : IRequest<TenantDetailDto?>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public long TenantId { get; init; }
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Contracts;
namespace TakeoutSaaS.Application.App.Tenants.Queries;
/// <summary>
/// 获取租户配额使用情况查询。
/// </summary>
public sealed record GetTenantQuotaUsageQuery : IRequest<IReadOnlyList<TenantQuotaUsageDto>>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public required long TenantId { get; init; }
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 克隆平台角色命令。
/// </summary>
public sealed record CloneAdminRoleCommand : IRequest<RoleDto>
{
/// <summary>
/// 源角色 ID由路由绑定
/// </summary>
public long SourceRoleId { get; init; }
/// <summary>
/// 新角色编码(平台范围内唯一)。
/// </summary>
public string NewCode { get; init; } = string.Empty;
/// <summary>
/// 新角色名称。
/// </summary>
public string NewName { get; init; } = string.Empty;
/// <summary>
/// 新角色描述(为空则沿用源角色)。
/// </summary>
public string? NewDescription { get; init; }
/// <summary>
/// 是否复制权限(默认 true
/// </summary>
public bool CopyPermissions { get; init; } = true;
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 创建平台角色命令。
/// </summary>
public sealed record CreateAdminRoleCommand : IRequest<RoleDto>
{
/// <summary>
/// 角色编码(平台范围内唯一)。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 角色名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 角色描述。
/// </summary>
public string? Description { get; init; }
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 删除平台角色命令。
/// </summary>
public sealed record DeleteAdminRoleCommand : IRequest<bool>
{
/// <summary>
/// 角色 ID由路由绑定
/// </summary>
public long RoleId { get; init; }
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 更新平台角色命令。
/// </summary>
public sealed record UpdateAdminRoleCommand : IRequest<RoleDto?>
{
/// <summary>
/// 角色 ID由路由绑定
/// </summary>
public long RoleId { get; init; }
/// <summary>
/// 角色名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 角色描述。
/// </summary>
public string? Description { get; init; }
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 更新平台角色权限命令。
/// </summary>
public sealed record UpdateAdminRolePermissionsCommand : IRequest<bool>
{
/// <summary>
/// 角色 ID由路由绑定
/// </summary>
public long RoleId { get; init; }
/// <summary>
/// 权限 ID 集合(替换模式)。
/// </summary>
public IReadOnlyCollection<long> PermissionIds { get; init; } = [];
}

View File

@@ -0,0 +1,86 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 克隆平台角色命令处理器。
/// </summary>
public sealed class CloneAdminRoleCommandHandler(
IRoleRepository roleRepository,
IRolePermissionRepository rolePermissionRepository)
: IRequestHandler<CloneAdminRoleCommand, RoleDto>
{
/// <inheritdoc />
public async Task<RoleDto> Handle(CloneAdminRoleCommand request, CancellationToken cancellationToken)
{
// 1. 校验新角色编码与名称不能为空
if (string.IsNullOrWhiteSpace(request.NewCode) || string.IsNullOrWhiteSpace(request.NewName))
{
throw new BusinessException(ErrorCodes.BadRequest, "新角色编码与名称不能为空");
}
// 2. 校验源角色存在
var source = await roleRepository.FindByIdAsync(PortalType.Admin, null, request.SourceRoleId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "源角色不存在");
// 3. 校验新角色编码唯一性(平台范围内)
var existing = await roleRepository.FindByCodeAsync(PortalType.Admin, null, request.NewCode, cancellationToken);
if (existing is not null)
{
throw new BusinessException(ErrorCodes.Conflict, $"角色编码 {request.NewCode} 已存在");
}
// 4. 构造新角色实体
var newRole = new Role
{
Portal = PortalType.Admin,
TenantId = null,
Code = request.NewCode.Trim(),
Name = request.NewName.Trim(),
Description = request.NewDescription ?? source.Description
};
// 5. 持久化新角色
await roleRepository.AddAsync(newRole, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
// 6. 复制权限关系
if (request.CopyPermissions)
{
var sourcePermissions = await rolePermissionRepository.GetByRoleIdsAsync(
PortalType.Admin,
null,
[request.SourceRoleId],
cancellationToken);
var permissionIds = sourcePermissions.Select(p => p.PermissionId).Distinct().ToArray();
if (permissionIds.Length > 0)
{
await rolePermissionRepository.ReplaceRolePermissionsAsync(
PortalType.Admin,
null,
newRole.Id,
permissionIds,
cancellationToken);
}
}
// 7. 返回 DTO
return new RoleDto
{
Portal = newRole.Portal,
Id = newRole.Id,
TenantId = newRole.TenantId,
Code = newRole.Code,
Name = newRole.Name,
Description = newRole.Description
};
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 创建平台角色命令处理器。
/// </summary>
public sealed class CreateAdminRoleCommandHandler(IRoleRepository roleRepository)
: IRequestHandler<CreateAdminRoleCommand, RoleDto>
{
/// <inheritdoc />
public async Task<RoleDto> Handle(CreateAdminRoleCommand request, CancellationToken cancellationToken)
{
// 1. 校验必填字段
if (string.IsNullOrWhiteSpace(request.Code) || string.IsNullOrWhiteSpace(request.Name))
{
throw new BusinessException(ErrorCodes.BadRequest, "角色编码与名称不能为空");
}
// 2. 检查编码唯一性(平台范围内)
var existing = await roleRepository.FindByCodeAsync(PortalType.Admin, null, request.Code, cancellationToken);
if (existing is not null)
{
throw new BusinessException(ErrorCodes.Conflict, $"角色编码 {request.Code} 已存在");
}
// 3. 构建角色实体
var role = new Role
{
Portal = PortalType.Admin,
TenantId = null,
Code = request.Code.Trim(),
Name = request.Name.Trim(),
Description = request.Description
};
// 4. 持久化
await roleRepository.AddAsync(role, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
// 5. 返回 DTO
return new RoleDto
{
Portal = role.Portal,
Id = role.Id,
TenantId = role.TenantId,
Code = role.Code,
Name = role.Name,
Description = role.Description
};
}
}

View File

@@ -0,0 +1,33 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 删除平台角色命令处理器。
/// </summary>
public sealed class DeleteAdminRoleCommandHandler(IRoleRepository roleRepository)
: IRequestHandler<DeleteAdminRoleCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(DeleteAdminRoleCommand request, CancellationToken cancellationToken)
{
// 1. 校验角色存在
var role = await roleRepository.FindByIdAsync(PortalType.Admin, null, request.RoleId, cancellationToken);
if (role is null)
{
throw new BusinessException(ErrorCodes.NotFound, "角色不存在");
}
// 2. 执行软删除
await roleRepository.DeleteAsync(PortalType.Admin, null, request.RoleId, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
// 3. 返回成功
return true;
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 获取平台角色权限列表查询处理器。
/// </summary>
public sealed class GetAdminRolePermissionsQueryHandler(
IRoleRepository roleRepository,
IRolePermissionRepository rolePermissionRepository,
IPermissionRepository permissionRepository)
: IRequestHandler<GetAdminRolePermissionsQuery, IReadOnlyList<PermissionDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<PermissionDto>> Handle(GetAdminRolePermissionsQuery request, CancellationToken cancellationToken)
{
// 1. 校验角色存在
var role = await roleRepository.FindByIdAsync(PortalType.Admin, null, request.RoleId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "角色不存在");
// 2. 查询角色权限关系
var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(
PortalType.Admin,
null,
[request.RoleId],
cancellationToken);
// 3. 提取权限 ID 集合
var permissionIds = rolePermissions.Select(rp => rp.PermissionId).Distinct().ToArray();
if (permissionIds.Length == 0)
{
return [];
}
// 4. 查询权限详情
var permissions = await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
// 5. 映射 DTO
return permissions
.Select(p => new PermissionDto
{
Portal = p.Portal,
Id = p.Id,
ParentId = p.ParentId,
SortOrder = p.SortOrder,
Type = p.Type,
Name = p.Name,
Code = p.Code,
Description = p.Description
})
.ToArray();
}
}

View File

@@ -0,0 +1,48 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 获取平台角色列表查询处理器。
/// </summary>
public sealed class ListAdminRolesQueryHandler(IRoleRepository roleRepository)
: IRequestHandler<ListAdminRolesQuery, PagedResult<RoleDto>>
{
/// <inheritdoc />
public async Task<PagedResult<RoleDto>> Handle(ListAdminRolesQuery request, CancellationToken cancellationToken)
{
// 1. 查询平台下所有角色
var roles = await roleRepository.SearchAsync(PortalType.Admin, null, request.Keyword, cancellationToken);
// 2. 计算分页参数
var totalCount = roles.Count;
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 100);
// 3. 应用分页
var pagedRoles = roles
.OrderBy(r => r.Code)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToArray();
// 4. 映射 DTO
var dtos = pagedRoles.Select(role => new RoleDto
{
Portal = role.Portal,
Id = role.Id,
TenantId = role.TenantId,
Code = role.Code,
Name = role.Name,
Description = role.Description
}).ToArray();
// 5. 返回分页结果
return new PagedResult<RoleDto>(dtos, page, pageSize, totalCount);
}
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 更新平台角色命令处理器。
/// </summary>
public sealed class UpdateAdminRoleCommandHandler(IRoleRepository roleRepository)
: IRequestHandler<UpdateAdminRoleCommand, RoleDto?>
{
/// <inheritdoc />
public async Task<RoleDto?> Handle(UpdateAdminRoleCommand request, CancellationToken cancellationToken)
{
// 1. 校验必填字段
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new BusinessException(ErrorCodes.BadRequest, "角色名称不能为空");
}
// 2. 查询目标角色
var role = await roleRepository.FindByIdAsync(PortalType.Admin, null, request.RoleId, cancellationToken);
if (role is null)
{
return null;
}
// 3. 更新字段
role.Name = request.Name.Trim();
role.Description = request.Description;
// 4. 持久化
await roleRepository.UpdateAsync(role, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
// 5. 返回 DTO
return new RoleDto
{
Portal = role.Portal,
Id = role.Id,
TenantId = role.TenantId,
Code = role.Code,
Name = role.Name,
Description = role.Description
};
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 更新平台角色权限命令处理器。
/// </summary>
public sealed class UpdateAdminRolePermissionsCommandHandler(
IRoleRepository roleRepository,
IRolePermissionRepository rolePermissionRepository)
: IRequestHandler<UpdateAdminRolePermissionsCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(UpdateAdminRolePermissionsCommand request, CancellationToken cancellationToken)
{
// 1. 校验角色存在
var role = await roleRepository.FindByIdAsync(PortalType.Admin, null, request.RoleId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "角色不存在");
// 2. 替换角色权限(事务保证)
await rolePermissionRepository.ReplaceRolePermissionsAsync(
PortalType.Admin,
null,
request.RoleId,
request.PermissionIds,
cancellationToken);
// 3. 返回成功
return true;
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 获取平台角色权限列表查询。
/// </summary>
public sealed record GetAdminRolePermissionsQuery : IRequest<IReadOnlyList<PermissionDto>>
{
/// <summary>
/// 角色 ID。
/// </summary>
public long RoleId { get; init; }
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 获取平台角色列表查询。
/// </summary>
public sealed record ListAdminRolesQuery : IRequest<PagedResult<RoleDto>>
{
/// <summary>
/// 关键字(角色名称/编码)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,95 @@
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Billings.Entities;
/// <summary>
/// 租户账单。
/// </summary>
public sealed class TenantBillingStatement : AuditableEntityBase
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; set; } = string.Empty;
/// <summary>
/// 账单类型。
/// </summary>
public TenantBillingType BillingType { get; set; }
/// <summary>
/// 账单周期开始时间。
/// </summary>
public DateTime PeriodStart { get; set; }
/// <summary>
/// 账单周期结束时间。
/// </summary>
public DateTime PeriodEnd { get; set; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; set; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; set; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; set; }
/// <summary>
/// 税额。
/// </summary>
public decimal TaxAmount { get; set; }
/// <summary>
/// 货币代码。
/// </summary>
public string Currency { get; set; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; set; }
/// <summary>
/// 到期日期。
/// </summary>
public DateTime DueDate { get; set; }
/// <summary>
/// 账单明细 JSON。
/// </summary>
public string? LineItemsJson { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; set; }
/// <summary>
/// 提醒发送时间。
/// </summary>
public DateTime? ReminderSentAt { get; set; }
/// <summary>
/// 关联订阅 ID。
/// </summary>
public long? SubscriptionId { get; set; }
}

View File

@@ -0,0 +1,75 @@
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Billings.Entities;
/// <summary>
/// 租户支付记录。
/// </summary>
public sealed class TenantPayment : AuditableEntityBase
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillingStatementId { get; set; }
/// <summary>
/// 支付金额。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 支付方式。
/// </summary>
public TenantPaymentMethod Method { get; set; }
/// <summary>
/// 支付状态。
/// </summary>
public TenantPaymentStatus 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; }
/// <summary>
/// 审核人 ID。
/// </summary>
public long? VerifiedBy { get; set; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? VerifiedAt { get; set; }
/// <summary>
/// 退款原因。
/// </summary>
public string? RefundReason { get; set; }
/// <summary>
/// 退款时间。
/// </summary>
public DateTime? RefundedAt { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Billings.Enums;
/// <summary>
/// 租户账单状态。
/// </summary>
public enum TenantBillingStatus
{
/// <summary>
/// 待支付。
/// </summary>
Pending = 0,
/// <summary>
/// 已支付。
/// </summary>
Paid = 1,
/// <summary>
/// 已逾期。
/// </summary>
Overdue = 2,
/// <summary>
/// 已取消。
/// </summary>
Cancelled = 3
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Billings.Enums;
/// <summary>
/// 租户账单类型。
/// </summary>
public enum TenantBillingType
{
/// <summary>
/// 订阅账单。
/// </summary>
Subscription = 0,
/// <summary>
/// 配额包购买。
/// </summary>
QuotaPurchase = 1,
/// <summary>
/// 手动创建。
/// </summary>
Manual = 2,
/// <summary>
/// 续费账单。
/// </summary>
Renewal = 3
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Billings.Enums;
/// <summary>
/// 租户支付方式。
/// </summary>
public enum TenantPaymentMethod
{
/// <summary>
/// 在线支付。
/// </summary>
Online = 0,
/// <summary>
/// 银行转账。
/// </summary>
BankTransfer = 1,
/// <summary>
/// 其他方式。
/// </summary>
Other = 2
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Billings.Enums;
/// <summary>
/// 租户支付状态。
/// </summary>
public enum TenantPaymentStatus
{
/// <summary>
/// 待处理。
/// </summary>
Pending = 0,
/// <summary>
/// 成功。
/// </summary>
Success = 1,
/// <summary>
/// 失败。
/// </summary>
Failed = 2,
/// <summary>
/// 已退款。
/// </summary>
Refunded = 3
}

View File

@@ -0,0 +1,128 @@
using TakeoutSaaS.Domain.Billings.Entities;
using TakeoutSaaS.Domain.Billings.Enums;
namespace TakeoutSaaS.Domain.Billings.Repositories;
/// <summary>
/// 账单仓储AdminApi 使用)。
/// </summary>
public interface IBillingRepository
{
/// <summary>
/// 获取账单列表(分页)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="status">账单状态。</param>
/// <param name="billingType">账单类型。</param>
/// <param name="startDate">开始日期。</param>
/// <param name="endDate">结束日期。</param>
/// <param name="minAmount">最小金额。</param>
/// <param name="maxAmount">最大金额。</param>
/// <param name="keyword">关键词(账单号、租户名)。</param>
/// <param name="sortBy">排序字段。</param>
/// <param name="sortDesc">是否降序。</param>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单列表和总数。</returns>
Task<(IReadOnlyList<BillingListResult> Items, int TotalCount)> GetListAsync(
long? tenantId,
TenantBillingStatus? status,
TenantBillingType? billingType,
DateTime? startDate,
DateTime? endDate,
decimal? minAmount,
decimal? maxAmount,
string? keyword,
string? sortBy,
bool? sortDesc,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 根据 ID 获取账单。
/// </summary>
/// <param name="billingId">账单 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单实体,不存在则返回 null。</returns>
Task<TenantBillingStatement?> GetByIdAsync(long billingId, CancellationToken cancellationToken = default);
/// <summary>
/// 根据 ID 获取账单(用于更新,带跟踪)。
/// </summary>
/// <param name="billingId">账单 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单实体,不存在则返回 null。</returns>
Task<TenantBillingStatement?> GetByIdForUpdateAsync(long billingId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取账单详情(带租户信息)。
/// </summary>
/// <param name="billingId">账单 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单详情结果,不存在则返回 null。</returns>
Task<BillingDetailResult?> GetDetailAsync(long billingId, CancellationToken cancellationToken = default);
/// <summary>
/// 保存仓储变更。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 添加支付记录。
/// </summary>
/// <param name="payment">支付记录实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task AddPaymentAsync(TenantPayment payment, CancellationToken cancellationToken = default);
}
/// <summary>
/// 账单列表结果。
/// </summary>
public sealed record BillingListResult(
long Id,
long TenantId,
string? TenantName,
string StatementNo,
TenantBillingType BillingType,
DateTime PeriodStart,
DateTime PeriodEnd,
decimal AmountDue,
decimal AmountPaid,
decimal DiscountAmount,
decimal TaxAmount,
string Currency,
TenantBillingStatus Status,
DateTime DueDate,
DateTime? OverdueNotifiedAt,
DateTime CreatedAt,
DateTime? UpdatedAt);
/// <summary>
/// 账单详情结果。
/// </summary>
public sealed record BillingDetailResult(
long Id,
long TenantId,
string? TenantName,
string StatementNo,
TenantBillingType BillingType,
DateTime PeriodStart,
DateTime PeriodEnd,
decimal AmountDue,
decimal AmountPaid,
decimal DiscountAmount,
decimal TaxAmount,
string Currency,
TenantBillingStatus Status,
DateTime DueDate,
string? LineItemsJson,
string? Notes,
DateTime? OverdueNotifiedAt,
DateTime? ReminderSentAt,
DateTime CreatedAt,
DateTime? UpdatedAt);

View File

@@ -0,0 +1,100 @@
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户套餐定义,描述不同等级的服务套餐。
/// </summary>
public sealed class TenantPackage : AuditableEntityBase
{
/// <summary>
/// 套餐名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 套餐描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 套餐类型。
/// </summary>
public TenantPackageType PackageType { get; set; } = TenantPackageType.Free;
/// <summary>
/// 月付价格。
/// </summary>
public decimal? MonthlyPrice { get; set; }
/// <summary>
/// 年付价格。
/// </summary>
public decimal? YearlyPrice { get; set; }
/// <summary>
/// 最大门店数。
/// </summary>
public int? MaxStoreCount { get; set; }
/// <summary>
/// 最大账号数。
/// </summary>
public int? MaxAccountCount { get; set; }
/// <summary>
/// 最大存储空间GB
/// </summary>
public int? MaxStorageGb { get; set; }
/// <summary>
/// 最大短信额度。
/// </summary>
public int? MaxSmsCredits { get; set; }
/// <summary>
/// 最大配送订单数。
/// </summary>
public int? MaxDeliveryOrders { get; set; }
/// <summary>
/// 功能策略 JSON。
/// </summary>
public string? FeaturePoliciesJson { get; set; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// 排序序号。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 是否允许新租户购买。
/// </summary>
public bool IsAllowNewTenantPurchase { get; set; } = true;
/// <summary>
/// 是否公开可见。
/// </summary>
public bool IsPublicVisible { get; set; } = true;
/// <summary>
/// 发布状态。
/// </summary>
public TenantPackagePublishStatus PublishStatus { get; set; } = TenantPackagePublishStatus.Draft;
/// <summary>
/// 是否推荐。
/// </summary>
public bool IsRecommended { get; set; }
/// <summary>
/// 标签列表。
/// </summary>
public string[] Tags { get; set; } = [];
}

View File

@@ -0,0 +1,40 @@
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户配额使用情况。
/// </summary>
public sealed class TenantQuotaUsage : AuditableEntityBase
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; set; }
/// <summary>
/// 配额上限值。
/// </summary>
public decimal LimitValue { get; set; }
/// <summary>
/// 已使用值。
/// </summary>
public decimal UsedValue { get; set; }
/// <summary>
/// 重置周期(如 monthly、yearly
/// </summary>
public string? ResetCycle { get; set; }
/// <summary>
/// 上次重置时间。
/// </summary>
public DateTime? LastResetAt { get; set; }
}

View File

@@ -0,0 +1,60 @@
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户订阅记录,描述租户当前的套餐订阅状态。
/// </summary>
public sealed class TenantSubscription : AuditableEntityBase
{
/// <summary>
/// 关联的租户 ID。
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 订阅的套餐 ID。
/// </summary>
public long TenantPackageId { get; set; }
/// <summary>
/// 订阅状态。
/// </summary>
public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Active;
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; set; }
/// <summary>
/// 到期时间。
/// </summary>
public DateTime EffectiveTo { get; set; }
/// <summary>
/// 下次计费日期。
/// </summary>
public DateTime? NextBillingDate { get; set; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; set; }
/// <summary>
/// 预约变更的套餐 ID下个周期生效
/// </summary>
public long? ScheduledPackageId { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// 关联的套餐(导航属性)。
/// </summary>
public TenantPackage? TenantPackage { get; set; }
}

View File

@@ -0,0 +1,60 @@
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户订阅变更历史记录。
/// </summary>
public sealed class TenantSubscriptionHistory : AuditableEntityBase
{
/// <summary>
/// 关联的租户 ID。
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 关联的订阅 ID。
/// </summary>
public long TenantSubscriptionId { get; set; }
/// <summary>
/// 变更前套餐 ID。
/// </summary>
public long FromPackageId { get; set; }
/// <summary>
/// 变更后套餐 ID。
/// </summary>
public long ToPackageId { get; set; }
/// <summary>
/// 变更类型。
/// </summary>
public SubscriptionChangeType ChangeType { get; set; }
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; set; }
/// <summary>
/// 到期时间。
/// </summary>
public DateTime EffectiveTo { get; set; }
/// <summary>
/// 金额。
/// </summary>
public decimal? Amount { get; set; }
/// <summary>
/// 货币。
/// </summary>
public string? Currency { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; set; }
}

View File

@@ -0,0 +1,95 @@
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户认证资料,用于企业资质审核。
/// </summary>
public sealed class TenantVerificationProfile : AuditableEntityBase
{
/// <summary>
/// 关联的租户 ID。
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 认证状态。
/// </summary>
public TenantVerificationStatus Status { get; set; } = TenantVerificationStatus.Draft;
/// <summary>
/// 营业执照号。
/// </summary>
public string? BusinessLicenseNumber { get; set; }
/// <summary>
/// 营业执照图片 URL。
/// </summary>
public string? BusinessLicenseUrl { get; set; }
/// <summary>
/// 法人姓名。
/// </summary>
public string? LegalPersonName { get; set; }
/// <summary>
/// 法人身份证号。
/// </summary>
public string? LegalPersonIdNumber { get; set; }
/// <summary>
/// 法人身份证正面 URL。
/// </summary>
public string? LegalPersonIdFrontUrl { get; set; }
/// <summary>
/// 法人身份证背面 URL。
/// </summary>
public string? LegalPersonIdBackUrl { get; set; }
/// <summary>
/// 银行账户名。
/// </summary>
public string? BankAccountName { get; set; }
/// <summary>
/// 银行账号。
/// </summary>
public string? BankAccountNumber { get; set; }
/// <summary>
/// 开户银行。
/// </summary>
public string? BankName { get; set; }
/// <summary>
/// 附加数据 JSON。
/// </summary>
public string? AdditionalDataJson { get; set; }
/// <summary>
/// 提交时间。
/// </summary>
public DateTime? SubmittedAt { get; set; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? ReviewedAt { get; set; }
/// <summary>
/// 审核人 ID。
/// </summary>
public long? ReviewedBy { get; set; }
/// <summary>
/// 审核人姓名。
/// </summary>
public string? ReviewedByName { get; set; }
/// <summary>
/// 审核备注。
/// </summary>
public string? ReviewRemarks { get; set; }
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 订阅变更类型枚举。
/// </summary>
public enum SubscriptionChangeType
{
/// <summary>
/// 新建订阅。
/// </summary>
Created = 0,
/// <summary>
/// 续费。
/// </summary>
Renewed = 1,
/// <summary>
/// 升级套餐。
/// </summary>
Upgraded = 2,
/// <summary>
/// 降级套餐。
/// </summary>
Downgraded = 3,
/// <summary>
/// 延期。
/// </summary>
Extended = 4,
/// <summary>
/// 取消。
/// </summary>
Cancelled = 5,
/// <summary>
/// 暂停。
/// </summary>
Suspended = 6,
/// <summary>
/// 恢复。
/// </summary>
Resumed = 7
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 订阅状态枚举。
/// </summary>
public enum SubscriptionStatus
{
/// <summary>
/// 活跃。
/// </summary>
Active = 0,
/// <summary>
/// 已过期。
/// </summary>
Expired = 1,
/// <summary>
/// 已取消。
/// </summary>
Cancelled = 2,
/// <summary>
/// 已暂停。
/// </summary>
Suspended = 3
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 套餐发布状态枚举。
/// </summary>
public enum TenantPackagePublishStatus
{
/// <summary>
/// 草稿。
/// </summary>
Draft = 0,
/// <summary>
/// 已发布。
/// </summary>
Published = 1
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 套餐类型枚举。
/// </summary>
public enum TenantPackageType
{
/// <summary>
/// 免费版。
/// </summary>
Free = 0,
/// <summary>
/// 标准版。
/// </summary>
Standard = 1,
/// <summary>
/// 专业版。
/// </summary>
Professional = 2,
/// <summary>
/// 企业版。
/// </summary>
Enterprise = 3
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 租户配额类型。
/// </summary>
public enum TenantQuotaType
{
/// <summary>
/// 门店数量。
/// </summary>
Store = 0,
/// <summary>
/// 账号数量。
/// </summary>
Account = 1,
/// <summary>
/// 存储空间GB
/// </summary>
StorageGb = 2,
/// <summary>
/// 短信额度。
/// </summary>
SmsCredits = 3,
/// <summary>
/// 配送订单数。
/// </summary>
DeliveryOrders = 4
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 租户认证状态枚举。
/// </summary>
public enum TenantVerificationStatus
{
/// <summary>
/// 草稿。
/// </summary>
Draft = 0,
/// <summary>
/// 待审核。
/// </summary>
Pending = 1,
/// <summary>
/// 已通过。
/// </summary>
Approved = 2,
/// <summary>
/// 已驳回。
/// </summary>
Rejected = 3
}

View File

@@ -0,0 +1,146 @@
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Domain.Tenants.Repositories;
/// <summary>
/// 订阅仓储AdminApi 使用)。
/// </summary>
public interface ISubscriptionRepository
{
/// <summary>
/// 获取订阅列表(分页)。
/// </summary>
/// <param name="status">订阅状态。</param>
/// <param name="tenantPackageId">套餐 ID。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantKeyword">租户关键词(名称或编码)。</param>
/// <param name="expiringWithinDays">即将到期天数筛选。</param>
/// <param name="autoRenew">是否自动续费。</param>
/// <param name="expireFrom">到期时间范围开始。</param>
/// <param name="expireTo">到期时间范围结束。</param>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>订阅列表和总数。</returns>
Task<(IReadOnlyList<SubscriptionListResult> Items, int TotalCount)> GetListAsync(
SubscriptionStatus? status,
long? tenantPackageId,
long? tenantId,
string? tenantKeyword,
int? expiringWithinDays,
bool? autoRenew,
DateTime? expireFrom,
DateTime? expireTo,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 根据 ID 获取订阅。
/// </summary>
/// <param name="subscriptionId">订阅 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>订阅实体,不存在则返回 null。</returns>
Task<TenantSubscription?> GetByIdAsync(long subscriptionId, CancellationToken cancellationToken = default);
/// <summary>
/// 根据 ID 获取订阅(用于更新,带跟踪)。
/// </summary>
/// <param name="subscriptionId">订阅 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>订阅实体,不存在则返回 null。</returns>
Task<TenantSubscription?> GetByIdForUpdateAsync(long subscriptionId, CancellationToken cancellationToken = default);
/// <summary>
/// 保存仓储变更。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 获取订阅详情。
/// </summary>
/// <param name="subscriptionId">订阅 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>订阅详情结果,不存在则返回 null。</returns>
Task<SubscriptionDetailResult?> GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取订阅变更历史。
/// </summary>
/// <param name="subscriptionId">订阅 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>变更历史列表。</returns>
Task<IReadOnlyList<SubscriptionHistoryResult>> GetHistoriesAsync(long subscriptionId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取订阅列表结果(单条,用于更新后返回)。
/// </summary>
/// <param name="subscriptionId">订阅 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>订阅列表结果,不存在则返回 null。</returns>
Task<SubscriptionListResult?> GetListResultByIdAsync(long subscriptionId, CancellationToken cancellationToken = default);
}
/// <summary>
/// 订阅列表结果。
/// </summary>
public sealed record SubscriptionListResult(
long Id,
long TenantId,
string TenantName,
string TenantCode,
long TenantPackageId,
string PackageName,
long? ScheduledPackageId,
string? ScheduledPackageName,
SubscriptionStatus Status,
DateTime EffectiveFrom,
DateTime EffectiveTo,
DateTime? NextBillingDate,
bool AutoRenew,
string? Notes,
DateTime CreatedAt,
DateTime? UpdatedAt);
/// <summary>
/// 订阅详情结果。
/// </summary>
public sealed record SubscriptionDetailResult(
long Id,
long TenantId,
string TenantName,
string TenantCode,
long TenantPackageId,
string PackageName,
long? ScheduledPackageId,
string? ScheduledPackageName,
SubscriptionStatus Status,
DateTime EffectiveFrom,
DateTime EffectiveTo,
DateTime? NextBillingDate,
bool AutoRenew,
string? Notes,
DateTime CreatedAt,
DateTime? UpdatedAt,
TenantPackage Package,
TenantPackage? ScheduledPackage);
/// <summary>
/// 订阅变更历史结果。
/// </summary>
public sealed record SubscriptionHistoryResult(
long Id,
long SubscriptionId,
SubscriptionChangeType ChangeType,
long FromPackageId,
string FromPackageName,
long ToPackageId,
string ToPackageName,
DateTime EffectiveFrom,
DateTime EffectiveTo,
string? Notes,
DateTime CreatedAt,
long? CreatedBy);

View File

@@ -0,0 +1,120 @@
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Domain.Tenants.Repositories;
/// <summary>
/// 租户套餐仓储AdminApi 使用)。
/// </summary>
public interface ITenantPackageRepository
{
/// <summary>
/// 根据 ID 获取租户套餐。
/// </summary>
/// <param name="tenantPackageId">套餐 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>套餐实体,不存在则返回 null。</returns>
Task<TenantPackage?> GetByIdAsync(long tenantPackageId, CancellationToken cancellationToken = default);
/// <summary>
/// 根据 ID 获取租户套餐(用于更新,带跟踪)。
/// </summary>
/// <param name="tenantPackageId">套餐 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>套餐实体,不存在则返回 null。</returns>
Task<TenantPackage?> GetByIdForUpdateAsync(long tenantPackageId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取租户套餐列表(分页)。
/// </summary>
/// <param name="keyword">关键字(套餐名称)。</param>
/// <param name="isActive">是否启用。</param>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>套餐列表和总数。</returns>
Task<(IReadOnlyList<TenantPackage> Items, int TotalCount)> GetListAsync(
string? keyword,
bool? isActive,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取套餐使用统计。
/// </summary>
/// <param name="tenantPackageIds">套餐 ID 列表。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>套餐使用统计列表。</returns>
Task<IReadOnlyList<TenantPackageUsageResult>> GetUsagesAsync(
IReadOnlyList<long> tenantPackageIds,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增租户套餐。
/// </summary>
/// <param name="package">套餐实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default);
/// <summary>
/// 软删除租户套餐。
/// </summary>
/// <param name="package">套餐实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task SoftDeleteAsync(TenantPackage package, CancellationToken cancellationToken = default);
/// <summary>
/// 保存仓储变更。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步操作任务。</returns>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 获取套餐当前使用租户列表(按有效订阅口径)。
/// </summary>
/// <param name="tenantPackageId">套餐 ID。</param>
/// <param name="keyword">关键字(租户名称或编码)。</param>
/// <param name="expiringWithinDays">即将到期天数筛选。</param>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>租户列表和总数。</returns>
Task<(IReadOnlyList<TenantPackageTenantResult> Items, int TotalCount)> GetTenantsAsync(
long tenantPackageId,
string? keyword,
int? expiringWithinDays,
int page,
int pageSize,
CancellationToken cancellationToken = default);
}
/// <summary>
/// 套餐使用统计结果。
/// </summary>
public sealed record TenantPackageUsageResult(
long TenantPackageId,
int ActiveSubscriptionCount,
int ActiveTenantCount,
int TotalSubscriptionCount,
decimal Mrr,
decimal Arr,
int ExpiringTenantCount7Days,
int ExpiringTenantCount15Days,
int ExpiringTenantCount30Days);
/// <summary>
/// 套餐使用租户结果。
/// </summary>
public sealed record TenantPackageTenantResult(
long TenantId,
string Code,
string Name,
TenantStatus Status,
string? ContactName,
string? ContactPhone,
DateTime SubscriptionEffectiveFrom,
DateTime SubscriptionEffectiveTo);

View File

@@ -1,3 +1,4 @@
using TakeoutSaaS.Domain.Billings.Entities;
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Domain.Tenants.Repositories;
@@ -30,4 +31,43 @@ public interface ITenantRepository
/// <param name="cancellationToken">取消标记。</param>
/// <returns>租户列表。</returns>
Task<IReadOnlyList<Tenant>> GetAllAsync(string? keyword, CancellationToken cancellationToken = default);
/// <summary>
/// 获取租户详情(包含认证、订阅、套餐信息)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>租户详情元组,未找到返回 null。</returns>
Task<TenantDetailResult?> GetDetailAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取租户配额使用情况列表。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>配额使用情况列表。</returns>
Task<IReadOnlyList<TenantQuotaUsage>> GetQuotaUsagesAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取租户账单列表(分页)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单列表和总数。</returns>
Task<(IReadOnlyList<TenantBillingStatement> Items, int TotalCount)> GetBillingsAsync(
long tenantId,
int page,
int pageSize,
CancellationToken cancellationToken = default);
}
/// <summary>
/// 租户详情查询结果。
/// </summary>
public sealed record TenantDetailResult(
Tenant Tenant,
TenantVerificationProfile? Verification,
TenantSubscription? Subscription,
TenantPackage? Package);

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Billings.Repositories;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories;
using TakeoutSaaS.Domain.Merchants.Repositories;
@@ -46,6 +47,9 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
services.AddScoped<ITenantRepository, EfTenantRepository>();
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
services.AddScoped<ISubscriptionRepository, EfSubscriptionRepository>();
services.AddScoped<IBillingRepository, EfBillingRepository>();
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
services.AddScoped<IOperationLogRepository, EfOperationLogRepository>();

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TakeoutSaaS.Domain.Analytics.Entities;
using TakeoutSaaS.Domain.Billings.Entities;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.CustomerService.Entities;
using TakeoutSaaS.Domain.Deliveries.Entities;
@@ -40,6 +41,34 @@ public class TakeoutAppDbContext(
/// </summary>
public DbSet<Tenant> Tenants => Set<Tenant>();
/// <summary>
/// 租户认证资料。
/// </summary>
public DbSet<TenantVerificationProfile> TenantVerificationProfiles => Set<TenantVerificationProfile>();
/// <summary>
/// 租户订阅记录。
/// </summary>
public DbSet<TenantSubscription> TenantSubscriptions => Set<TenantSubscription>();
/// <summary>
/// 租户套餐定义。
/// </summary>
public DbSet<TenantPackage> TenantPackages => Set<TenantPackage>();
/// <summary>
/// 租户配额使用情况。
/// </summary>
public DbSet<TenantQuotaUsage> TenantQuotaUsages => Set<TenantQuotaUsage>();
/// <summary>
/// 租户账单。
/// </summary>
public DbSet<TenantBillingStatement> TenantBillingStatements => Set<TenantBillingStatement>();
/// <summary>
/// 租户订阅变更历史。
/// </summary>
public DbSet<TenantSubscriptionHistory> TenantSubscriptionHistories => Set<TenantSubscriptionHistory>();
/// <summary>
/// 租户支付记录。
/// </summary>
public DbSet<TenantPayment> TenantPayments => Set<TenantPayment>();
/// <summary>
/// 商户实体。
/// </summary>
public DbSet<Merchant> Merchants => Set<Merchant>();
@@ -341,6 +370,13 @@ public class TakeoutAppDbContext(
internal static void ConfigureModel(ModelBuilder modelBuilder)
{
ConfigureTenant(modelBuilder.Entity<Tenant>());
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
ConfigureTenantSubscription(modelBuilder.Entity<TenantSubscription>());
ConfigureTenantPackage(modelBuilder.Entity<TenantPackage>());
ConfigureTenantQuotaUsage(modelBuilder.Entity<TenantQuotaUsage>());
ConfigureTenantBillingStatement(modelBuilder.Entity<TenantBillingStatement>());
ConfigureTenantSubscriptionHistory(modelBuilder.Entity<TenantSubscriptionHistory>());
ConfigureTenantPayment(modelBuilder.Entity<TenantPayment>());
ConfigureMerchant(modelBuilder.Entity<Merchant>());
ConfigureStore(modelBuilder.Entity<Store>());
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
@@ -430,6 +466,127 @@ public class TakeoutAppDbContext(
builder.HasIndex(x => x.ContactPhone).IsUnique();
}
private static void ConfigureTenantVerificationProfile(EntityTypeBuilder<TenantVerificationProfile> builder)
{
builder.ToTable("tenant_verification_profiles");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(64);
builder.Property(x => x.BusinessLicenseUrl).HasMaxLength(512);
builder.Property(x => x.LegalPersonName).HasMaxLength(64);
builder.Property(x => x.LegalPersonIdNumber).HasMaxLength(32);
builder.Property(x => x.LegalPersonIdFrontUrl).HasMaxLength(512);
builder.Property(x => x.LegalPersonIdBackUrl).HasMaxLength(512);
builder.Property(x => x.BankAccountName).HasMaxLength(128);
builder.Property(x => x.BankAccountNumber).HasMaxLength(64);
builder.Property(x => x.BankName).HasMaxLength(128);
builder.Property(x => x.AdditionalDataJson).HasColumnType("text");
builder.Property(x => x.ReviewedByName).HasMaxLength(64);
builder.Property(x => x.ReviewRemarks).HasMaxLength(512);
builder.HasIndex(x => x.TenantId);
}
private static void ConfigureTenantSubscription(EntityTypeBuilder<TenantSubscription> builder)
{
builder.ToTable("tenant_subscriptions");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.TenantPackageId).IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.EffectiveFrom).IsRequired();
builder.Property(x => x.EffectiveTo).IsRequired();
builder.Property(x => x.Notes).HasColumnType("text");
builder.HasIndex(x => x.TenantId);
builder.HasOne(x => x.TenantPackage)
.WithMany()
.HasForeignKey(x => x.TenantPackageId)
.OnDelete(DeleteBehavior.Restrict);
}
private static void ConfigureTenantPackage(EntityTypeBuilder<TenantPackage> builder)
{
builder.ToTable("tenant_packages");
builder.HasKey(x => x.Id);
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
builder.Property(x => x.Description).HasMaxLength(512);
builder.Property(x => x.PackageType).HasConversion<int>();
builder.Property(x => x.MonthlyPrice).HasPrecision(18, 2);
builder.Property(x => x.YearlyPrice).HasPrecision(18, 2);
builder.Property(x => x.FeaturePoliciesJson).HasColumnType("text");
builder.Property(x => x.PublishStatus).HasConversion<int>();
builder.HasIndex(x => x.Name);
}
private static void ConfigureTenantQuotaUsage(EntityTypeBuilder<TenantQuotaUsage> builder)
{
builder.ToTable("tenant_quota_usages");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.QuotaType).HasConversion<int>();
builder.Property(x => x.LimitValue).HasPrecision(18, 2);
builder.Property(x => x.UsedValue).HasPrecision(18, 2);
builder.Property(x => x.ResetCycle).HasColumnType("text");
builder.HasIndex(x => new { x.TenantId, x.QuotaType }).IsUnique();
}
private static void ConfigureTenantBillingStatement(EntityTypeBuilder<TenantBillingStatement> builder)
{
builder.ToTable("tenant_billing_statements");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired();
builder.Property(x => x.BillingType).HasConversion<int>();
builder.Property(x => x.PeriodStart).IsRequired();
builder.Property(x => x.PeriodEnd).IsRequired();
builder.Property(x => x.AmountDue).HasPrecision(18, 2);
builder.Property(x => x.AmountPaid).HasPrecision(18, 2);
builder.Property(x => x.DiscountAmount).HasPrecision(18, 2);
builder.Property(x => x.TaxAmount).HasPrecision(18, 2);
builder.Property(x => x.Currency).HasMaxLength(8).IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.DueDate).IsRequired();
builder.Property(x => x.LineItemsJson).HasColumnType("text");
builder.Property(x => x.Notes).HasMaxLength(512);
builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.Status, x.DueDate });
}
private static void ConfigureTenantSubscriptionHistory(EntityTypeBuilder<TenantSubscriptionHistory> builder)
{
builder.ToTable("tenant_subscription_histories");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.TenantSubscriptionId).IsRequired();
builder.Property(x => x.FromPackageId).IsRequired();
builder.Property(x => x.ToPackageId).IsRequired();
builder.Property(x => x.ChangeType).HasConversion<int>();
builder.Property(x => x.EffectiveFrom).IsRequired();
builder.Property(x => x.EffectiveTo).IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2);
builder.Property(x => x.Currency).HasMaxLength(8);
builder.Property(x => x.Notes).HasMaxLength(512);
builder.HasIndex(x => x.TenantSubscriptionId);
builder.HasIndex(x => x.TenantId);
}
private static void ConfigureTenantPayment(EntityTypeBuilder<TenantPayment> builder)
{
builder.ToTable("tenant_payments");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
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(128);
builder.Property(x => x.ProofUrl).HasMaxLength(512);
builder.Property(x => x.Notes).HasMaxLength(512);
builder.Property(x => x.RefundReason).HasMaxLength(512);
builder.HasIndex(x => x.BillingStatementId);
builder.HasIndex(x => x.TenantId);
}
private static void ConfigureMerchant(EntityTypeBuilder<Merchant> builder)
{
builder.ToTable("merchants");

View File

@@ -0,0 +1,241 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Billings.Entities;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Domain.Billings.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 账单仓储实现AdminApi 使用)。
/// </summary>
public sealed class EfBillingRepository(TakeoutAdminDbContext context) : IBillingRepository
{
/// <inheritdoc />
public async Task<(IReadOnlyList<BillingListResult> Items, int TotalCount)> GetListAsync(
long? tenantId,
TenantBillingStatus? status,
TenantBillingType? billingType,
DateTime? startDate,
DateTime? endDate,
decimal? minAmount,
decimal? maxAmount,
string? keyword,
string? sortBy,
bool? sortDesc,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = from b in context.TenantBillingStatements.AsNoTracking()
join t in context.Tenants.AsNoTracking() on b.TenantId equals t.Id into tenants
from t in tenants.DefaultIfEmpty()
where b.DeletedAt == null
select new { Billing = b, TenantName = t != null ? t.Name : (string?)null };
// 2. 应用租户 ID 过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.Billing.TenantId == tenantId.Value);
}
// 3. 应用状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Billing.Status == status.Value);
}
// 4. 应用账单类型过滤
if (billingType.HasValue)
{
query = query.Where(x => x.Billing.BillingType == billingType.Value);
}
// 5. 应用日期范围过滤
if (startDate.HasValue)
{
query = query.Where(x => x.Billing.CreatedAt >= startDate.Value);
}
if (endDate.HasValue)
{
query = query.Where(x => x.Billing.CreatedAt <= endDate.Value);
}
// 6. 应用金额范围过滤
if (minAmount.HasValue)
{
query = query.Where(x => x.Billing.AmountDue >= minAmount.Value);
}
if (maxAmount.HasValue)
{
query = query.Where(x => x.Billing.AmountDue <= maxAmount.Value);
}
// 7. 应用关键词过滤
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => x.Billing.StatementNo.Contains(normalized) ||
(x.TenantName != null && x.TenantName.Contains(normalized)));
}
// 8. 获取总数
var totalCount = await query.CountAsync(cancellationToken);
// 9. 应用排序并分页查询
var isDesc = sortDesc ?? true;
var sortField = sortBy?.ToLowerInvariant() ?? "createdat";
List<BillingListResult> items;
if (sortField == "duedate")
{
items = isDesc
? await query.OrderByDescending(x => x.Billing.DueDate).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken)
: await query.OrderBy(x => x.Billing.DueDate).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken);
}
else if (sortField == "amountdue")
{
items = isDesc
? await query.OrderByDescending(x => x.Billing.AmountDue).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken)
: await query.OrderBy(x => x.Billing.AmountDue).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken);
}
else
{
items = isDesc
? await query.OrderByDescending(x => x.Billing.CreatedAt).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken)
: await query.OrderBy(x => x.Billing.CreatedAt).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken);
}
// 10. 返回结果
return (items, totalCount);
}
/// <inheritdoc />
public async Task<TenantBillingStatement?> GetByIdAsync(long billingId, CancellationToken cancellationToken = default)
{
// 1. 查询账单(排除已删除,无跟踪)
return await context.TenantBillingStatements
.AsNoTracking()
.Where(b => b.Id == billingId && b.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<TenantBillingStatement?> GetByIdForUpdateAsync(long billingId, CancellationToken cancellationToken = default)
{
// 1. 查询账单(排除已删除,带跟踪用于更新)
return await context.TenantBillingStatements
.Where(b => b.Id == billingId && b.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<BillingDetailResult?> GetDetailAsync(long billingId, CancellationToken cancellationToken = default)
{
// 1. 查询账单详情(带租户信息)
var result = await (from b in context.TenantBillingStatements.AsNoTracking()
join t in context.Tenants.AsNoTracking() on b.TenantId equals t.Id into tenants
from t in tenants.DefaultIfEmpty()
where b.Id == billingId && b.DeletedAt == null
select new
{
Billing = b,
TenantName = t != null ? t.Name : (string?)null
}).FirstOrDefaultAsync(cancellationToken);
// 2. 如果不存在,返回 null
if (result is null)
{
return null;
}
// 3. 返回详情结果
return new BillingDetailResult(
result.Billing.Id,
result.Billing.TenantId,
result.TenantName,
result.Billing.StatementNo,
result.Billing.BillingType,
result.Billing.PeriodStart,
result.Billing.PeriodEnd,
result.Billing.AmountDue,
result.Billing.AmountPaid,
result.Billing.DiscountAmount,
result.Billing.TaxAmount,
result.Billing.Currency,
result.Billing.Status,
result.Billing.DueDate,
result.Billing.LineItemsJson,
result.Billing.Notes,
result.Billing.OverdueNotifiedAt,
result.Billing.ReminderSentAt,
result.Billing.CreatedAt,
result.Billing.UpdatedAt);
}
/// <inheritdoc />
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存变更
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddPaymentAsync(TenantPayment payment, CancellationToken cancellationToken = default)
{
// 1. 添加支付记录
await context.TenantPayments.AddAsync(payment, cancellationToken);
}
}

View File

@@ -0,0 +1,272 @@
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>
/// 订阅仓储实现AdminApi 使用)。
/// </summary>
public sealed class EfSubscriptionRepository(TakeoutAdminDbContext context) : ISubscriptionRepository
{
/// <inheritdoc />
public async Task<(IReadOnlyList<SubscriptionListResult> Items, int TotalCount)> GetListAsync(
SubscriptionStatus? status,
long? tenantPackageId,
long? tenantId,
string? tenantKeyword,
int? expiringWithinDays,
bool? autoRenew,
DateTime? expireFrom,
DateTime? expireTo,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 获取当前时间
var now = DateTime.UtcNow;
// 2. 构建基础查询
var query = from s in context.TenantSubscriptions.AsNoTracking()
join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id
join p in context.TenantPackages.AsNoTracking() on s.TenantPackageId equals p.Id
where s.DeletedAt == null && t.DeletedAt == null
select new
{
Subscription = s,
Tenant = t,
Package = p,
ScheduledPackage = context.TenantPackages
.Where(sp => sp.Id == s.ScheduledPackageId)
.Select(sp => new { sp.Id, sp.Name })
.FirstOrDefault()
};
// 3. 应用订阅状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Subscription.Status == status.Value);
}
// 4. 应用套餐 ID 过滤
if (tenantPackageId.HasValue)
{
query = query.Where(x => x.Subscription.TenantPackageId == tenantPackageId.Value);
}
// 5. 应用租户 ID 过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.Subscription.TenantId == tenantId.Value);
}
// 6. 应用租户关键词过滤
if (!string.IsNullOrWhiteSpace(tenantKeyword))
{
var normalized = tenantKeyword.Trim();
query = query.Where(x => x.Tenant.Name.Contains(normalized) || x.Tenant.Code.Contains(normalized));
}
// 7. 应用到期天数筛选
if (expiringWithinDays.HasValue)
{
var expiryDate = now.AddDays(expiringWithinDays.Value);
query = query.Where(x => x.Subscription.EffectiveTo <= expiryDate && x.Subscription.EffectiveTo > now);
}
// 8. 应用自动续费筛选
if (autoRenew.HasValue)
{
query = query.Where(x => x.Subscription.AutoRenew == autoRenew.Value);
}
// 9. 应用到期时间范围筛选
if (expireFrom.HasValue)
{
query = query.Where(x => x.Subscription.EffectiveTo >= expireFrom.Value);
}
if (expireTo.HasValue)
{
query = query.Where(x => x.Subscription.EffectiveTo <= expireTo.Value);
}
// 10. 获取总数
var totalCount = await query.CountAsync(cancellationToken);
// 11. 分页查询(按到期时间升序)
var items = await query
.OrderBy(x => x.Subscription.EffectiveTo)
.ThenBy(x => x.Subscription.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new SubscriptionListResult(
x.Subscription.Id,
x.Tenant.Id,
x.Tenant.Name,
x.Tenant.Code,
x.Package.Id,
x.Package.Name,
x.ScheduledPackage != null ? x.ScheduledPackage.Id : (long?)null,
x.ScheduledPackage != null ? x.ScheduledPackage.Name : null,
x.Subscription.Status,
x.Subscription.EffectiveFrom,
x.Subscription.EffectiveTo,
x.Subscription.NextBillingDate,
x.Subscription.AutoRenew,
x.Subscription.Notes,
x.Subscription.CreatedAt,
x.Subscription.UpdatedAt))
.ToListAsync(cancellationToken);
// 12. 返回结果
return (items, totalCount);
}
/// <inheritdoc />
public async Task<TenantSubscription?> GetByIdAsync(long subscriptionId, CancellationToken cancellationToken = default)
{
// 1. 查询订阅(排除已删除,无跟踪)
return await context.TenantSubscriptions
.AsNoTracking()
.Where(s => s.Id == subscriptionId && s.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<TenantSubscription?> GetByIdForUpdateAsync(long subscriptionId, CancellationToken cancellationToken = default)
{
// 1. 查询订阅(排除已删除,带跟踪用于更新)
return await context.TenantSubscriptions
.Where(s => s.Id == subscriptionId && s.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存变更
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<SubscriptionDetailResult?> GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default)
{
// 1. 查询订阅详情
var result = await (from s in context.TenantSubscriptions.AsNoTracking()
join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id
join p in context.TenantPackages.AsNoTracking() on s.TenantPackageId equals p.Id
where s.Id == subscriptionId && s.DeletedAt == null && t.DeletedAt == null
select new
{
Subscription = s,
Tenant = t,
Package = p,
ScheduledPackage = context.TenantPackages
.Where(sp => sp.Id == s.ScheduledPackageId)
.FirstOrDefault()
}).FirstOrDefaultAsync(cancellationToken);
// 2. 如果不存在,返回 null
if (result is null)
{
return null;
}
// 3. 返回详情结果
return new SubscriptionDetailResult(
result.Subscription.Id,
result.Tenant.Id,
result.Tenant.Name,
result.Tenant.Code,
result.Package.Id,
result.Package.Name,
result.ScheduledPackage?.Id,
result.ScheduledPackage?.Name,
result.Subscription.Status,
result.Subscription.EffectiveFrom,
result.Subscription.EffectiveTo,
result.Subscription.NextBillingDate,
result.Subscription.AutoRenew,
result.Subscription.Notes,
result.Subscription.CreatedAt,
result.Subscription.UpdatedAt,
result.Package,
result.ScheduledPackage);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SubscriptionHistoryResult>> GetHistoriesAsync(long subscriptionId, CancellationToken cancellationToken = default)
{
// 1. 查询变更历史
var histories = await (from h in context.TenantSubscriptionHistories.AsNoTracking()
join fp in context.TenantPackages.AsNoTracking() on h.FromPackageId equals fp.Id
join tp in context.TenantPackages.AsNoTracking() on h.ToPackageId equals tp.Id
where h.TenantSubscriptionId == subscriptionId && h.DeletedAt == null
orderby h.CreatedAt descending
select new SubscriptionHistoryResult(
h.Id,
h.TenantSubscriptionId,
h.ChangeType,
h.FromPackageId,
fp.Name,
h.ToPackageId,
tp.Name,
h.EffectiveFrom,
h.EffectiveTo,
h.Notes,
h.CreatedAt,
h.CreatedBy))
.ToListAsync(cancellationToken);
// 2. 返回结果
return histories;
}
/// <inheritdoc />
public async Task<SubscriptionListResult?> GetListResultByIdAsync(long subscriptionId, CancellationToken cancellationToken = default)
{
// 1. 查询订阅(带租户和套餐信息)
var result = await (from s in context.TenantSubscriptions.AsNoTracking()
join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id
join p in context.TenantPackages.AsNoTracking() on s.TenantPackageId equals p.Id
where s.Id == subscriptionId && s.DeletedAt == null && t.DeletedAt == null
select new
{
Subscription = s,
Tenant = t,
Package = p,
ScheduledPackage = context.TenantPackages
.Where(sp => sp.Id == s.ScheduledPackageId)
.Select(sp => new { sp.Id, sp.Name })
.FirstOrDefault()
}).FirstOrDefaultAsync(cancellationToken);
// 2. 如果不存在,返回 null
if (result is null)
{
return null;
}
// 3. 返回列表结果
return new SubscriptionListResult(
result.Subscription.Id,
result.Tenant.Id,
result.Tenant.Name,
result.Tenant.Code,
result.Package.Id,
result.Package.Name,
result.ScheduledPackage != null ? result.ScheduledPackage.Id : (long?)null,
result.ScheduledPackage != null ? result.ScheduledPackage.Name : null,
result.Subscription.Status,
result.Subscription.EffectiveFrom,
result.Subscription.EffectiveTo,
result.Subscription.NextBillingDate,
result.Subscription.AutoRenew,
result.Subscription.Notes,
result.Subscription.CreatedAt,
result.Subscription.UpdatedAt);
}
}

View File

@@ -0,0 +1,234 @@
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>
/// 租户套餐仓储实现AdminApi 使用)。
/// </summary>
public sealed class EfTenantPackageRepository(TakeoutAdminDbContext context) : ITenantPackageRepository
{
/// <inheritdoc />
public async Task<TenantPackage?> GetByIdAsync(long tenantPackageId, CancellationToken cancellationToken = default)
{
// 1. 查询套餐(排除已删除,无跟踪)
return await context.TenantPackages
.AsNoTracking()
.Where(p => p.Id == tenantPackageId && p.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<TenantPackage?> GetByIdForUpdateAsync(long tenantPackageId, CancellationToken cancellationToken = default)
{
// 1. 查询套餐(排除已删除,带跟踪用于更新)
return await context.TenantPackages
.Where(p => p.Id == tenantPackageId && p.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantPackage> Items, int TotalCount)> GetListAsync(
string? keyword,
bool? isActive,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = context.TenantPackages
.AsNoTracking()
.Where(p => p.DeletedAt == null);
// 2. 应用关键字过滤
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(p => p.Name.Contains(normalized));
}
// 3. 应用启用状态过滤
if (isActive.HasValue)
{
query = query.Where(p => p.IsActive == isActive.Value);
}
// 4. 获取总数
var totalCount = await query.CountAsync(cancellationToken);
// 5. 分页查询(按排序序号升序)
var items = await query
.OrderBy(p => p.SortOrder)
.ThenBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
// 6. 返回结果
return (items, totalCount);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPackageUsageResult>> GetUsagesAsync(
IReadOnlyList<long> tenantPackageIds,
CancellationToken cancellationToken = default)
{
// 1. 如果没有传入套餐 ID返回空列表
if (tenantPackageIds.Count == 0)
{
return [];
}
// 2. 获取当前时间用于计算到期天数
var now = DateTime.UtcNow;
var in7Days = now.AddDays(7);
var in15Days = now.AddDays(15);
var in30Days = now.AddDays(30);
// 3. 查询订阅数据并按套餐分组统计
var subscriptions = await context.TenantSubscriptions
.AsNoTracking()
.Where(s => tenantPackageIds.Contains(s.TenantPackageId) && s.DeletedAt == null)
.Select(s => new
{
s.TenantPackageId,
s.TenantId,
s.Status,
s.EffectiveTo,
MonthlyPrice = context.TenantPackages
.Where(p => p.Id == s.TenantPackageId)
.Select(p => p.MonthlyPrice)
.FirstOrDefault()
})
.ToListAsync(cancellationToken);
// 4. 按套餐 ID 分组统计
var results = tenantPackageIds.Select(packageId =>
{
var packageSubscriptions = subscriptions.Where(s => s.TenantPackageId == packageId).ToList();
// 4.1 活跃订阅(状态为 Active 且未过期)
var activeSubscriptions = packageSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo > now)
.ToList();
// 4.2 活跃租户数(去重)
var activeTenantCount = activeSubscriptions.Select(s => s.TenantId).Distinct().Count();
// 4.3 总订阅数
var totalSubscriptionCount = packageSubscriptions.Count;
// 4.4 计算 MRR月度经常性收入
var mrr = activeSubscriptions.Sum(s => s.MonthlyPrice ?? 0);
// 4.5 计算 ARR年度经常性收入
var arr = mrr * 12;
// 4.6 计算到期租户数(基于活跃订阅)
var expiringIn7Days = activeSubscriptions.Count(s => s.EffectiveTo <= in7Days);
var expiringIn15Days = activeSubscriptions.Count(s => s.EffectiveTo <= in15Days);
var expiringIn30Days = activeSubscriptions.Count(s => s.EffectiveTo <= in30Days);
return new TenantPackageUsageResult(
packageId,
activeSubscriptions.Count,
activeTenantCount,
totalSubscriptionCount,
mrr,
arr,
expiringIn7Days,
expiringIn15Days,
expiringIn30Days);
}).ToList();
// 5. 返回结果
return results;
}
/// <inheritdoc />
public async Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default)
{
// 1. 添加套餐实体
await context.TenantPackages.AddAsync(package, cancellationToken);
}
/// <inheritdoc />
public Task SoftDeleteAsync(TenantPackage package, CancellationToken cancellationToken = default)
{
// 1. 设置软删除时间
package.DeletedAt = DateTime.UtcNow;
// 2. 返回已完成任务
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存变更
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantPackageTenantResult> Items, int TotalCount)> GetTenantsAsync(
long tenantPackageId,
string? keyword,
int? expiringWithinDays,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 获取当前时间
var now = DateTime.UtcNow;
// 2. 构建基础查询:活跃订阅(状态为 Active 且未过期)
var query = from s in context.TenantSubscriptions.AsNoTracking()
join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id
where s.TenantPackageId == tenantPackageId
&& s.DeletedAt == null
&& t.DeletedAt == null
&& s.Status == SubscriptionStatus.Active
&& s.EffectiveTo > now
select new { Subscription = s, Tenant = t };
// 3. 应用关键字过滤
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => x.Tenant.Name.Contains(normalized) || x.Tenant.Code.Contains(normalized));
}
// 4. 应用到期天数筛选
if (expiringWithinDays.HasValue)
{
var expiryDate = now.AddDays(expiringWithinDays.Value);
query = query.Where(x => x.Subscription.EffectiveTo <= expiryDate);
}
// 5. 获取总数
var totalCount = await query.CountAsync(cancellationToken);
// 6. 分页查询(按到期时间升序,即将到期的排前面)
var items = await query
.OrderBy(x => x.Subscription.EffectiveTo)
.ThenBy(x => x.Tenant.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new TenantPackageTenantResult(
x.Tenant.Id,
x.Tenant.Code,
x.Tenant.Name,
x.Tenant.Status,
x.Tenant.ContactName,
x.Tenant.ContactPhone,
x.Subscription.EffectiveFrom,
x.Subscription.EffectiveTo))
.ToListAsync(cancellationToken);
// 7. 返回结果
return (items, totalCount);
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Billings.Entities;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
@@ -53,4 +54,73 @@ public sealed class EfTenantRepository(TakeoutAdminDbContext context) : ITenantR
// 3. 返回列表
return await query.OrderBy(x => x.Code).ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<TenantDetailResult?> GetDetailAsync(long tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询租户基本信息
var tenant = await context.Tenants
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == tenantId, cancellationToken);
if (tenant is null)
{
return null;
}
// 2. 查询认证信息(取最新一条)
var verification = await context.TenantVerificationProfiles
.AsNoTracking()
.Where(v => v.TenantId == tenantId)
.OrderByDescending(v => v.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
// 3. 查询订阅信息(取最新一条,包含套餐)
var subscription = await context.TenantSubscriptions
.AsNoTracking()
.Include(s => s.TenantPackage)
.Where(s => s.TenantId == tenantId)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
// 4. 返回结果
return new TenantDetailResult(tenant, verification, subscription, subscription?.TenantPackage);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantQuotaUsage>> GetQuotaUsagesAsync(long tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询租户配额使用情况
return await context.TenantQuotaUsages
.AsNoTracking()
.Where(q => q.TenantId == tenantId && q.DeletedAt == null)
.OrderBy(q => q.QuotaType)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantBillingStatement> Items, int TotalCount)> GetBillingsAsync(
long tenantId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = context.TenantBillingStatements
.AsNoTracking()
.Where(b => b.TenantId == tenantId && b.DeletedAt == null);
// 2. 获取总数
var totalCount = await query.CountAsync(cancellationToken);
// 3. 分页查询(按创建时间倒序)
var items = await query
.OrderByDescending(b => b.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
// 4. 返回结果
return (items, totalCount);
}
}

View File

@@ -1,6 +1,9 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace TakeoutSaaS.Module.Authorization.Policies;
/// <summary>
/// 权限策略提供者(按需动态构建策略)。
/// </summary>
@@ -10,7 +13,12 @@ public sealed class PermissionAuthorizationPolicyProvider(IOptions<Authorization
/// 权限策略名称前缀。
/// </summary>
public const string PolicyPrefix = "PERMISSION:";
private readonly AuthorizationOptions _options = options.Value;
/// <summary>
/// 线程安全的策略缓存。
/// </summary>
private static readonly ConcurrentDictionary<string, AuthorizationPolicy> PolicyCache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 获取或构建指定名称的权限策略。
/// </summary>
@@ -23,26 +31,32 @@ public sealed class PermissionAuthorizationPolicyProvider(IOptions<Authorization
{
return base.GetPolicyAsync(policyName);
}
// 2. 复用已存在的策略
var existingPolicy = _options.GetPolicy(policyName);
if (existingPolicy != null)
// 2. 尝试从线程安全缓存获取
if (PolicyCache.TryGetValue(policyName, out var cachedPolicy))
{
return Task.FromResult<AuthorizationPolicy?>(existingPolicy);
return Task.FromResult<AuthorizationPolicy?>(cachedPolicy);
}
// 3. 解析策略携带的权限列表
var permissions = ParsePermissions(policyName);
if (permissions.Length == 0)
{
return Task.FromResult<AuthorizationPolicy?>(null);
}
// 4. 动态构建策略并缓存
// 4. 动态构建策略
var policy = new AuthorizationPolicyBuilder()
.AddRequirements(new PermissionRequirement(permissions))
.Build();
_options.AddPolicy(policyName, policy);
// 5. 返回构建好的策略
// 5. 添加到线程安全缓存(如果已存在则返回已存在的值)
policy = PolicyCache.GetOrAdd(policyName, policy);
// 6. 返回构建好的策略
return Task.FromResult<AuthorizationPolicy?>(policy);
}
/// <summary>
/// 根据权限集合构建策略名称。
/// </summary>
@@ -50,13 +64,16 @@ public sealed class PermissionAuthorizationPolicyProvider(IOptions<Authorization
/// <returns>策略名称。</returns>
public static string BuildPolicyName(IEnumerable<string> permissions)
=> $"{PolicyPrefix}{string.Join('|', NormalizePermissions(permissions))}";
private static string[] ParsePermissions(string policyName)
{
// 1. 拆分策略名称得到原始权限列表
var raw = policyName[PolicyPrefix.Length..];
// 2. 规范化并过滤权限
return NormalizePermissions(raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
private static string[] NormalizePermissions(IEnumerable<string> permissions)
=> [.. permissions
.Where(p => !string.IsNullOrWhiteSpace(p))