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