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

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Billings.Repositories;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories;
using TakeoutSaaS.Domain.Merchants.Repositories;
@@ -46,6 +47,9 @@ public static class AppServiceCollectionExtensions
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
services.AddScoped<ITenantRepository, EfTenantRepository>();
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
services.AddScoped<ISubscriptionRepository, EfSubscriptionRepository>();
services.AddScoped<IBillingRepository, EfBillingRepository>();
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
services.AddScoped<IOperationLogRepository, EfOperationLogRepository>();

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TakeoutSaaS.Domain.Analytics.Entities;
using TakeoutSaaS.Domain.Billings.Entities;
using TakeoutSaaS.Domain.Coupons.Entities;
using TakeoutSaaS.Domain.CustomerService.Entities;
using TakeoutSaaS.Domain.Deliveries.Entities;
@@ -40,6 +41,34 @@ public class TakeoutAppDbContext(
/// </summary>
public DbSet<Tenant> Tenants => Set<Tenant>();
/// <summary>
/// 租户认证资料。
/// </summary>
public DbSet<TenantVerificationProfile> TenantVerificationProfiles => Set<TenantVerificationProfile>();
/// <summary>
/// 租户订阅记录。
/// </summary>
public DbSet<TenantSubscription> TenantSubscriptions => Set<TenantSubscription>();
/// <summary>
/// 租户套餐定义。
/// </summary>
public DbSet<TenantPackage> TenantPackages => Set<TenantPackage>();
/// <summary>
/// 租户配额使用情况。
/// </summary>
public DbSet<TenantQuotaUsage> TenantQuotaUsages => Set<TenantQuotaUsage>();
/// <summary>
/// 租户账单。
/// </summary>
public DbSet<TenantBillingStatement> TenantBillingStatements => Set<TenantBillingStatement>();
/// <summary>
/// 租户订阅变更历史。
/// </summary>
public DbSet<TenantSubscriptionHistory> TenantSubscriptionHistories => Set<TenantSubscriptionHistory>();
/// <summary>
/// 租户支付记录。
/// </summary>
public DbSet<TenantPayment> TenantPayments => Set<TenantPayment>();
/// <summary>
/// 商户实体。
/// </summary>
public DbSet<Merchant> Merchants => Set<Merchant>();
@@ -341,6 +370,13 @@ public class TakeoutAppDbContext(
internal static void ConfigureModel(ModelBuilder modelBuilder)
{
ConfigureTenant(modelBuilder.Entity<Tenant>());
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
ConfigureTenantSubscription(modelBuilder.Entity<TenantSubscription>());
ConfigureTenantPackage(modelBuilder.Entity<TenantPackage>());
ConfigureTenantQuotaUsage(modelBuilder.Entity<TenantQuotaUsage>());
ConfigureTenantBillingStatement(modelBuilder.Entity<TenantBillingStatement>());
ConfigureTenantSubscriptionHistory(modelBuilder.Entity<TenantSubscriptionHistory>());
ConfigureTenantPayment(modelBuilder.Entity<TenantPayment>());
ConfigureMerchant(modelBuilder.Entity<Merchant>());
ConfigureStore(modelBuilder.Entity<Store>());
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
@@ -430,6 +466,127 @@ public class TakeoutAppDbContext(
builder.HasIndex(x => x.ContactPhone).IsUnique();
}
private static void ConfigureTenantVerificationProfile(EntityTypeBuilder<TenantVerificationProfile> builder)
{
builder.ToTable("tenant_verification_profiles");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(64);
builder.Property(x => x.BusinessLicenseUrl).HasMaxLength(512);
builder.Property(x => x.LegalPersonName).HasMaxLength(64);
builder.Property(x => x.LegalPersonIdNumber).HasMaxLength(32);
builder.Property(x => x.LegalPersonIdFrontUrl).HasMaxLength(512);
builder.Property(x => x.LegalPersonIdBackUrl).HasMaxLength(512);
builder.Property(x => x.BankAccountName).HasMaxLength(128);
builder.Property(x => x.BankAccountNumber).HasMaxLength(64);
builder.Property(x => x.BankName).HasMaxLength(128);
builder.Property(x => x.AdditionalDataJson).HasColumnType("text");
builder.Property(x => x.ReviewedByName).HasMaxLength(64);
builder.Property(x => x.ReviewRemarks).HasMaxLength(512);
builder.HasIndex(x => x.TenantId);
}
private static void ConfigureTenantSubscription(EntityTypeBuilder<TenantSubscription> builder)
{
builder.ToTable("tenant_subscriptions");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.TenantPackageId).IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.EffectiveFrom).IsRequired();
builder.Property(x => x.EffectiveTo).IsRequired();
builder.Property(x => x.Notes).HasColumnType("text");
builder.HasIndex(x => x.TenantId);
builder.HasOne(x => x.TenantPackage)
.WithMany()
.HasForeignKey(x => x.TenantPackageId)
.OnDelete(DeleteBehavior.Restrict);
}
private static void ConfigureTenantPackage(EntityTypeBuilder<TenantPackage> builder)
{
builder.ToTable("tenant_packages");
builder.HasKey(x => x.Id);
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
builder.Property(x => x.Description).HasMaxLength(512);
builder.Property(x => x.PackageType).HasConversion<int>();
builder.Property(x => x.MonthlyPrice).HasPrecision(18, 2);
builder.Property(x => x.YearlyPrice).HasPrecision(18, 2);
builder.Property(x => x.FeaturePoliciesJson).HasColumnType("text");
builder.Property(x => x.PublishStatus).HasConversion<int>();
builder.HasIndex(x => x.Name);
}
private static void ConfigureTenantQuotaUsage(EntityTypeBuilder<TenantQuotaUsage> builder)
{
builder.ToTable("tenant_quota_usages");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.QuotaType).HasConversion<int>();
builder.Property(x => x.LimitValue).HasPrecision(18, 2);
builder.Property(x => x.UsedValue).HasPrecision(18, 2);
builder.Property(x => x.ResetCycle).HasColumnType("text");
builder.HasIndex(x => new { x.TenantId, x.QuotaType }).IsUnique();
}
private static void ConfigureTenantBillingStatement(EntityTypeBuilder<TenantBillingStatement> builder)
{
builder.ToTable("tenant_billing_statements");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired();
builder.Property(x => x.BillingType).HasConversion<int>();
builder.Property(x => x.PeriodStart).IsRequired();
builder.Property(x => x.PeriodEnd).IsRequired();
builder.Property(x => x.AmountDue).HasPrecision(18, 2);
builder.Property(x => x.AmountPaid).HasPrecision(18, 2);
builder.Property(x => x.DiscountAmount).HasPrecision(18, 2);
builder.Property(x => x.TaxAmount).HasPrecision(18, 2);
builder.Property(x => x.Currency).HasMaxLength(8).IsRequired();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.DueDate).IsRequired();
builder.Property(x => x.LineItemsJson).HasColumnType("text");
builder.Property(x => x.Notes).HasMaxLength(512);
builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.Status, x.DueDate });
}
private static void ConfigureTenantSubscriptionHistory(EntityTypeBuilder<TenantSubscriptionHistory> builder)
{
builder.ToTable("tenant_subscription_histories");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.TenantSubscriptionId).IsRequired();
builder.Property(x => x.FromPackageId).IsRequired();
builder.Property(x => x.ToPackageId).IsRequired();
builder.Property(x => x.ChangeType).HasConversion<int>();
builder.Property(x => x.EffectiveFrom).IsRequired();
builder.Property(x => x.EffectiveTo).IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2);
builder.Property(x => x.Currency).HasMaxLength(8);
builder.Property(x => x.Notes).HasMaxLength(512);
builder.HasIndex(x => x.TenantSubscriptionId);
builder.HasIndex(x => x.TenantId);
}
private static void ConfigureTenantPayment(EntityTypeBuilder<TenantPayment> builder)
{
builder.ToTable("tenant_payments");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.BillingStatementId).IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
builder.Property(x => x.Method).HasConversion<int>();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.TransactionNo).HasMaxLength(128);
builder.Property(x => x.ProofUrl).HasMaxLength(512);
builder.Property(x => x.Notes).HasMaxLength(512);
builder.Property(x => x.RefundReason).HasMaxLength(512);
builder.HasIndex(x => x.BillingStatementId);
builder.HasIndex(x => x.TenantId);
}
private static void ConfigureMerchant(EntityTypeBuilder<Merchant> builder)
{
builder.ToTable("merchants");

View File

@@ -0,0 +1,241 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Billings.Entities;
using TakeoutSaaS.Domain.Billings.Enums;
using TakeoutSaaS.Domain.Billings.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 账单仓储实现AdminApi 使用)。
/// </summary>
public sealed class EfBillingRepository(TakeoutAdminDbContext context) : IBillingRepository
{
/// <inheritdoc />
public async Task<(IReadOnlyList<BillingListResult> Items, int TotalCount)> GetListAsync(
long? tenantId,
TenantBillingStatus? status,
TenantBillingType? billingType,
DateTime? startDate,
DateTime? endDate,
decimal? minAmount,
decimal? maxAmount,
string? keyword,
string? sortBy,
bool? sortDesc,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = from b in context.TenantBillingStatements.AsNoTracking()
join t in context.Tenants.AsNoTracking() on b.TenantId equals t.Id into tenants
from t in tenants.DefaultIfEmpty()
where b.DeletedAt == null
select new { Billing = b, TenantName = t != null ? t.Name : (string?)null };
// 2. 应用租户 ID 过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.Billing.TenantId == tenantId.Value);
}
// 3. 应用状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Billing.Status == status.Value);
}
// 4. 应用账单类型过滤
if (billingType.HasValue)
{
query = query.Where(x => x.Billing.BillingType == billingType.Value);
}
// 5. 应用日期范围过滤
if (startDate.HasValue)
{
query = query.Where(x => x.Billing.CreatedAt >= startDate.Value);
}
if (endDate.HasValue)
{
query = query.Where(x => x.Billing.CreatedAt <= endDate.Value);
}
// 6. 应用金额范围过滤
if (minAmount.HasValue)
{
query = query.Where(x => x.Billing.AmountDue >= minAmount.Value);
}
if (maxAmount.HasValue)
{
query = query.Where(x => x.Billing.AmountDue <= maxAmount.Value);
}
// 7. 应用关键词过滤
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => x.Billing.StatementNo.Contains(normalized) ||
(x.TenantName != null && x.TenantName.Contains(normalized)));
}
// 8. 获取总数
var totalCount = await query.CountAsync(cancellationToken);
// 9. 应用排序并分页查询
var isDesc = sortDesc ?? true;
var sortField = sortBy?.ToLowerInvariant() ?? "createdat";
List<BillingListResult> items;
if (sortField == "duedate")
{
items = isDesc
? await query.OrderByDescending(x => x.Billing.DueDate).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken)
: await query.OrderBy(x => x.Billing.DueDate).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken);
}
else if (sortField == "amountdue")
{
items = isDesc
? await query.OrderByDescending(x => x.Billing.AmountDue).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken)
: await query.OrderBy(x => x.Billing.AmountDue).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken);
}
else
{
items = isDesc
? await query.OrderByDescending(x => x.Billing.CreatedAt).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken)
: await query.OrderBy(x => x.Billing.CreatedAt).ThenBy(x => x.Billing.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new BillingListResult(
x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo,
x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd,
x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount,
x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate,
x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt))
.ToListAsync(cancellationToken);
}
// 10. 返回结果
return (items, totalCount);
}
/// <inheritdoc />
public async Task<TenantBillingStatement?> GetByIdAsync(long billingId, CancellationToken cancellationToken = default)
{
// 1. 查询账单(排除已删除,无跟踪)
return await context.TenantBillingStatements
.AsNoTracking()
.Where(b => b.Id == billingId && b.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<TenantBillingStatement?> GetByIdForUpdateAsync(long billingId, CancellationToken cancellationToken = default)
{
// 1. 查询账单(排除已删除,带跟踪用于更新)
return await context.TenantBillingStatements
.Where(b => b.Id == billingId && b.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<BillingDetailResult?> GetDetailAsync(long billingId, CancellationToken cancellationToken = default)
{
// 1. 查询账单详情(带租户信息)
var result = await (from b in context.TenantBillingStatements.AsNoTracking()
join t in context.Tenants.AsNoTracking() on b.TenantId equals t.Id into tenants
from t in tenants.DefaultIfEmpty()
where b.Id == billingId && b.DeletedAt == null
select new
{
Billing = b,
TenantName = t != null ? t.Name : (string?)null
}).FirstOrDefaultAsync(cancellationToken);
// 2. 如果不存在,返回 null
if (result is null)
{
return null;
}
// 3. 返回详情结果
return new BillingDetailResult(
result.Billing.Id,
result.Billing.TenantId,
result.TenantName,
result.Billing.StatementNo,
result.Billing.BillingType,
result.Billing.PeriodStart,
result.Billing.PeriodEnd,
result.Billing.AmountDue,
result.Billing.AmountPaid,
result.Billing.DiscountAmount,
result.Billing.TaxAmount,
result.Billing.Currency,
result.Billing.Status,
result.Billing.DueDate,
result.Billing.LineItemsJson,
result.Billing.Notes,
result.Billing.OverdueNotifiedAt,
result.Billing.ReminderSentAt,
result.Billing.CreatedAt,
result.Billing.UpdatedAt);
}
/// <inheritdoc />
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存变更
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddPaymentAsync(TenantPayment payment, CancellationToken cancellationToken = default)
{
// 1. 添加支付记录
await context.TenantPayments.AddAsync(payment, cancellationToken);
}
}

View File

@@ -0,0 +1,272 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 订阅仓储实现AdminApi 使用)。
/// </summary>
public sealed class EfSubscriptionRepository(TakeoutAdminDbContext context) : ISubscriptionRepository
{
/// <inheritdoc />
public async Task<(IReadOnlyList<SubscriptionListResult> Items, int TotalCount)> GetListAsync(
SubscriptionStatus? status,
long? tenantPackageId,
long? tenantId,
string? tenantKeyword,
int? expiringWithinDays,
bool? autoRenew,
DateTime? expireFrom,
DateTime? expireTo,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 获取当前时间
var now = DateTime.UtcNow;
// 2. 构建基础查询
var query = from s in context.TenantSubscriptions.AsNoTracking()
join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id
join p in context.TenantPackages.AsNoTracking() on s.TenantPackageId equals p.Id
where s.DeletedAt == null && t.DeletedAt == null
select new
{
Subscription = s,
Tenant = t,
Package = p,
ScheduledPackage = context.TenantPackages
.Where(sp => sp.Id == s.ScheduledPackageId)
.Select(sp => new { sp.Id, sp.Name })
.FirstOrDefault()
};
// 3. 应用订阅状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Subscription.Status == status.Value);
}
// 4. 应用套餐 ID 过滤
if (tenantPackageId.HasValue)
{
query = query.Where(x => x.Subscription.TenantPackageId == tenantPackageId.Value);
}
// 5. 应用租户 ID 过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.Subscription.TenantId == tenantId.Value);
}
// 6. 应用租户关键词过滤
if (!string.IsNullOrWhiteSpace(tenantKeyword))
{
var normalized = tenantKeyword.Trim();
query = query.Where(x => x.Tenant.Name.Contains(normalized) || x.Tenant.Code.Contains(normalized));
}
// 7. 应用到期天数筛选
if (expiringWithinDays.HasValue)
{
var expiryDate = now.AddDays(expiringWithinDays.Value);
query = query.Where(x => x.Subscription.EffectiveTo <= expiryDate && x.Subscription.EffectiveTo > now);
}
// 8. 应用自动续费筛选
if (autoRenew.HasValue)
{
query = query.Where(x => x.Subscription.AutoRenew == autoRenew.Value);
}
// 9. 应用到期时间范围筛选
if (expireFrom.HasValue)
{
query = query.Where(x => x.Subscription.EffectiveTo >= expireFrom.Value);
}
if (expireTo.HasValue)
{
query = query.Where(x => x.Subscription.EffectiveTo <= expireTo.Value);
}
// 10. 获取总数
var totalCount = await query.CountAsync(cancellationToken);
// 11. 分页查询(按到期时间升序)
var items = await query
.OrderBy(x => x.Subscription.EffectiveTo)
.ThenBy(x => x.Subscription.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new SubscriptionListResult(
x.Subscription.Id,
x.Tenant.Id,
x.Tenant.Name,
x.Tenant.Code,
x.Package.Id,
x.Package.Name,
x.ScheduledPackage != null ? x.ScheduledPackage.Id : (long?)null,
x.ScheduledPackage != null ? x.ScheduledPackage.Name : null,
x.Subscription.Status,
x.Subscription.EffectiveFrom,
x.Subscription.EffectiveTo,
x.Subscription.NextBillingDate,
x.Subscription.AutoRenew,
x.Subscription.Notes,
x.Subscription.CreatedAt,
x.Subscription.UpdatedAt))
.ToListAsync(cancellationToken);
// 12. 返回结果
return (items, totalCount);
}
/// <inheritdoc />
public async Task<TenantSubscription?> GetByIdAsync(long subscriptionId, CancellationToken cancellationToken = default)
{
// 1. 查询订阅(排除已删除,无跟踪)
return await context.TenantSubscriptions
.AsNoTracking()
.Where(s => s.Id == subscriptionId && s.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<TenantSubscription?> GetByIdForUpdateAsync(long subscriptionId, CancellationToken cancellationToken = default)
{
// 1. 查询订阅(排除已删除,带跟踪用于更新)
return await context.TenantSubscriptions
.Where(s => s.Id == subscriptionId && s.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存变更
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<SubscriptionDetailResult?> GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default)
{
// 1. 查询订阅详情
var result = await (from s in context.TenantSubscriptions.AsNoTracking()
join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id
join p in context.TenantPackages.AsNoTracking() on s.TenantPackageId equals p.Id
where s.Id == subscriptionId && s.DeletedAt == null && t.DeletedAt == null
select new
{
Subscription = s,
Tenant = t,
Package = p,
ScheduledPackage = context.TenantPackages
.Where(sp => sp.Id == s.ScheduledPackageId)
.FirstOrDefault()
}).FirstOrDefaultAsync(cancellationToken);
// 2. 如果不存在,返回 null
if (result is null)
{
return null;
}
// 3. 返回详情结果
return new SubscriptionDetailResult(
result.Subscription.Id,
result.Tenant.Id,
result.Tenant.Name,
result.Tenant.Code,
result.Package.Id,
result.Package.Name,
result.ScheduledPackage?.Id,
result.ScheduledPackage?.Name,
result.Subscription.Status,
result.Subscription.EffectiveFrom,
result.Subscription.EffectiveTo,
result.Subscription.NextBillingDate,
result.Subscription.AutoRenew,
result.Subscription.Notes,
result.Subscription.CreatedAt,
result.Subscription.UpdatedAt,
result.Package,
result.ScheduledPackage);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SubscriptionHistoryResult>> GetHistoriesAsync(long subscriptionId, CancellationToken cancellationToken = default)
{
// 1. 查询变更历史
var histories = await (from h in context.TenantSubscriptionHistories.AsNoTracking()
join fp in context.TenantPackages.AsNoTracking() on h.FromPackageId equals fp.Id
join tp in context.TenantPackages.AsNoTracking() on h.ToPackageId equals tp.Id
where h.TenantSubscriptionId == subscriptionId && h.DeletedAt == null
orderby h.CreatedAt descending
select new SubscriptionHistoryResult(
h.Id,
h.TenantSubscriptionId,
h.ChangeType,
h.FromPackageId,
fp.Name,
h.ToPackageId,
tp.Name,
h.EffectiveFrom,
h.EffectiveTo,
h.Notes,
h.CreatedAt,
h.CreatedBy))
.ToListAsync(cancellationToken);
// 2. 返回结果
return histories;
}
/// <inheritdoc />
public async Task<SubscriptionListResult?> GetListResultByIdAsync(long subscriptionId, CancellationToken cancellationToken = default)
{
// 1. 查询订阅(带租户和套餐信息)
var result = await (from s in context.TenantSubscriptions.AsNoTracking()
join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id
join p in context.TenantPackages.AsNoTracking() on s.TenantPackageId equals p.Id
where s.Id == subscriptionId && s.DeletedAt == null && t.DeletedAt == null
select new
{
Subscription = s,
Tenant = t,
Package = p,
ScheduledPackage = context.TenantPackages
.Where(sp => sp.Id == s.ScheduledPackageId)
.Select(sp => new { sp.Id, sp.Name })
.FirstOrDefault()
}).FirstOrDefaultAsync(cancellationToken);
// 2. 如果不存在,返回 null
if (result is null)
{
return null;
}
// 3. 返回列表结果
return new SubscriptionListResult(
result.Subscription.Id,
result.Tenant.Id,
result.Tenant.Name,
result.Tenant.Code,
result.Package.Id,
result.Package.Name,
result.ScheduledPackage != null ? result.ScheduledPackage.Id : (long?)null,
result.ScheduledPackage != null ? result.ScheduledPackage.Name : null,
result.Subscription.Status,
result.Subscription.EffectiveFrom,
result.Subscription.EffectiveTo,
result.Subscription.NextBillingDate,
result.Subscription.AutoRenew,
result.Subscription.Notes,
result.Subscription.CreatedAt,
result.Subscription.UpdatedAt);
}
}

View File

@@ -0,0 +1,234 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户套餐仓储实现AdminApi 使用)。
/// </summary>
public sealed class EfTenantPackageRepository(TakeoutAdminDbContext context) : ITenantPackageRepository
{
/// <inheritdoc />
public async Task<TenantPackage?> GetByIdAsync(long tenantPackageId, CancellationToken cancellationToken = default)
{
// 1. 查询套餐(排除已删除,无跟踪)
return await context.TenantPackages
.AsNoTracking()
.Where(p => p.Id == tenantPackageId && p.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<TenantPackage?> GetByIdForUpdateAsync(long tenantPackageId, CancellationToken cancellationToken = default)
{
// 1. 查询套餐(排除已删除,带跟踪用于更新)
return await context.TenantPackages
.Where(p => p.Id == tenantPackageId && p.DeletedAt == null)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantPackage> Items, int TotalCount)> GetListAsync(
string? keyword,
bool? isActive,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = context.TenantPackages
.AsNoTracking()
.Where(p => p.DeletedAt == null);
// 2. 应用关键字过滤
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(p => p.Name.Contains(normalized));
}
// 3. 应用启用状态过滤
if (isActive.HasValue)
{
query = query.Where(p => p.IsActive == isActive.Value);
}
// 4. 获取总数
var totalCount = await query.CountAsync(cancellationToken);
// 5. 分页查询(按排序序号升序)
var items = await query
.OrderBy(p => p.SortOrder)
.ThenBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
// 6. 返回结果
return (items, totalCount);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPackageUsageResult>> GetUsagesAsync(
IReadOnlyList<long> tenantPackageIds,
CancellationToken cancellationToken = default)
{
// 1. 如果没有传入套餐 ID返回空列表
if (tenantPackageIds.Count == 0)
{
return [];
}
// 2. 获取当前时间用于计算到期天数
var now = DateTime.UtcNow;
var in7Days = now.AddDays(7);
var in15Days = now.AddDays(15);
var in30Days = now.AddDays(30);
// 3. 查询订阅数据并按套餐分组统计
var subscriptions = await context.TenantSubscriptions
.AsNoTracking()
.Where(s => tenantPackageIds.Contains(s.TenantPackageId) && s.DeletedAt == null)
.Select(s => new
{
s.TenantPackageId,
s.TenantId,
s.Status,
s.EffectiveTo,
MonthlyPrice = context.TenantPackages
.Where(p => p.Id == s.TenantPackageId)
.Select(p => p.MonthlyPrice)
.FirstOrDefault()
})
.ToListAsync(cancellationToken);
// 4. 按套餐 ID 分组统计
var results = tenantPackageIds.Select(packageId =>
{
var packageSubscriptions = subscriptions.Where(s => s.TenantPackageId == packageId).ToList();
// 4.1 活跃订阅(状态为 Active 且未过期)
var activeSubscriptions = packageSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo > now)
.ToList();
// 4.2 活跃租户数(去重)
var activeTenantCount = activeSubscriptions.Select(s => s.TenantId).Distinct().Count();
// 4.3 总订阅数
var totalSubscriptionCount = packageSubscriptions.Count;
// 4.4 计算 MRR月度经常性收入
var mrr = activeSubscriptions.Sum(s => s.MonthlyPrice ?? 0);
// 4.5 计算 ARR年度经常性收入
var arr = mrr * 12;
// 4.6 计算到期租户数(基于活跃订阅)
var expiringIn7Days = activeSubscriptions.Count(s => s.EffectiveTo <= in7Days);
var expiringIn15Days = activeSubscriptions.Count(s => s.EffectiveTo <= in15Days);
var expiringIn30Days = activeSubscriptions.Count(s => s.EffectiveTo <= in30Days);
return new TenantPackageUsageResult(
packageId,
activeSubscriptions.Count,
activeTenantCount,
totalSubscriptionCount,
mrr,
arr,
expiringIn7Days,
expiringIn15Days,
expiringIn30Days);
}).ToList();
// 5. 返回结果
return results;
}
/// <inheritdoc />
public async Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default)
{
// 1. 添加套餐实体
await context.TenantPackages.AddAsync(package, cancellationToken);
}
/// <inheritdoc />
public Task SoftDeleteAsync(TenantPackage package, CancellationToken cancellationToken = default)
{
// 1. 设置软删除时间
package.DeletedAt = DateTime.UtcNow;
// 2. 返回已完成任务
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存变更
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantPackageTenantResult> Items, int TotalCount)> GetTenantsAsync(
long tenantPackageId,
string? keyword,
int? expiringWithinDays,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 获取当前时间
var now = DateTime.UtcNow;
// 2. 构建基础查询:活跃订阅(状态为 Active 且未过期)
var query = from s in context.TenantSubscriptions.AsNoTracking()
join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id
where s.TenantPackageId == tenantPackageId
&& s.DeletedAt == null
&& t.DeletedAt == null
&& s.Status == SubscriptionStatus.Active
&& s.EffectiveTo > now
select new { Subscription = s, Tenant = t };
// 3. 应用关键字过滤
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => x.Tenant.Name.Contains(normalized) || x.Tenant.Code.Contains(normalized));
}
// 4. 应用到期天数筛选
if (expiringWithinDays.HasValue)
{
var expiryDate = now.AddDays(expiringWithinDays.Value);
query = query.Where(x => x.Subscription.EffectiveTo <= expiryDate);
}
// 5. 获取总数
var totalCount = await query.CountAsync(cancellationToken);
// 6. 分页查询(按到期时间升序,即将到期的排前面)
var items = await query
.OrderBy(x => x.Subscription.EffectiveTo)
.ThenBy(x => x.Tenant.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new TenantPackageTenantResult(
x.Tenant.Id,
x.Tenant.Code,
x.Tenant.Name,
x.Tenant.Status,
x.Tenant.ContactName,
x.Tenant.ContactPhone,
x.Subscription.EffectiveFrom,
x.Subscription.EffectiveTo))
.ToListAsync(cancellationToken);
// 7. 返回结果
return (items, totalCount);
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Billings.Entities;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
@@ -53,4 +54,73 @@ public sealed class EfTenantRepository(TakeoutAdminDbContext context) : ITenantR
// 3. 返回列表
return await query.OrderBy(x => x.Code).ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<TenantDetailResult?> GetDetailAsync(long tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询租户基本信息
var tenant = await context.Tenants
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == tenantId, cancellationToken);
if (tenant is null)
{
return null;
}
// 2. 查询认证信息(取最新一条)
var verification = await context.TenantVerificationProfiles
.AsNoTracking()
.Where(v => v.TenantId == tenantId)
.OrderByDescending(v => v.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
// 3. 查询订阅信息(取最新一条,包含套餐)
var subscription = await context.TenantSubscriptions
.AsNoTracking()
.Include(s => s.TenantPackage)
.Where(s => s.TenantId == tenantId)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
// 4. 返回结果
return new TenantDetailResult(tenant, verification, subscription, subscription?.TenantPackage);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantQuotaUsage>> GetQuotaUsagesAsync(long tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询租户配额使用情况
return await context.TenantQuotaUsages
.AsNoTracking()
.Where(q => q.TenantId == tenantId && q.DeletedAt == null)
.OrderBy(q => q.QuotaType)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantBillingStatement> Items, int TotalCount)> GetBillingsAsync(
long tenantId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = context.TenantBillingStatements
.AsNoTracking()
.Where(b => b.TenantId == tenantId && b.DeletedAt == null);
// 2. 获取总数
var totalCount = await query.CountAsync(cancellationToken);
// 3. 分页查询(按创建时间倒序)
var items = await query
.OrderByDescending(b => b.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
// 4. 返回结果
return (items, totalCount);
}
}