feat(admin): 新增管理员角色、账单、订阅、套餐管理功能
- 新增 AdminRolesController 实现角色 CRUD 和权限管理 - 新增 BillingsController 实现账单查询功能 - 新增 SubscriptionsController 实现订阅管理功能 - 新增 TenantPackagesController 实现套餐管理功能 - 新增租户详情、配额使用、账单列表等查询功能 - 新增 TenantPackage、TenantSubscription 等领域实体 - 新增相关枚举:SubscriptionStatus、TenantPackageType 等 - 更新 appsettings 配置文件 - 更新权限授权策略提供者 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user