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

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

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

View File

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

View File

@@ -0,0 +1,197 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Contracts;
/// <summary>
/// 账单详情 DTO。
/// </summary>
public sealed record BillingDetailDto
{
/// <summary>
/// 账单 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string? TenantName { get; init; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 账单类型。
/// </summary>
public TenantBillingType BillingType { get; init; }
/// <summary>
/// 账单周期开始时间。
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 账单周期结束时间。
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 货币代码。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日期。
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 账单明细 JSON。
/// </summary>
public string? LineItemsJson { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; init; }
/// <summary>
/// 提醒发送时间。
/// </summary>
public DateTime? ReminderSentAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
/// <summary>
/// 支付记录列表。
/// </summary>
public IReadOnlyList<PaymentRecordDto> Payments { get; init; } = [];
}
/// <summary>
/// 支付记录 DTO。
/// </summary>
public sealed record PaymentRecordDto
{
/// <summary>
/// 支付记录 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 账单 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long BillingStatementId { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public int Method { get; init; }
/// <summary>
/// 支付状态。
/// </summary>
public int Status { get; init; }
/// <summary>
/// 交易号。
/// </summary>
public string? TransactionNo { get; init; }
/// <summary>
/// 支付凭证 URL。
/// </summary>
public string? ProofUrl { get; init; }
/// <summary>
/// 支付时间。
/// </summary>
public DateTime? PaidAt { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 审核人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? VerifiedBy { get; init; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? VerifiedAt { get; init; }
/// <summary>
/// 退款原因。
/// </summary>
public string? RefundReason { get; init; }
/// <summary>
/// 退款时间。
/// </summary>
public DateTime? RefundedAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,98 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Contracts;
/// <summary>
/// 账单列表项 DTO。
/// </summary>
public sealed record BillingListDto
{
/// <summary>
/// 账单 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string? TenantName { get; init; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 账单类型。
/// </summary>
public TenantBillingType BillingType { get; init; }
/// <summary>
/// 账单周期开始时间。
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 账单周期结束时间。
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 货币代码。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日期。
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Subscriptions.Contracts;
/// <summary>
/// 订阅列表项 DTO。
/// </summary>
public sealed record SubscriptionListDto
{
/// <summary>
/// 订阅 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 租户编码。
/// </summary>
public string TenantCode { get; init; } = string.Empty;
/// <summary>
/// 当前套餐 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantPackageId { get; init; }
/// <summary>
/// 当前套餐名称。
/// </summary>
public string PackageName { get; init; } = string.Empty;
/// <summary>
/// 排期套餐 ID下周期生效雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? ScheduledPackageId { get; init; }
/// <summary>
/// 排期套餐名称。
/// </summary>
public string? ScheduledPackageName { get; init; }
/// <summary>
/// 订阅状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间。
/// </summary>
public DateTime EffectiveTo { get; init; }
/// <summary>
/// 下次计费时间。
/// </summary>
public DateTime? NextBillingDate { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 获取订阅列表查询处理器。
/// </summary>
public sealed class ListSubscriptionsQueryHandler(ISubscriptionRepository subscriptionRepository)
: IRequestHandler<ListSubscriptionsQuery, PagedResult<SubscriptionListDto>>
{
/// <inheritdoc />
public async Task<PagedResult<SubscriptionListDto>> Handle(
ListSubscriptionsQuery request,
CancellationToken cancellationToken)
{
// 1. 查询订阅列表
var (items, totalCount) = await subscriptionRepository.GetListAsync(
request.Status,
request.TenantPackageId,
request.TenantId,
request.TenantKeyword,
request.ExpiringWithinDays,
request.AutoRenew,
request.ExpireFrom,
request.ExpireTo,
request.Page,
request.PageSize,
cancellationToken);
// 2. 映射为 DTO
var dtos = items.Select(s => new SubscriptionListDto
{
Id = s.Id,
TenantId = s.TenantId,
TenantName = s.TenantName,
TenantCode = s.TenantCode,
TenantPackageId = s.TenantPackageId,
PackageName = s.PackageName,
ScheduledPackageId = s.ScheduledPackageId,
ScheduledPackageName = s.ScheduledPackageName,
Status = s.Status,
EffectiveFrom = s.EffectiveFrom,
EffectiveTo = s.EffectiveTo,
NextBillingDate = s.NextBillingDate,
AutoRenew = s.AutoRenew,
Notes = s.Notes,
CreatedAt = s.CreatedAt,
UpdatedAt = s.UpdatedAt
}).ToList();
// 3. 返回分页结果
return new PagedResult<SubscriptionListDto>(dtos, totalCount, request.Page, request.PageSize);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Contracts;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Subscriptions.Queries;
/// <summary>
/// 获取订阅列表查询。
/// </summary>
public sealed record ListSubscriptionsQuery : IRequest<PagedResult<SubscriptionListDto>>
{
/// <summary>
/// 订阅状态。
/// </summary>
public SubscriptionStatus? Status { get; init; }
/// <summary>
/// 套餐 ID。
/// </summary>
public long? TenantPackageId { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 租户关键词(名称或编码)。
/// </summary>
public string? TenantKeyword { get; init; }
/// <summary>
/// 即将到期天数筛选N 天内到期)。
/// </summary>
public int? ExpiringWithinDays { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool? AutoRenew { get; init; }
/// <summary>
/// 到期时间范围开始。
/// </summary>
public DateTime? ExpireFrom { get; init; }
/// <summary>
/// 到期时间范围结束。
/// </summary>
public DateTime? ExpireTo { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Contracts;
/// <summary>
/// 租户账单列表项 DTO。
/// </summary>
public sealed record TenantBillingListDto
{
/// <summary>
/// 账单 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string? TenantName { get; init; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 账单类型。
/// </summary>
public TenantBillingType BillingType { get; init; }
/// <summary>
/// 账单周期开始时间。
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 账单周期结束时间。
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 货币代码。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日期。
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

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

View File

@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Contracts;
/// <summary>
/// 租户配额使用情况 DTO。
/// </summary>
public sealed record TenantQuotaUsageDto
{
/// <summary>
/// 租户 ID雪花序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 配额上限值。
/// </summary>
public decimal LimitValue { get; init; }
/// <summary>
/// 已使用值。
/// </summary>
public decimal UsedValue { get; init; }
/// <summary>
/// 剩余可用值。
/// </summary>
public decimal RemainingValue { get; init; }
/// <summary>
/// 重置周期(如 monthly、yearly
/// </summary>
public string? ResetCycle { get; init; }
/// <summary>
/// 上次重置时间。
/// </summary>
public DateTime? LastResetAt { get; init; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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