feat: 新增配额包/支付相关实体与迁移

App:新增 operation_logs/quota_packages/tenant_payments/tenant_quota_package_purchases 表

Identity:修正 Avatar 字段类型(varchar(256)->text),保持现有数据不变
This commit is contained in:
2025-12-17 17:27:45 +08:00
parent 9c28790f5e
commit ab59e2e3e2
103 changed files with 14450 additions and 4 deletions

View File

@@ -0,0 +1,80 @@
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Application.App.Billings;
/// <summary>
/// 账单 DTO 映射助手。
/// </summary>
internal static class BillingMapping
{
/// <summary>
/// 将账单实体映射为账单 DTO。
/// </summary>
/// <param name="bill">账单实体。</param>
/// <param name="tenantName">租户名称。</param>
/// <returns>账单 DTO。</returns>
public static BillDto ToDto(this TenantBillingStatement bill, string? tenantName = null)
=> new()
{
Id = bill.Id,
TenantId = bill.TenantId,
TenantName = tenantName,
StatementNo = bill.StatementNo,
PeriodStart = bill.PeriodStart,
PeriodEnd = bill.PeriodEnd,
AmountDue = bill.AmountDue,
AmountPaid = bill.AmountPaid,
Status = bill.Status,
DueDate = bill.DueDate,
CreatedAt = bill.CreatedAt
};
/// <summary>
/// 将账单实体与支付记录映射为账单详情 DTO。
/// </summary>
/// <param name="bill">账单实体。</param>
/// <param name="payments">支付记录列表。</param>
/// <param name="tenantName">租户名称。</param>
/// <returns>账单详情 DTO。</returns>
public static BillDetailDto ToDetailDto(
this TenantBillingStatement bill,
List<TenantPayment> payments,
string? tenantName = null)
=> new()
{
Id = bill.Id,
TenantId = bill.TenantId,
TenantName = tenantName,
StatementNo = bill.StatementNo,
PeriodStart = bill.PeriodStart,
PeriodEnd = bill.PeriodEnd,
AmountDue = bill.AmountDue,
AmountPaid = bill.AmountPaid,
Status = bill.Status,
DueDate = bill.DueDate,
LineItemsJson = bill.LineItemsJson,
CreatedAt = bill.CreatedAt,
Payments = payments.Select(p => p.ToDto()).ToList()
};
/// <summary>
/// 将支付记录实体映射为支付 DTO。
/// </summary>
/// <param name="payment">支付记录实体。</param>
/// <returns>支付 DTO。</returns>
public static PaymentDto ToDto(this TenantPayment payment)
=> new()
{
Id = payment.Id,
BillingStatementId = payment.BillingStatementId,
Amount = payment.Amount,
Method = payment.Method,
Status = payment.Status,
TransactionNo = payment.TransactionNo,
ProofUrl = payment.ProofUrl,
PaidAt = payment.PaidAt,
Notes = payment.Notes,
CreatedAt = payment.CreatedAt
};
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 创建账单命令。
/// </summary>
public sealed record CreateBillCommand : IRequest<BillDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 记录支付命令。
/// </summary>
public sealed record RecordPaymentCommand : IRequest<PaymentDto>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillId { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public PaymentMethod Method { get; init; }
/// <summary>
/// 交易号。
/// </summary>
public string? TransactionNo { get; init; }
/// <summary>
/// 支付凭证 URL。
/// </summary>
public string? ProofUrl { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 更新账单状态命令。
/// </summary>
public sealed record UpdateBillStatusCommand : IRequest<BillDto?>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillId { get; init; }
/// <summary>
/// 新状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,78 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单详情 DTO。
/// </summary>
public sealed record BillDetailDto
{
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string? TenantName { get; init; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 账单明细 JSON。
/// </summary>
public string? LineItemsJson { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 支付记录列表。
/// </summary>
public List<PaymentDto> Payments { get; init; } = new();
}

View File

@@ -0,0 +1,68 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单 DTO。
/// </summary>
public sealed record BillDto
{
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string? TenantName { get; init; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 支付记录 DTO。
/// </summary>
public sealed record PaymentDto
{
/// <summary>
/// 支付记录 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long BillingStatementId { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public PaymentMethod Method { get; init; }
/// <summary>
/// 支付状态。
/// </summary>
public PaymentStatus Status { get; init; }
/// <summary>
/// 交易号。
/// </summary>
public string? TransactionNo { get; init; }
/// <summary>
/// 支付凭证 URL。
/// </summary>
public string? ProofUrl { get; init; }
/// <summary>
/// 支付时间。
/// </summary>
public DateTime? PaidAt { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,61 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 创建账单处理器。
/// </summary>
public sealed class CreateBillCommandHandler(
ITenantBillingRepository billingRepository,
ITenantRepository tenantRepository,
IIdGenerator idGenerator)
: IRequestHandler<CreateBillCommand, BillDto>
{
/// <summary>
/// 处理创建账单请求。
/// </summary>
/// <param name="request">创建命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单 DTO。</returns>
public async Task<BillDto> Handle(CreateBillCommand request, CancellationToken cancellationToken)
{
// 1. 验证租户存在
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
if (tenant is null)
{
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
}
// 2. 生成账单编号
var statementNo = $"BILL-{DateTime.UtcNow:yyyyMMdd}-{idGenerator.NextId()}";
// 3. 构建账单实体
var bill = new TenantBillingStatement
{
TenantId = request.TenantId,
StatementNo = statementNo,
PeriodStart = DateTime.UtcNow,
PeriodEnd = DateTime.UtcNow,
AmountDue = request.AmountDue,
AmountPaid = 0,
Status = TenantBillingStatus.Pending,
DueDate = request.DueDate,
LineItemsJson = request.Notes
};
// 4. 持久化账单
await billingRepository.AddAsync(bill, cancellationToken);
await billingRepository.SaveChangesAsync(cancellationToken);
// 5. 返回 DTO
return bill.ToDto(tenant.Name);
}
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 获取账单详情查询处理器。
/// </summary>
public sealed class GetBillDetailQueryHandler(
ITenantBillingRepository billingRepository,
ITenantPaymentRepository paymentRepository,
ITenantRepository tenantRepository)
: IRequestHandler<GetBillDetailQuery, BillDetailDto?>
{
/// <summary>
/// 处理获取账单详情请求。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单详情或 null。</returns>
public async Task<BillDetailDto?> Handle(GetBillDetailQuery request, CancellationToken cancellationToken)
{
// 1. 查询账单
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
if (bill is null)
{
return null;
}
// 2. 查询支付记录
var payments = await paymentRepository.GetByBillingIdAsync(request.BillId, cancellationToken);
// 3. 查询租户名称
var tenant = await tenantRepository.FindByIdAsync(bill.TenantId, cancellationToken);
// 4. 返回详情 DTO
return bill.ToDetailDto(payments.ToList(), tenant?.Name);
}
}

View File

@@ -0,0 +1,53 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 获取账单列表查询处理器。
/// </summary>
public sealed class GetBillListQueryHandler(
ITenantBillingRepository billingRepository,
ITenantRepository tenantRepository)
: IRequestHandler<GetBillListQuery, PagedResult<BillDto>>
{
/// <summary>
/// 处理获取账单列表请求。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分页账单列表。</returns>
public async Task<PagedResult<BillDto>> Handle(GetBillListQuery request, CancellationToken cancellationToken)
{
// 1. 分页查询账单
var (bills, total) = await billingRepository.SearchPagedAsync(
request.TenantId,
request.Status,
request.StartDate,
request.EndDate,
request.Keyword,
request.PageNumber,
request.PageSize,
cancellationToken);
// 2. 无数据直接返回
if (bills.Count == 0)
{
return new PagedResult<BillDto>([], request.PageNumber, request.PageSize, total);
}
// 3. 批量查询租户信息
var tenantIds = bills.Select(b => b.TenantId).Distinct().ToArray();
var tenants = await tenantRepository.FindByIdsAsync(tenantIds, cancellationToken);
var tenantDict = tenants.ToDictionary(t => t.Id, t => t.Name);
// 4. 映射 DTO
var result = bills.Select(b => b.ToDto(tenantDict.GetValueOrDefault(b.TenantId))).ToList();
// 5. 返回分页结果
return new PagedResult<BillDto>(result, request.PageNumber, request.PageSize, total);
}
}

View File

@@ -0,0 +1,28 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 获取租户支付记录查询处理器。
/// </summary>
public sealed class GetTenantPaymentsQueryHandler(ITenantPaymentRepository paymentRepository)
: IRequestHandler<GetTenantPaymentsQuery, List<PaymentDto>>
{
/// <summary>
/// 处理获取支付记录请求。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录列表。</returns>
public async Task<List<PaymentDto>> Handle(GetTenantPaymentsQuery request, CancellationToken cancellationToken)
{
// 1. 查询支付记录
var payments = await paymentRepository.GetByBillingIdAsync(request.BillId, cancellationToken);
// 2. 映射并返回 DTO
return payments.Select(p => p.ToDto()).ToList();
}
}

View File

@@ -0,0 +1,66 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 记录支付处理器。
/// </summary>
public sealed class RecordPaymentCommandHandler(
ITenantBillingRepository billingRepository,
ITenantPaymentRepository paymentRepository)
: IRequestHandler<RecordPaymentCommand, PaymentDto>
{
/// <summary>
/// 处理记录支付请求。
/// </summary>
/// <param name="request">记录支付命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付 DTO。</returns>
public async Task<PaymentDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
{
// 1. 查询账单
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
if (bill is null)
{
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
}
// 2. 构建支付记录
var payment = new TenantPayment
{
TenantId = bill.TenantId,
BillingStatementId = request.BillId,
Amount = request.Amount,
Method = request.Method,
Status = PaymentStatus.Success,
TransactionNo = request.TransactionNo,
ProofUrl = request.ProofUrl,
PaidAt = DateTime.UtcNow,
Notes = request.Notes
};
// 3. 更新账单已付金额
bill.AmountPaid += request.Amount;
// 4. 如果已付金额 >= 应付金额,标记为已支付
if (bill.AmountPaid >= bill.AmountDue)
{
bill.Status = TenantBillingStatus.Paid;
}
// 5. 持久化变更
await paymentRepository.AddAsync(payment, cancellationToken);
await billingRepository.UpdateAsync(bill, cancellationToken);
await paymentRepository.SaveChangesAsync(cancellationToken);
// 6. 返回 DTO
return payment.ToDto();
}
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 更新账单状态处理器。
/// </summary>
public sealed class UpdateBillStatusCommandHandler(
ITenantBillingRepository billingRepository,
ITenantRepository tenantRepository)
: IRequestHandler<UpdateBillStatusCommand, BillDto?>
{
/// <summary>
/// 处理更新账单状态请求。
/// </summary>
/// <param name="request">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单 DTO 或 null。</returns>
public async Task<BillDto?> Handle(UpdateBillStatusCommand request, CancellationToken cancellationToken)
{
// 1. 查询账单
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
if (bill is null)
{
return null;
}
// 2. 更新状态
bill.Status = request.Status;
if (!string.IsNullOrWhiteSpace(request.Notes))
{
bill.LineItemsJson = request.Notes;
}
// 3. 持久化变更
await billingRepository.UpdateAsync(bill, cancellationToken);
await billingRepository.SaveChangesAsync(cancellationToken);
// 4. 查询租户名称
var tenant = await tenantRepository.FindByIdAsync(bill.TenantId, cancellationToken);
// 5. 返回 DTO
return bill.ToDto(tenant?.Name);
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 获取账单详情查询。
/// </summary>
public sealed record GetBillDetailQuery : IRequest<BillDetailDto?>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillId { get; init; }
}

View File

@@ -0,0 +1,47 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 获取账单列表查询。
/// </summary>
public sealed record GetBillListQuery : IRequest<PagedResult<BillDto>>
{
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int PageNumber { get; init; } = 1;
/// <summary>
/// 页大小。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 租户 ID 筛选(可选)。
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 状态筛选(可选)。
/// </summary>
public TenantBillingStatus? Status { get; init; }
/// <summary>
/// 开始日期筛选(可选)。
/// </summary>
public DateTime? StartDate { get; init; }
/// <summary>
/// 结束日期筛选(可选)。
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// 搜索关键词(账单号或租户名)。
/// </summary>
public string? Keyword { get; init; }
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 获取租户支付记录查询。
/// </summary>
public sealed record GetTenantPaymentsQuery : IRequest<List<PaymentDto>>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillId { get; init; }
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
/// <summary>
/// 创建配额包命令。
/// </summary>
public sealed record CreateQuotaPackageCommand : IRequest<QuotaPackageDto>
{
/// <summary>
/// 配额包名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 配额数值。
/// </summary>
public decimal QuotaValue { get; init; }
/// <summary>
/// 价格。
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// 是否上架。
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; init; } = 0;
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; init; }
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
/// <summary>
/// 删除配额包命令。
/// </summary>
public sealed record DeleteQuotaPackageCommand : IRequest<bool>
{
/// <summary>
/// 配额包 ID。
/// </summary>
public long QuotaPackageId { get; init; }
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
/// <summary>
/// 为租户购买配额包命令。
/// </summary>
public sealed record PurchaseQuotaPackageCommand : IRequest<TenantQuotaPurchaseDto>
{
/// <summary>
/// 租户 ID。
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 配额包 ID。
/// </summary>
public long QuotaPackageId { get; init; }
/// <summary>
/// 过期时间(可选)。
/// </summary>
public DateTime? ExpiredAt { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,51 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
/// <summary>
/// 更新配额包命令。
/// </summary>
public sealed record UpdateQuotaPackageCommand : IRequest<QuotaPackageDto?>
{
/// <summary>
/// 配额包 ID。
/// </summary>
public long QuotaPackageId { get; init; }
/// <summary>
/// 配额包名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 配额数值。
/// </summary>
public decimal QuotaValue { get; init; }
/// <summary>
/// 价格。
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// 是否上架。
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; init; } = 0;
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; init; }
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
/// <summary>
/// 更新配额包状态命令(上架/下架)。
/// </summary>
public sealed record UpdateQuotaPackageStatusCommand : IRequest<bool>
{
/// <summary>
/// 配额包 ID。
/// </summary>
public long QuotaPackageId { get; init; }
/// <summary>
/// 是否上架。
/// </summary>
public bool IsActive { get; init; }
}

View File

@@ -0,0 +1,62 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
/// <summary>
/// 配额包 DTO。
/// </summary>
public sealed record QuotaPackageDto
{
/// <summary>
/// 配额包 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 配额包名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 配额数值。
/// </summary>
public decimal QuotaValue { get; init; }
/// <summary>
/// 价格。
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// 是否上架。
/// </summary>
public bool IsActive { get; init; }
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; init; }
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
/// <summary>
/// 配额包列表 DTO。
/// </summary>
public sealed record QuotaPackageListDto
{
/// <summary>
/// 配额包 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 配额包名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 配额数值。
/// </summary>
public decimal QuotaValue { get; init; }
/// <summary>
/// 价格。
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// 是否上架。
/// </summary>
public bool IsActive { get; init; }
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; init; }
}

View File

@@ -0,0 +1,64 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
/// <summary>
/// 租户配额购买记录 DTO。
/// </summary>
public sealed record TenantQuotaPurchaseDto
{
/// <summary>
/// 购买记录 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 配额包 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long QuotaPackageId { get; init; }
/// <summary>
/// 配额包名称。
/// </summary>
public string QuotaPackageName { get; init; } = string.Empty;
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 购买时的配额值。
/// </summary>
public decimal QuotaValue { get; init; }
/// <summary>
/// 购买价格。
/// </summary>
public decimal Price { get; init; }
/// <summary>
/// 购买时间。
/// </summary>
public DateTime PurchasedAt { get; init; }
/// <summary>
/// 过期时间(可选)。
/// </summary>
public DateTime? ExpiredAt { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
/// <summary>
/// 租户配额使用情况 DTO。
/// </summary>
public sealed record TenantQuotaUsageDto
{
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 配额上限。
/// </summary>
public decimal LimitValue { get; init; }
/// <summary>
/// 已使用值。
/// </summary>
public decimal UsedValue { get; init; }
/// <summary>
/// 剩余值。
/// </summary>
public decimal RemainingValue { get; init; }
/// <summary>
/// 配额刷新周期。
/// </summary>
public string? ResetCycle { get; init; }
/// <summary>
/// 最近一次重置时间。
/// </summary>
public DateTime? LastResetAt { get; init; }
}

View File

@@ -0,0 +1,54 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
/// <summary>
/// 创建配额包命令处理器。
/// </summary>
public sealed class CreateQuotaPackageCommandHandler(
IQuotaPackageRepository quotaPackageRepository,
IIdGenerator idGenerator)
: IRequestHandler<CreateQuotaPackageCommand, QuotaPackageDto>
{
/// <inheritdoc />
public async Task<QuotaPackageDto> Handle(CreateQuotaPackageCommand request, CancellationToken cancellationToken)
{
// 1. 创建配额包实体
var quotaPackage = new QuotaPackage
{
Id = idGenerator.NextId(),
Name = request.Name,
QuotaType = request.QuotaType,
QuotaValue = request.QuotaValue,
Price = request.Price,
IsActive = request.IsActive,
SortOrder = request.SortOrder,
Description = request.Description,
CreatedAt = DateTime.UtcNow
};
// 2. 保存到数据库
await quotaPackageRepository.AddAsync(quotaPackage, cancellationToken);
await quotaPackageRepository.SaveChangesAsync(cancellationToken);
// 3. 返回 DTO
return new QuotaPackageDto
{
Id = quotaPackage.Id,
Name = quotaPackage.Name,
QuotaType = quotaPackage.QuotaType,
QuotaValue = quotaPackage.QuotaValue,
Price = quotaPackage.Price,
IsActive = quotaPackage.IsActive,
SortOrder = quotaPackage.SortOrder,
Description = quotaPackage.Description,
CreatedAt = quotaPackage.CreatedAt,
UpdatedAt = quotaPackage.UpdatedAt
};
}
}

View File

@@ -0,0 +1,29 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
/// <summary>
/// 删除配额包命令处理器。
/// </summary>
public sealed class DeleteQuotaPackageCommandHandler(IQuotaPackageRepository quotaPackageRepository)
: IRequestHandler<DeleteQuotaPackageCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(DeleteQuotaPackageCommand request, CancellationToken cancellationToken)
{
// 1. 软删除配额包
var deleted = await quotaPackageRepository.SoftDeleteAsync(request.QuotaPackageId, cancellationToken);
if (!deleted)
{
return false;
}
// 2. 保存变更
await quotaPackageRepository.SaveChangesAsync(cancellationToken);
return true;
}
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Application.App.QuotaPackages.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
/// <summary>
/// 获取配额包列表查询处理器。
/// </summary>
public sealed class GetQuotaPackageListQueryHandler(IQuotaPackageRepository quotaPackageRepository)
: IRequestHandler<GetQuotaPackageListQuery, PagedResult<QuotaPackageListDto>>
{
/// <inheritdoc />
public async Task<PagedResult<QuotaPackageListDto>> Handle(GetQuotaPackageListQuery request, CancellationToken cancellationToken)
{
// 1. 分页查询
var (items, total) = await quotaPackageRepository.SearchPagedAsync(
request.QuotaType,
request.IsActive,
request.Page,
request.PageSize,
cancellationToken);
// 2. 映射为 DTO
var dtos = items.Select(x => new QuotaPackageListDto
{
Id = x.Id,
Name = x.Name,
QuotaType = x.QuotaType,
QuotaValue = x.QuotaValue,
Price = x.Price,
IsActive = x.IsActive,
SortOrder = x.SortOrder
}).ToList();
// 3. 返回分页结果
return new PagedResult<QuotaPackageListDto>(dtos, request.Page, request.PageSize, total);
}
}

View File

@@ -0,0 +1,43 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Application.App.QuotaPackages.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
/// <summary>
/// 获取租户配额购买记录查询处理器。
/// </summary>
public sealed class GetTenantQuotaPurchasesQueryHandler(IQuotaPackageRepository quotaPackageRepository)
: IRequestHandler<GetTenantQuotaPurchasesQuery, PagedResult<TenantQuotaPurchaseDto>>
{
/// <inheritdoc />
public async Task<PagedResult<TenantQuotaPurchaseDto>> Handle(GetTenantQuotaPurchasesQuery request, CancellationToken cancellationToken)
{
// 1. 分页查询购买记录
var (items, total) = await quotaPackageRepository.GetPurchasesPagedAsync(
request.TenantId,
request.Page,
request.PageSize,
cancellationToken);
// 2. 映射为 DTO
var dtos = items.Select(x => new TenantQuotaPurchaseDto
{
Id = x.Purchase.Id,
TenantId = x.Purchase.TenantId,
QuotaPackageId = x.Purchase.QuotaPackageId,
QuotaPackageName = x.Package.Name,
QuotaType = x.Package.QuotaType,
QuotaValue = x.Purchase.QuotaValue,
Price = x.Purchase.Price,
PurchasedAt = x.Purchase.PurchasedAt,
ExpiredAt = x.Purchase.ExpiredAt,
Notes = x.Purchase.Notes
}).ToList();
// 3. 返回分页结果
return new PagedResult<TenantQuotaPurchaseDto>(dtos, request.Page, request.PageSize, total);
}
}

View File

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

View File

@@ -0,0 +1,72 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
/// <summary>
/// 购买配额包命令处理器。
/// </summary>
public sealed class PurchaseQuotaPackageCommandHandler(
IQuotaPackageRepository quotaPackageRepository,
IIdGenerator idGenerator)
: IRequestHandler<PurchaseQuotaPackageCommand, TenantQuotaPurchaseDto>
{
/// <inheritdoc />
public async Task<TenantQuotaPurchaseDto> Handle(PurchaseQuotaPackageCommand request, CancellationToken cancellationToken)
{
// 1. 查找配额包
var quotaPackage = await quotaPackageRepository.FindByIdAsync(request.QuotaPackageId, cancellationToken);
if (quotaPackage == null)
{
throw new InvalidOperationException("配额包不存在");
}
// 2. 创建购买记录
var purchase = new TenantQuotaPackagePurchase
{
Id = idGenerator.NextId(),
TenantId = request.TenantId,
QuotaPackageId = request.QuotaPackageId,
QuotaValue = quotaPackage.QuotaValue,
Price = quotaPackage.Price,
PurchasedAt = DateTime.UtcNow,
ExpiredAt = request.ExpiredAt,
Notes = request.Notes,
CreatedAt = DateTime.UtcNow
};
// 3. 保存购买记录
await quotaPackageRepository.AddPurchaseAsync(purchase, cancellationToken);
// 4. 更新租户配额(根据配额类型更新对应配额)
var quotaUsage = await quotaPackageRepository.FindUsageAsync(request.TenantId, quotaPackage.QuotaType, cancellationToken);
if (quotaUsage != null)
{
quotaUsage.LimitValue += quotaPackage.QuotaValue;
await quotaPackageRepository.UpdateUsageAsync(quotaUsage, cancellationToken);
}
await quotaPackageRepository.SaveChangesAsync(cancellationToken);
// 5. 返回 DTO
return new TenantQuotaPurchaseDto
{
Id = purchase.Id,
TenantId = purchase.TenantId,
QuotaPackageId = purchase.QuotaPackageId,
QuotaPackageName = quotaPackage.Name,
QuotaType = quotaPackage.QuotaType,
QuotaValue = purchase.QuotaValue,
Price = purchase.Price,
PurchasedAt = purchase.PurchasedAt,
ExpiredAt = purchase.ExpiredAt,
Notes = purchase.Notes
};
}
}

View File

@@ -0,0 +1,54 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
/// <summary>
/// 更新配额包命令处理器。
/// </summary>
public sealed class UpdateQuotaPackageCommandHandler(IQuotaPackageRepository quotaPackageRepository)
: IRequestHandler<UpdateQuotaPackageCommand, QuotaPackageDto?>
{
/// <inheritdoc />
public async Task<QuotaPackageDto?> Handle(UpdateQuotaPackageCommand request, CancellationToken cancellationToken)
{
// 1. 查找配额包
var quotaPackage = await quotaPackageRepository.FindByIdAsync(request.QuotaPackageId, cancellationToken);
if (quotaPackage == null)
{
return null;
}
// 2. 更新配额包
quotaPackage.Name = request.Name;
quotaPackage.QuotaType = request.QuotaType;
quotaPackage.QuotaValue = request.QuotaValue;
quotaPackage.Price = request.Price;
quotaPackage.IsActive = request.IsActive;
quotaPackage.SortOrder = request.SortOrder;
quotaPackage.Description = request.Description;
quotaPackage.UpdatedAt = DateTime.UtcNow;
// 3. 保存到数据库
await quotaPackageRepository.UpdateAsync(quotaPackage, cancellationToken);
await quotaPackageRepository.SaveChangesAsync(cancellationToken);
// 4. 返回 DTO
return new QuotaPackageDto
{
Id = quotaPackage.Id,
Name = quotaPackage.Name,
QuotaType = quotaPackage.QuotaType,
QuotaValue = quotaPackage.QuotaValue,
Price = quotaPackage.Price,
IsActive = quotaPackage.IsActive,
SortOrder = quotaPackage.SortOrder,
Description = quotaPackage.Description,
CreatedAt = quotaPackage.CreatedAt,
UpdatedAt = quotaPackage.UpdatedAt
};
}
}

View File

@@ -0,0 +1,34 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
/// <summary>
/// 更新配额包状态命令处理器。
/// </summary>
public sealed class UpdateQuotaPackageStatusCommandHandler(IQuotaPackageRepository quotaPackageRepository)
: IRequestHandler<UpdateQuotaPackageStatusCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(UpdateQuotaPackageStatusCommand request, CancellationToken cancellationToken)
{
// 1. 查找配额包
var quotaPackage = await quotaPackageRepository.FindByIdAsync(request.QuotaPackageId, cancellationToken);
if (quotaPackage == null)
{
return false;
}
// 2. 更新状态
quotaPackage.IsActive = request.IsActive;
quotaPackage.UpdatedAt = DateTime.UtcNow;
// 3. 保存到数据库
await quotaPackageRepository.UpdateAsync(quotaPackage, cancellationToken);
await quotaPackageRepository.SaveChangesAsync(cancellationToken);
return true;
}
}

View File

@@ -0,0 +1,32 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
/// <summary>
/// 获取配额包列表查询。
/// </summary>
public sealed record GetQuotaPackageListQuery : IRequest<PagedResult<QuotaPackageListDto>>
{
/// <summary>
/// 配额类型(可选筛选)。
/// </summary>
public TenantQuotaType? QuotaType { get; init; }
/// <summary>
/// 状态(可选筛选)。
/// </summary>
public bool? IsActive { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页大小。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
/// <summary>
/// 获取租户配额购买记录查询。
/// </summary>
public sealed record GetTenantQuotaPurchasesQuery : IRequest<PagedResult<TenantQuotaPurchaseDto>>
{
/// <summary>
/// 租户 ID。
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页大小。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,21 @@
using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
/// <summary>
/// 获取租户配额使用情况查询。
/// </summary>
public sealed record GetTenantQuotaUsageQuery : IRequest<IReadOnlyList<TenantQuotaUsageDto>>
{
/// <summary>
/// 租户 ID。
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 配额类型(可选筛选)。
/// </summary>
public TenantQuotaType? QuotaType { get; init; }
}

View File

@@ -0,0 +1,49 @@
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Statistics.Dto;
/// <summary>
/// 即将到期的订阅项。
/// </summary>
public record ExpiringSubscriptionDto
{
/// <summary>
/// 订阅ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 租户ID。
/// </summary>
public string TenantId { get; init; } = string.Empty;
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 套餐名称。
/// </summary>
public string PackageName { get; init; } = string.Empty;
/// <summary>
/// 订阅状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 到期时间。
/// </summary>
public DateTime EffectiveTo { get; init; }
/// <summary>
/// 剩余天数。
/// </summary>
public int DaysRemaining { get; init; }
/// <summary>
/// 是否开启自动续费。
/// </summary>
public bool AutoRenew { get; init; }
}

View File

@@ -0,0 +1,50 @@
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Statistics.Dto;
/// <summary>
/// 配额使用排行。
/// </summary>
public record QuotaUsageRankingDto
{
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 排行列表。
/// </summary>
public IReadOnlyList<QuotaUsageRankItem> Rankings { get; init; } = Array.Empty<QuotaUsageRankItem>();
}
/// <summary>
/// 配额使用排行项。
/// </summary>
public record QuotaUsageRankItem
{
/// <summary>
/// 租户ID。
/// </summary>
public string TenantId { get; init; } = string.Empty;
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 已使用值。
/// </summary>
public decimal UsedValue { get; init; }
/// <summary>
/// 限制值。
/// </summary>
public decimal LimitValue { get; init; }
/// <summary>
/// 使用百分比。
/// </summary>
public decimal UsagePercentage { get; init; }
}

View File

@@ -0,0 +1,53 @@
namespace TakeoutSaaS.Application.App.Statistics.Dto;
/// <summary>
/// 收入统计。
/// </summary>
public record RevenueStatisticsDto
{
/// <summary>
/// 总收入。
/// </summary>
public decimal TotalRevenue { get; init; }
/// <summary>
/// 本月收入。
/// </summary>
public decimal MonthlyRevenue { get; init; }
/// <summary>
/// 本季度收入。
/// </summary>
public decimal QuarterlyRevenue { get; init; }
/// <summary>
/// 月度收入明细。
/// </summary>
public IReadOnlyList<MonthlyRevenueItem> MonthlyDetails { get; init; } = Array.Empty<MonthlyRevenueItem>();
}
/// <summary>
/// 月度收入项。
/// </summary>
public record MonthlyRevenueItem
{
/// <summary>
/// 年份。
/// </summary>
public int Year { get; init; }
/// <summary>
/// 月份。
/// </summary>
public int Month { get; init; }
/// <summary>
/// 收入金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 账单数量。
/// </summary>
public int BillCount { get; init; }
}

View File

@@ -0,0 +1,42 @@
namespace TakeoutSaaS.Application.App.Statistics.Dto;
/// <summary>
/// 订阅概览。
/// </summary>
public record SubscriptionOverviewDto
{
/// <summary>
/// 活跃订阅总数。
/// </summary>
public int TotalActive { get; init; }
/// <summary>
/// 7天内到期数量。
/// </summary>
public int ExpiringIn7Days { get; init; }
/// <summary>
/// 3天内到期数量。
/// </summary>
public int ExpiringIn3Days { get; init; }
/// <summary>
/// 1天内到期数量。
/// </summary>
public int ExpiringIn1Day { get; init; }
/// <summary>
/// 已过期数量。
/// </summary>
public int Expired { get; init; }
/// <summary>
/// 待激活数量。
/// </summary>
public int Pending { get; init; }
/// <summary>
/// 已暂停数量。
/// </summary>
public int Suspended { get; init; }
}

View File

@@ -0,0 +1,38 @@
using MediatR;
using TakeoutSaaS.Application.App.Statistics.Dto;
using TakeoutSaaS.Application.App.Statistics.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Statistics.Handlers;
/// <summary>
/// 获取即将到期的订阅列表处理器。
/// </summary>
public sealed class GetExpiringSubscriptionsQueryHandler(IStatisticsRepository statisticsRepository)
: IRequestHandler<GetExpiringSubscriptionsQuery, IReadOnlyList<ExpiringSubscriptionDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<ExpiringSubscriptionDto>> Handle(GetExpiringSubscriptionsQuery request, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
// 查询即将到期的订阅
var items = await statisticsRepository.GetExpiringSubscriptionsAsync(
request.DaysAhead,
request.OnlyWithoutAutoRenew,
cancellationToken);
// 映射为 DTO
return items.Select(x => new ExpiringSubscriptionDto
{
Id = x.Subscription.Id,
TenantId = x.Subscription.TenantId.ToString(),
TenantName = x.TenantName,
PackageName = x.PackageName,
Status = x.Subscription.Status,
EffectiveTo = x.Subscription.EffectiveTo,
DaysRemaining = (int)(x.Subscription.EffectiveTo - now).TotalDays,
AutoRenew = x.Subscription.AutoRenew
}).ToList();
}
}

View File

@@ -0,0 +1,39 @@
using MediatR;
using TakeoutSaaS.Application.App.Statistics.Dto;
using TakeoutSaaS.Application.App.Statistics.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Statistics.Handlers;
/// <summary>
/// 获取配额使用排行处理器。
/// </summary>
public sealed class GetQuotaUsageRankingQueryHandler(IStatisticsRepository statisticsRepository)
: IRequestHandler<GetQuotaUsageRankingQuery, QuotaUsageRankingDto>
{
/// <inheritdoc />
public async Task<QuotaUsageRankingDto> Handle(GetQuotaUsageRankingQuery request, CancellationToken cancellationToken)
{
// 查询指定类型的配额使用排行
var items = await statisticsRepository.GetQuotaUsageRankingAsync(
request.QuotaType,
request.TopN,
cancellationToken);
// 映射为 DTO
var rankings = items.Select(x => new QuotaUsageRankItem
{
TenantId = x.TenantId.ToString(),
TenantName = x.TenantName,
UsedValue = x.UsedValue,
LimitValue = x.LimitValue,
UsagePercentage = x.UsagePercentage
}).ToList();
return new QuotaUsageRankingDto
{
QuotaType = request.QuotaType,
Rankings = rankings
};
}
}

View File

@@ -0,0 +1,71 @@
using MediatR;
using TakeoutSaaS.Application.App.Statistics.Dto;
using TakeoutSaaS.Application.App.Statistics.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Statistics.Handlers;
/// <summary>
/// 获取收入统计处理器。
/// </summary>
public sealed class GetRevenueStatisticsQueryHandler(IStatisticsRepository statisticsRepository)
: IRequestHandler<GetRevenueStatisticsQuery, RevenueStatisticsDto>
{
/// <inheritdoc />
public async Task<RevenueStatisticsDto> Handle(GetRevenueStatisticsQuery request, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var currentMonth = new DateTime(now.Year, now.Month, 1);
var currentQuarter = GetQuarterStart(now);
var startMonth = currentMonth.AddMonths(-request.MonthsCount + 1);
// 查询所有已付款的账单
var bills = await statisticsRepository.GetPaidBillsAsync(cancellationToken);
// 总收入
var totalRevenue = bills.Sum(b => b.AmountPaid);
// 本月收入
var monthlyRevenue = bills
.Where(b => b.PeriodStart >= currentMonth)
.Sum(b => b.AmountPaid);
// 本季度收入
var quarterlyRevenue = bills
.Where(b => b.PeriodStart >= currentQuarter)
.Sum(b => b.AmountPaid);
// 月度收入明细
var monthlyDetails = bills
.Where(b => b.PeriodStart >= startMonth)
.GroupBy(b => new { b.PeriodStart.Year, b.PeriodStart.Month })
.Select(g => new MonthlyRevenueItem
{
Year = g.Key.Year,
Month = g.Key.Month,
Amount = g.Sum(b => b.AmountPaid),
BillCount = g.Count()
})
.OrderBy(m => m.Year)
.ThenBy(m => m.Month)
.ToList();
return new RevenueStatisticsDto
{
TotalRevenue = totalRevenue,
MonthlyRevenue = monthlyRevenue,
QuarterlyRevenue = quarterlyRevenue,
MonthlyDetails = monthlyDetails
};
}
/// <summary>
/// 获取季度开始时间。
/// </summary>
private static DateTime GetQuarterStart(DateTime date)
{
var quarter = (date.Month - 1) / 3;
var quarterStartMonth = quarter * 3 + 1;
return new DateTime(date.Year, quarterStartMonth, 1);
}
}

View File

@@ -0,0 +1,49 @@
using MediatR;
using TakeoutSaaS.Application.App.Statistics.Dto;
using TakeoutSaaS.Application.App.Statistics.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Statistics.Handlers;
/// <summary>
/// 获取订阅概览统计处理器。
/// </summary>
public sealed class GetSubscriptionOverviewQueryHandler(IStatisticsRepository statisticsRepository)
: IRequestHandler<GetSubscriptionOverviewQuery, SubscriptionOverviewDto>
{
/// <inheritdoc />
public async Task<SubscriptionOverviewDto> Handle(GetSubscriptionOverviewQuery request, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var in7Days = now.AddDays(7);
var in3Days = now.AddDays(3);
var in1Day = now.AddDays(1);
// 查询所有订阅
var subscriptions = await statisticsRepository.GetAllSubscriptionsAsync(cancellationToken);
// 统计各项数据
var overview = new SubscriptionOverviewDto
{
TotalActive = subscriptions.Count(s => s.Status == SubscriptionStatus.Active),
ExpiringIn7Days = subscriptions.Count(s =>
s.Status == SubscriptionStatus.Active &&
s.EffectiveTo >= now &&
s.EffectiveTo <= in7Days),
ExpiringIn3Days = subscriptions.Count(s =>
s.Status == SubscriptionStatus.Active &&
s.EffectiveTo >= now &&
s.EffectiveTo <= in3Days),
ExpiringIn1Day = subscriptions.Count(s =>
s.Status == SubscriptionStatus.Active &&
s.EffectiveTo >= now &&
s.EffectiveTo <= in1Day),
Expired = subscriptions.Count(s => s.Status == SubscriptionStatus.GracePeriod),
Pending = subscriptions.Count(s => s.Status == SubscriptionStatus.Pending),
Suspended = subscriptions.Count(s => s.Status == SubscriptionStatus.Suspended)
};
return overview;
}
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Statistics.Dto;
namespace TakeoutSaaS.Application.App.Statistics.Queries;
/// <summary>
/// 获取即将到期的订阅列表。
/// </summary>
public sealed record GetExpiringSubscriptionsQuery : IRequest<IReadOnlyList<ExpiringSubscriptionDto>>
{
/// <summary>
/// 筛选天数默认7天内到期。
/// </summary>
public int DaysAhead { get; init; } = 7;
/// <summary>
/// 是否只返回未开启自动续费的订阅。
/// </summary>
public bool OnlyWithoutAutoRenew { get; init; } = false;
}

View File

@@ -0,0 +1,21 @@
using MediatR;
using TakeoutSaaS.Application.App.Statistics.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Statistics.Queries;
/// <summary>
/// 获取配额使用排行。
/// </summary>
public sealed record GetQuotaUsageRankingQuery : IRequest<QuotaUsageRankingDto>
{
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 返回前N条记录默认前10。
/// </summary>
public int TopN { get; init; } = 10;
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Statistics.Dto;
namespace TakeoutSaaS.Application.App.Statistics.Queries;
/// <summary>
/// 获取收入统计。
/// </summary>
public sealed record GetRevenueStatisticsQuery : IRequest<RevenueStatisticsDto>
{
/// <summary>
/// 统计月份数量默认12个月。
/// </summary>
public int MonthsCount { get; init; } = 12;
}

View File

@@ -0,0 +1,11 @@
using MediatR;
using TakeoutSaaS.Application.App.Statistics.Dto;
namespace TakeoutSaaS.Application.App.Statistics.Queries;
/// <summary>
/// 获取订阅概览统计。
/// </summary>
public sealed record GetSubscriptionOverviewQuery : IRequest<SubscriptionOverviewDto>
{
}

View File

@@ -0,0 +1,72 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 批量延期订阅命令。
/// </summary>
public sealed record BatchExtendSubscriptionsCommand : IRequest<BatchExtendResult>
{
/// <summary>
/// 订阅ID列表。
/// </summary>
[Required]
[MinLength(1, ErrorMessage = "至少需要选择一个订阅")]
public IReadOnlyList<long> SubscriptionIds { get; init; } = Array.Empty<long>();
/// <summary>
/// 延期时长(天)。
/// </summary>
[Range(1, 3650, ErrorMessage = "延期天数必须在1-3650天之间")]
public int? DurationDays { get; init; }
/// <summary>
/// 延期时长(月)。
/// </summary>
[Range(1, 120, ErrorMessage = "延期月数必须在1-120月之间")]
public int? DurationMonths { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
[MaxLength(500)]
public string? Notes { get; init; }
}
/// <summary>
/// 批量延期结果。
/// </summary>
public record BatchExtendResult
{
/// <summary>
/// 成功数量。
/// </summary>
public int SuccessCount { get; init; }
/// <summary>
/// 失败数量。
/// </summary>
public int FailureCount { get; init; }
/// <summary>
/// 失败详情列表。
/// </summary>
public IReadOnlyList<BatchFailureItem> Failures { get; init; } = Array.Empty<BatchFailureItem>();
}
/// <summary>
/// 批量操作失败项。
/// </summary>
public record BatchFailureItem
{
/// <summary>
/// 订阅ID。
/// </summary>
public long SubscriptionId { get; init; }
/// <summary>
/// 失败原因。
/// </summary>
public string Reason { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,45 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 批量发送续费提醒命令。
/// </summary>
public sealed record BatchSendReminderCommand : IRequest<BatchSendReminderResult>
{
/// <summary>
/// 订阅ID列表。
/// </summary>
[Required]
[MinLength(1, ErrorMessage = "至少需要选择一个订阅")]
public IReadOnlyList<long> SubscriptionIds { get; init; } = Array.Empty<long>();
/// <summary>
/// 提醒内容。
/// </summary>
[Required(ErrorMessage = "提醒内容不能为空")]
[MaxLength(1000, ErrorMessage = "提醒内容不能超过1000字符")]
public string ReminderContent { get; init; } = string.Empty;
}
/// <summary>
/// 批量发送提醒结果。
/// </summary>
public record BatchSendReminderResult
{
/// <summary>
/// 成功发送数量。
/// </summary>
public int SuccessCount { get; init; }
/// <summary>
/// 发送失败数量。
/// </summary>
public int FailureCount { get; init; }
/// <summary>
/// 失败详情列表。
/// </summary>
public IReadOnlyList<BatchFailureItem> Failures { get; init; } = Array.Empty<BatchFailureItem>();
}

View File

@@ -0,0 +1,34 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 变更套餐命令。
/// </summary>
public sealed record ChangeSubscriptionPlanCommand : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID从路由参数绑定
/// </summary>
[Required]
public long SubscriptionId { get; init; }
/// <summary>
/// 目标套餐 ID。
/// </summary>
[Required]
public long TargetPackageId { get; init; }
/// <summary>
/// 是否立即生效,否则在下周期生效。
/// </summary>
public bool Immediate { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
[MaxLength(500)]
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,29 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 延期订阅命令。
/// </summary>
public sealed record ExtendSubscriptionCommand : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID从路由参数绑定
/// </summary>
[Required]
public long SubscriptionId { get; init; }
/// <summary>
/// 延期时长(月)。
/// </summary>
[Range(1, 120)]
public int DurationMonths { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
[MaxLength(500)]
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,28 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 更新订阅基础信息命令。
/// </summary>
public sealed record UpdateSubscriptionCommand : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID从路由参数绑定
/// </summary>
[Required]
public long SubscriptionId { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool? AutoRenew { get; init; }
/// <summary>
/// 运营备注信息。
/// </summary>
[MaxLength(500)]
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
/// <summary>
/// 更新订阅状态命令。
/// </summary>
public sealed record UpdateSubscriptionStatusCommand : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID从路由参数绑定
/// </summary>
[Required]
public long SubscriptionId { get; init; }
/// <summary>
/// 目标状态。
/// </summary>
[Required]
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
[MaxLength(500)]
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
/// <summary>
/// 配额使用 DTO。
/// </summary>
public sealed record QuotaUsageDto
{
/// <summary>
/// 配额 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 配额上限。
/// </summary>
public decimal LimitValue { get; init; }
/// <summary>
/// 已使用量。
/// </summary>
public decimal UsedValue { get; init; }
/// <summary>
/// 使用率(百分比)。
/// </summary>
public decimal UsagePercentage => LimitValue > 0 ? Math.Round(UsedValue / LimitValue * 100, 2) : 0;
/// <summary>
/// 剩余额度。
/// </summary>
public decimal RemainingValue => Math.Max(0, LimitValue - UsedValue);
/// <summary>
/// 重置周期描述。
/// </summary>
public string? ResetCycle { get; init; }
/// <summary>
/// 最近一次重置时间。
/// </summary>
public DateTime? LastResetAt { get; init; }
}

View File

@@ -0,0 +1,106 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
/// <summary>
/// 订阅详情 DTO。
/// </summary>
public sealed record SubscriptionDetailDto
{
/// <summary>
/// 订阅 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 租户编码。
/// </summary>
public string TenantCode { get; init; } = string.Empty;
/// <summary>
/// 当前套餐 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantPackageId { get; init; }
/// <summary>
/// 当前套餐信息。
/// </summary>
public TenantPackageDto? Package { get; init; }
/// <summary>
/// 排期套餐 ID下周期生效
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long? ScheduledPackageId { get; init; }
/// <summary>
/// 排期套餐信息。
/// </summary>
public TenantPackageDto? ScheduledPackage { get; init; }
/// <summary>
/// 订阅状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 生效时间UTC
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间UTC
/// </summary>
public DateTime EffectiveTo { get; init; }
/// <summary>
/// 下次计费时间。
/// </summary>
public DateTime? NextBillingDate { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 配额使用情况列表。
/// </summary>
public IReadOnlyList<QuotaUsageDto> QuotaUsages { get; init; } = [];
/// <summary>
/// 订阅变更历史列表。
/// </summary>
public IReadOnlyList<SubscriptionHistoryDto> ChangeHistory { get; init; } = [];
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,80 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Subscriptions.Dto;
/// <summary>
/// 订阅变更历史 DTO。
/// </summary>
public sealed record SubscriptionHistoryDto
{
/// <summary>
/// 历史记录 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 订阅 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantSubscriptionId { get; init; }
/// <summary>
/// 原套餐 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long FromPackageId { get; init; }
/// <summary>
/// 原套餐名称。
/// </summary>
public string FromPackageName { get; init; } = string.Empty;
/// <summary>
/// 新套餐 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long ToPackageId { get; init; }
/// <summary>
/// 新套餐名称。
/// </summary>
public string ToPackageName { get; init; } = string.Empty;
/// <summary>
/// 变更类型。
/// </summary>
public SubscriptionChangeType ChangeType { get; init; }
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间。
/// </summary>
public DateTime EffectiveTo { get; init; }
/// <summary>
/// 相关费用。
/// </summary>
public decimal? Amount { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string? Currency { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

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

View File

@@ -0,0 +1,133 @@
using MediatR;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 批量延期订阅命令处理器。
/// </summary>
public sealed class BatchExtendSubscriptionsCommandHandler(
ISubscriptionRepository subscriptionRepository,
IIdGenerator idGenerator,
ILogger<BatchExtendSubscriptionsCommandHandler> logger)
: IRequestHandler<BatchExtendSubscriptionsCommand, BatchExtendResult>
{
/// <inheritdoc />
public async Task<BatchExtendResult> Handle(BatchExtendSubscriptionsCommand request, CancellationToken cancellationToken)
{
var successCount = 0;
var failures = new List<BatchFailureItem>();
// 验证参数
if (!request.DurationDays.HasValue && !request.DurationMonths.HasValue)
{
throw new InvalidOperationException("必须指定延期天数或延期月数");
}
// 计算延期时间
var extendDays = request.DurationDays ?? 0;
var extendMonths = request.DurationMonths ?? 0;
// 查询所有订阅
var subscriptions = await subscriptionRepository.FindByIdsAsync(request.SubscriptionIds, cancellationToken);
foreach (var subscriptionId in request.SubscriptionIds)
{
try
{
var subscription = subscriptions.FirstOrDefault(s => s.Id == subscriptionId);
if (subscription == null)
{
failures.Add(new BatchFailureItem
{
SubscriptionId = subscriptionId,
Reason = "订阅不存在"
});
continue;
}
// 记录原始到期时间
var originalEffectiveTo = subscription.EffectiveTo;
// 计算新的到期时间
var newEffectiveTo = subscription.EffectiveTo;
if (extendMonths > 0)
{
newEffectiveTo = newEffectiveTo.AddMonths(extendMonths);
}
if (extendDays > 0)
{
newEffectiveTo = newEffectiveTo.AddDays(extendDays);
}
subscription.EffectiveTo = newEffectiveTo;
// 更新备注
if (!string.IsNullOrWhiteSpace(request.Notes))
{
subscription.Notes = request.Notes;
}
// 记录变更历史
var history = new TenantSubscriptionHistory
{
Id = idGenerator.NextId(),
TenantId = subscription.TenantId,
TenantSubscriptionId = subscription.Id,
FromPackageId = subscription.TenantPackageId,
ToPackageId = subscription.TenantPackageId,
ChangeType = SubscriptionChangeType.Renew,
EffectiveFrom = originalEffectiveTo,
EffectiveTo = newEffectiveTo,
Amount = null,
Currency = null,
Notes = request.Notes ?? $"批量延期: {(extendMonths > 0 ? $"{extendMonths}" : "")}{(extendDays > 0 ? $"{extendDays}" : "")}"
};
await subscriptionRepository.AddHistoryAsync(history, cancellationToken);
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
successCount++;
}
catch (Exception ex)
{
logger.LogError(ex, "批量延期订阅失败: SubscriptionId={SubscriptionId}", subscriptionId);
failures.Add(new BatchFailureItem
{
SubscriptionId = subscriptionId,
Reason = $"处理失败: {ex.Message}"
});
}
}
// 记录操作日志
var operationLog = new OperationLog
{
Id = idGenerator.NextId(),
OperationType = "BatchExtend",
TargetType = "Subscription",
TargetIds = JsonSerializer.Serialize(request.SubscriptionIds),
Parameters = JsonSerializer.Serialize(new { request.DurationDays, request.DurationMonths, request.Notes }),
Result = JsonSerializer.Serialize(new { SuccessCount = successCount, FailureCount = failures.Count }),
Success = failures.Count == 0,
CreatedAt = DateTime.UtcNow
};
await subscriptionRepository.AddOperationLogAsync(operationLog, cancellationToken);
// 保存所有更改
await subscriptionRepository.SaveChangesAsync(cancellationToken);
return new BatchExtendResult
{
SuccessCount = successCount,
FailureCount = failures.Count,
Failures = failures
};
}
}

View File

@@ -0,0 +1,104 @@
using MediatR;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 批量发送续费提醒命令处理器。
/// </summary>
public sealed class BatchSendReminderCommandHandler(
ISubscriptionRepository subscriptionRepository,
IIdGenerator idGenerator,
ILogger<BatchSendReminderCommandHandler> logger)
: IRequestHandler<BatchSendReminderCommand, BatchSendReminderResult>
{
/// <inheritdoc />
public async Task<BatchSendReminderResult> Handle(BatchSendReminderCommand request, CancellationToken cancellationToken)
{
var successCount = 0;
var failures = new List<BatchFailureItem>();
// 查询所有订阅及租户信息
var subscriptions = await subscriptionRepository.FindByIdsWithTenantAsync(
request.SubscriptionIds,
cancellationToken);
foreach (var subscriptionId in request.SubscriptionIds)
{
try
{
var item = subscriptions.FirstOrDefault(s => s.Subscription.Id == subscriptionId);
if (item == null)
{
failures.Add(new BatchFailureItem
{
SubscriptionId = subscriptionId,
Reason = "订阅不存在"
});
continue;
}
// 创建通知记录
var notification = new TenantNotification
{
Id = idGenerator.NextId(),
TenantId = item.Subscription.TenantId,
Title = "续费提醒",
Message = request.ReminderContent,
Severity = TenantNotificationSeverity.Warning,
Channel = TenantNotificationChannel.InApp,
SentAt = DateTime.UtcNow,
ReadAt = null,
CreatedAt = DateTime.UtcNow
};
await subscriptionRepository.AddNotificationAsync(notification, cancellationToken);
successCount++;
logger.LogInformation(
"发送续费提醒: SubscriptionId={SubscriptionId}, TenantId={TenantId}, TenantName={TenantName}",
subscriptionId, item.Subscription.TenantId, item.Tenant.Name);
}
catch (Exception ex)
{
logger.LogError(ex, "发送续费提醒失败: SubscriptionId={SubscriptionId}", subscriptionId);
failures.Add(new BatchFailureItem
{
SubscriptionId = subscriptionId,
Reason = $"发送失败: {ex.Message}"
});
}
}
// 记录操作日志
var operationLog = new OperationLog
{
Id = idGenerator.NextId(),
OperationType = "BatchRemind",
TargetType = "Subscription",
TargetIds = JsonSerializer.Serialize(request.SubscriptionIds),
Parameters = JsonSerializer.Serialize(new { request.ReminderContent }),
Result = JsonSerializer.Serialize(new { SuccessCount = successCount, FailureCount = failures.Count }),
Success = failures.Count == 0,
CreatedAt = DateTime.UtcNow
};
await subscriptionRepository.AddOperationLogAsync(operationLog, cancellationToken);
// 保存所有更改
await subscriptionRepository.SaveChangesAsync(cancellationToken);
return new BatchSendReminderResult
{
SuccessCount = successCount,
FailureCount = failures.Count,
Failures = failures
};
}
}

View File

@@ -0,0 +1,93 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 变更套餐命令处理器。
/// </summary>
public sealed class ChangeSubscriptionPlanCommandHandler(
ISubscriptionRepository subscriptionRepository,
IIdGenerator idGenerator,
IMediator mediator)
: IRequestHandler<ChangeSubscriptionPlanCommand, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(ChangeSubscriptionPlanCommand request, CancellationToken cancellationToken)
{
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
if (subscription == null)
{
return null;
}
// 2. 记录原套餐ID
var previousPackageId = subscription.TenantPackageId;
// 3. 根据是否立即生效更新订阅
if (request.Immediate)
{
// 立即生效:直接更新当前套餐
subscription.TenantPackageId = request.TargetPackageId;
subscription.ScheduledPackageId = null;
}
else
{
// 下周期生效:设置排期套餐
subscription.ScheduledPackageId = request.TargetPackageId;
}
// 4. 更新备注
if (!string.IsNullOrWhiteSpace(request.Notes))
{
subscription.Notes = request.Notes;
}
// 5. 判断变更类型(升级或降级)
var fromPackage = await subscriptionRepository.FindPackageByIdAsync(previousPackageId, cancellationToken);
var toPackage = await subscriptionRepository.FindPackageByIdAsync(request.TargetPackageId, cancellationToken);
var changeType = SubscriptionChangeType.Upgrade;
if (fromPackage != null && toPackage != null)
{
// 简单根据价格判断升降级
if (toPackage.MonthlyPrice < fromPackage.MonthlyPrice)
{
changeType = SubscriptionChangeType.Downgrade;
}
}
// 6. 记录变更历史
var history = new TenantSubscriptionHistory
{
Id = idGenerator.NextId(),
TenantId = subscription.TenantId,
TenantSubscriptionId = subscription.Id,
FromPackageId = previousPackageId,
ToPackageId = request.TargetPackageId,
ChangeType = changeType,
EffectiveFrom = request.Immediate ? DateTime.UtcNow : subscription.EffectiveTo,
EffectiveTo = subscription.EffectiveTo,
Amount = null,
Currency = null,
Notes = request.Notes ?? (request.Immediate ? "套餐立即变更" : "套餐排期变更")
};
await subscriptionRepository.AddHistoryAsync(history, cancellationToken);
// 7. 保存更改
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 8. 返回更新后的详情
return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
}
}

View File

@@ -0,0 +1,67 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 延期订阅命令处理器。
/// </summary>
public sealed class ExtendSubscriptionCommandHandler(
ISubscriptionRepository subscriptionRepository,
IIdGenerator idGenerator,
IMediator mediator)
: IRequestHandler<ExtendSubscriptionCommand, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(ExtendSubscriptionCommand request, CancellationToken cancellationToken)
{
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
if (subscription == null)
{
return null;
}
// 2. 计算新的到期时间(从当前到期时间延长)
var originalEffectiveTo = subscription.EffectiveTo;
subscription.EffectiveTo = subscription.EffectiveTo.AddMonths(request.DurationMonths);
// 3. 更新备注
if (!string.IsNullOrWhiteSpace(request.Notes))
{
subscription.Notes = request.Notes;
}
// 4. 记录变更历史(使用 Renew 类型表示延期)
var history = new TenantSubscriptionHistory
{
Id = idGenerator.NextId(),
TenantId = subscription.TenantId,
TenantSubscriptionId = subscription.Id,
FromPackageId = subscription.TenantPackageId,
ToPackageId = subscription.TenantPackageId,
ChangeType = SubscriptionChangeType.Renew,
EffectiveFrom = originalEffectiveTo,
EffectiveTo = subscription.EffectiveTo,
Amount = null,
Currency = null,
Notes = request.Notes ?? $"延期 {request.DurationMonths} 个月"
};
await subscriptionRepository.AddHistoryAsync(history, cancellationToken);
// 5. 保存更改
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 6. 返回更新后的详情
return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
}
}

View File

@@ -0,0 +1,84 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Application.App.Tenants;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 订阅详情查询处理器。
/// </summary>
public sealed class GetSubscriptionDetailQueryHandler(ISubscriptionRepository subscriptionRepository)
: IRequestHandler<GetSubscriptionDetailQuery, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(GetSubscriptionDetailQuery request, CancellationToken cancellationToken)
{
// 1. 查询订阅基础信息
var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken);
if (detail == null)
{
return null;
}
// 2. 查询配额使用情况
var quotaUsages = await subscriptionRepository.GetQuotaUsagesAsync(
detail.Subscription.TenantId,
cancellationToken);
var quotaUsageDtos = quotaUsages.Select(q => new QuotaUsageDto
{
Id = q.Id,
QuotaType = q.QuotaType,
LimitValue = q.LimitValue,
UsedValue = q.UsedValue,
ResetCycle = q.ResetCycle,
LastResetAt = q.LastResetAt
}).ToList();
// 3. 查询订阅变更历史(关联套餐信息)
var histories = await subscriptionRepository.GetHistoryAsync(request.SubscriptionId, cancellationToken);
var historyDtos = histories.Select(h => new SubscriptionHistoryDto
{
Id = h.History.Id,
TenantSubscriptionId = h.History.TenantSubscriptionId,
FromPackageId = h.History.FromPackageId,
FromPackageName = h.FromPackageName,
ToPackageId = h.History.ToPackageId,
ToPackageName = h.ToPackageName,
ChangeType = h.History.ChangeType,
EffectiveFrom = h.History.EffectiveFrom,
EffectiveTo = h.History.EffectiveTo,
Amount = h.History.Amount,
Currency = h.History.Currency,
Notes = h.History.Notes,
CreatedAt = h.History.CreatedAt
}).ToList();
// 4. 构建返回结果
return new SubscriptionDetailDto
{
Id = detail.Subscription.Id,
TenantId = detail.Subscription.TenantId,
TenantName = detail.TenantName,
TenantCode = detail.TenantCode,
TenantPackageId = detail.Subscription.TenantPackageId,
Package = detail.Package?.ToDto(),
ScheduledPackageId = detail.Subscription.ScheduledPackageId,
ScheduledPackage = detail.ScheduledPackage?.ToDto(),
Status = detail.Subscription.Status,
EffectiveFrom = detail.Subscription.EffectiveFrom,
EffectiveTo = detail.Subscription.EffectiveTo,
NextBillingDate = detail.Subscription.NextBillingDate,
AutoRenew = detail.Subscription.AutoRenew,
Notes = detail.Subscription.Notes,
QuotaUsages = quotaUsageDtos,
ChangeHistory = historyDtos,
CreatedAt = detail.Subscription.CreatedAt,
UpdatedAt = detail.Subscription.UpdatedAt
};
}
}

View File

@@ -0,0 +1,58 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 订阅分页查询处理器。
/// </summary>
public sealed class GetSubscriptionListQueryHandler(ISubscriptionRepository subscriptionRepository)
: IRequestHandler<GetSubscriptionListQuery, PagedResult<SubscriptionListDto>>
{
/// <inheritdoc />
public async Task<PagedResult<SubscriptionListDto>> Handle(GetSubscriptionListQuery request, CancellationToken cancellationToken)
{
// 1. 构建查询过滤条件
var filter = new SubscriptionSearchFilter
{
Status = request.Status,
TenantPackageId = request.TenantPackageId,
TenantId = request.TenantId,
TenantKeyword = request.TenantKeyword,
ExpiringWithinDays = request.ExpiringWithinDays,
AutoRenew = request.AutoRenew,
Page = request.Page,
PageSize = request.PageSize
};
// 2. 执行分页查询
var (items, total) = await subscriptionRepository.SearchPagedAsync(filter, cancellationToken);
// 3. 映射为 DTO
var dtos = items.Select(x => new SubscriptionListDto
{
Id = x.Subscription.Id,
TenantId = x.Subscription.TenantId,
TenantName = x.TenantName,
TenantCode = x.TenantCode,
TenantPackageId = x.Subscription.TenantPackageId,
PackageName = x.PackageName,
ScheduledPackageId = x.Subscription.ScheduledPackageId,
ScheduledPackageName = x.ScheduledPackageName,
Status = x.Subscription.Status,
EffectiveFrom = x.Subscription.EffectiveFrom,
EffectiveTo = x.Subscription.EffectiveTo,
NextBillingDate = x.Subscription.NextBillingDate,
AutoRenew = x.Subscription.AutoRenew,
Notes = x.Subscription.Notes,
CreatedAt = x.Subscription.CreatedAt,
UpdatedAt = x.Subscription.UpdatedAt
}).ToList();
// 4. 返回分页结果
return new PagedResult<SubscriptionListDto>(dtos, request.Page, request.PageSize, total);
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 更新订阅基础信息命令处理器。
/// </summary>
public sealed class UpdateSubscriptionCommandHandler(
ISubscriptionRepository subscriptionRepository,
IMediator mediator)
: IRequestHandler<UpdateSubscriptionCommand, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(UpdateSubscriptionCommand request, CancellationToken cancellationToken)
{
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
if (subscription == null)
{
return null;
}
// 2. 更新字段
if (request.AutoRenew.HasValue)
{
subscription.AutoRenew = request.AutoRenew.Value;
}
if (request.Notes != null)
{
subscription.Notes = request.Notes;
}
// 3. 保存更改
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 4. 返回更新后的详情
return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
}
}

View File

@@ -0,0 +1,44 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Commands;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Application.App.Subscriptions.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
/// <summary>
/// 更新订阅状态命令处理器。
/// </summary>
public sealed class UpdateSubscriptionStatusCommandHandler(
ISubscriptionRepository subscriptionRepository,
IMediator mediator)
: IRequestHandler<UpdateSubscriptionStatusCommand, SubscriptionDetailDto?>
{
/// <inheritdoc />
public async Task<SubscriptionDetailDto?> Handle(UpdateSubscriptionStatusCommand request, CancellationToken cancellationToken)
{
// 1. 查询订阅
var subscription = await subscriptionRepository.FindByIdAsync(request.SubscriptionId, cancellationToken);
if (subscription == null)
{
return null;
}
// 2. 更新状态
subscription.Status = request.Status;
// 3. 更新备注
if (!string.IsNullOrWhiteSpace(request.Notes))
{
subscription.Notes = request.Notes;
}
// 4. 保存更改
await subscriptionRepository.UpdateAsync(subscription, cancellationToken);
await subscriptionRepository.SaveChangesAsync(cancellationToken);
// 5. 返回更新后的详情
return await mediator.Send(new GetSubscriptionDetailQuery { SubscriptionId = subscription.Id }, cancellationToken);
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
namespace TakeoutSaaS.Application.App.Subscriptions.Queries;
/// <summary>
/// 查询订阅详情(含套餐信息、配额使用、变更历史)。
/// </summary>
public sealed record GetSubscriptionDetailQuery : IRequest<SubscriptionDetailDto?>
{
/// <summary>
/// 订阅 ID。
/// </summary>
public long SubscriptionId { get; init; }
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using TakeoutSaaS.Application.App.Subscriptions.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Subscriptions.Queries;
/// <summary>
/// 订阅分页查询。
/// </summary>
public sealed record GetSubscriptionListQuery : IRequest<PagedResult<SubscriptionListDto>>
{
/// <summary>
/// 订阅状态(精确匹配)。
/// </summary>
public SubscriptionStatus? Status { get; init; }
/// <summary>
/// 套餐 ID精确匹配
/// </summary>
public long? TenantPackageId { get; init; }
/// <summary>
/// 租户 ID精确匹配
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 租户关键词(名称或编码模糊匹配)。
/// </summary>
public string? TenantKeyword { get; init; }
/// <summary>
/// 到期时间筛选:未来 N 天内到期。
/// </summary>
public int? ExpiringWithinDays { get; init; }
/// <summary>
/// 是否自动续费筛选。
/// </summary>
public bool? AutoRenew { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页大小。
/// </summary>
public int PageSize { get; init; } = 20;
}