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:
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user