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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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