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:
197
src/Api/TakeoutSaaS.AdminApi/Controllers/AdminRolesController.cs
Normal file
197
src/Api/TakeoutSaaS.AdminApi/Controllers/AdminRolesController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
147
src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs
Normal file
147
src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
100
src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs
Normal file
100
src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs
Normal 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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 套餐发布状态枚举。
|
||||
/// </summary>
|
||||
public enum TenantPackagePublishStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 草稿。
|
||||
/// </summary>
|
||||
Draft = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 已发布。
|
||||
/// </summary>
|
||||
Published = 1
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user