diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs index 455c095..03c5630 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs @@ -37,6 +37,58 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro return ApiResponse>.Ok(result); } + /// + /// 查询套餐使用统计(订阅关联数量、使用租户数量)。 + /// + /// 套餐 ID 列表(为空表示查询全部)。 + /// 取消标记。 + /// 套餐使用统计列表。 + [HttpGet("usages")] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Usages( + [FromQuery] long[]? tenantPackageIds, + CancellationToken cancellationToken) + { + // 1. 查询使用统计 + var result = await mediator.Send(new GetTenantPackageUsagesQuery { TenantPackageIds = tenantPackageIds }, cancellationToken); + + // 2. 返回结果 + return ApiResponse>.Ok(result); + } + + /// + /// 查询套餐当前使用租户列表(按有效订阅口径)。 + /// + /// 套餐 ID。 + /// 关键词(可选)。 + /// 页码(从 1 开始)。 + /// 每页大小。 + /// 取消标记。 + /// 使用租户分页结果。 + [HttpGet("{tenantPackageId:long}/tenants")] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Tenants( + long tenantPackageId, + [FromQuery] string? keyword, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + // 1. 查询套餐使用租户分页 + var result = await mediator.Send(new GetTenantPackageTenantsQuery + { + TenantPackageId = tenantPackageId, + Keyword = keyword, + Page = page, + PageSize = pageSize + }, cancellationToken); + + // 2. 返回结果 + return ApiResponse>.Ok(result); + } + /// /// 查看套餐详情。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageTenantDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageTenantDto.cs new file mode 100644 index 0000000..9c49c80 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageTenantDto.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 套餐使用租户 DTO(用于平台查看套餐关联租户列表)。 +/// +public sealed class TenantPackageTenantDto +{ + /// + /// 租户 ID。 + /// + public long TenantId { get; init; } + + /// + /// 租户编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 租户名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 租户状态。 + /// + public TenantStatus Status { get; init; } + + /// + /// 联系人。 + /// + public string? ContactName { get; init; } + + /// + /// 联系电话。 + /// + public string? ContactPhone { get; init; } + + /// + /// 当前订阅生效时间(UTC)。 + /// + public DateTime SubscriptionEffectiveFrom { get; init; } + + /// + /// 当前订阅到期时间(UTC)。 + /// + public DateTime SubscriptionEffectiveTo { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs new file mode 100644 index 0000000..d1e3398 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs @@ -0,0 +1,28 @@ +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 套餐使用统计 DTO(订阅关联数量、使用租户数量)。 +/// +public sealed class TenantPackageUsageDto +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } + + /// + /// 当前有效订阅数量(以当前时间为准)。 + /// + public int ActiveSubscriptionCount { get; init; } + + /// + /// 当前使用租户数量(以当前时间为准,按租户去重)。 + /// + public int ActiveTenantCount { get; init; } + + /// + /// 历史总订阅记录数量(不含软删)。 + /// + public int TotalSubscriptionCount { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs new file mode 100644 index 0000000..edc4e7c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs @@ -0,0 +1,175 @@ +using MediatR; +using System.Data; +using System.Data.Common; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 查询套餐当前使用租户列表处理器。 +/// +public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperExecutor) + : IRequestHandler> +{ + /// + public async Task> Handle(GetTenantPackageTenantsQuery request, CancellationToken cancellationToken) + { + // 1. 参数规范化 + var page = request.Page <= 0 ? 1 : request.Page; + var pageSize = request.PageSize <= 0 ? 20 : request.PageSize; + var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim(); + + // 2. (空行后) 以当前时间为准筛选“有效订阅” + var now = DateTime.UtcNow; + var offset = (page - 1) * pageSize; + + // 3. (空行后) 查询总数 + 列表 + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + // 3.1 统计总数 + var total = await ExecuteScalarIntAsync( + connection, + BuildCountSql(), + [ + ("packageId", request.TenantPackageId), + ("now", now), + ("keyword", keyword) + ], + token); + + // 3.2 (空行后) 查询列表 + var listSql = BuildListSql(); + await using var listCommand = CreateCommand( + connection, + listSql, + [ + ("packageId", request.TenantPackageId), + ("now", now), + ("keyword", keyword), + ("offset", offset), + ("limit", pageSize) + ]); + + await using var reader = await listCommand.ExecuteReaderAsync(token); + var items = new List(); + while (await reader.ReadAsync(token)) + { + items.Add(new TenantPackageTenantDto + { + TenantId = reader.GetInt64(0), + Code = reader.GetString(1), + Name = reader.GetString(2), + Status = (TenantStatus)reader.GetInt32(3), + ContactName = reader.IsDBNull(4) ? null : reader.GetString(4), + ContactPhone = reader.IsDBNull(5) ? null : reader.GetString(5), + SubscriptionEffectiveFrom = reader.GetDateTime(6), + SubscriptionEffectiveTo = reader.GetDateTime(7) + }); + } + + // 3.3 (空行后) 返回分页 + return new PagedResult(items, page, pageSize, total); + }, + cancellationToken); + } + + private static string BuildCountSql() + { + return """ + select count(*) + from public.tenants t + where t."DeletedAt" is null + and ( + @keyword is null + or t."Name" ilike ('%' || @keyword || '%') + or t."Code" ilike ('%' || @keyword || '%') + or coalesce(t."ContactName", '') ilike ('%' || @keyword || '%') + or coalesce(t."ContactPhone", '') ilike ('%' || @keyword || '%') + ) + and exists ( + select 1 + from public.tenant_subscriptions s + where s."DeletedAt" is null + and s."TenantId" = t."Id" + and s."TenantPackageId" = @packageId + and s."Status" = 1 + and s."EffectiveFrom" <= @now + and s."EffectiveTo" >= @now + ); + """; + } + + private static string BuildListSql() + { + return """ + select + t."Id" as "TenantId", + t."Code", + t."Name", + t."Status", + t."ContactName", + t."ContactPhone", + s."EffectiveFrom", + s."EffectiveTo" + from public.tenants t + join lateral ( + select s."EffectiveFrom", s."EffectiveTo" + from public.tenant_subscriptions s + where s."DeletedAt" is null + and s."TenantId" = t."Id" + and s."TenantPackageId" = @packageId + and s."Status" = 1 + and s."EffectiveFrom" <= @now + and s."EffectiveTo" >= @now + order by s."EffectiveTo" desc + limit 1 + ) s on true + where t."DeletedAt" is null + and ( + @keyword is null + or t."Name" ilike ('%' || @keyword || '%') + or t."Code" ilike ('%' || @keyword || '%') + or coalesce(t."ContactName", '') ilike ('%' || @keyword || '%') + or coalesce(t."ContactPhone", '') ilike ('%' || @keyword || '%') + ) + order by t."CreatedAt" desc + offset @offset + limit @limit; + """; + } + + private static async Task ExecuteScalarIntAsync( + IDbConnection connection, + string sql, + (string Name, object? Value)[] parameters, + CancellationToken cancellationToken) + { + await using var command = CreateCommand(connection, sql, parameters); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull ? 0 : Convert.ToInt32(result); + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + foreach (var (name, value) in parameters) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } + + return (DbCommand)command; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs new file mode 100644 index 0000000..e3aa0cb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs @@ -0,0 +1,117 @@ +using MediatR; +using System.Data; +using System.Data.Common; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 查询套餐使用统计处理器。 +/// +public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExecutor) + : IRequestHandler> +{ + /// + public async Task> Handle(GetTenantPackageUsagesQuery request, CancellationToken cancellationToken) + { + // 1. 规范化输入 + var ids = request.TenantPackageIds? + .Where(x => x > 0) + .Distinct() + .ToArray(); + + // 2. (空行后) 构造 SQL(以当前时间为准统计“有效订阅/使用租户”) + var now = DateTime.UtcNow; + var sql = BuildSql(ids, out var parameters, now); + + // 3. (空行后) 查询统计结果 + return await dapperExecutor.QueryAsync( + DatabaseConstants.AppDataSource, + DatabaseConnectionRole.Read, + async (connection, token) => + { + await using var command = CreateCommand(connection, sql, parameters); + await using var reader = await command.ExecuteReaderAsync(token); + var list = new List(); + + // 4. (空行后) 逐行读取 + while (await reader.ReadAsync(token)) + { + list.Add(new TenantPackageUsageDto + { + TenantPackageId = reader.GetInt64(0), + ActiveSubscriptionCount = reader.GetInt32(1), + ActiveTenantCount = reader.GetInt32(2), + TotalSubscriptionCount = reader.GetInt32(3) + }); + } + + return (IReadOnlyList)list; + }, + cancellationToken); + } + + private static string BuildSql(long[]? ids, out (string Name, object? Value)[] parameters, DateTime now) + { + // 1. 基础查询 + var builder = new System.Text.StringBuilder(); + builder.AppendLine(""" + select + "TenantPackageId" as "TenantPackageId", + count(*) filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now) as "ActiveSubscriptionCount", + count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now) as "ActiveTenantCount", + count(*) as "TotalSubscriptionCount" + from public.tenant_subscriptions + where "DeletedAt" is null + """); + + var list = new List<(string Name, object? Value)> + { + ("now", now) + }; + + // 2. (空行后) 可选按套餐 ID 过滤 + if (ids is { Length: > 0 }) + { + builder.Append(" and \"TenantPackageId\" in ("); + for (var i = 0; i < ids.Length; i++) + { + if (i > 0) + { + builder.Append(','); + } + + var name = $"p{i}"; + builder.Append($"@{name}"); + list.Add((name, ids[i])); + } + + builder.AppendLine(")"); + } + + // 3. (空行后) 分组 + builder.AppendLine("group by \"TenantPackageId\";"); + + parameters = list.ToArray(); + return builder.ToString(); + } + + private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + foreach (var (name, value) in parameters) + { + var p = command.CreateParameter(); + p.ParameterName = name; + p.Value = value ?? DBNull.Value; + command.Parameters.Add(p); + } + + return (DbCommand)command; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs new file mode 100644 index 0000000..b16615b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs @@ -0,0 +1,32 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 查询指定套餐当前使用租户列表(按“当前有效订阅”口径)。 +/// +public sealed record GetTenantPackageTenantsQuery : IRequest> +{ + /// + /// 套餐 ID。 + /// + public required long TenantPackageId { get; init; } + + /// + /// 关键词(租户名称/编码/联系人/电话)。 + /// + public string? Keyword { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页大小。 + /// + public int PageSize { get; init; } = 20; +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageUsagesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageUsagesQuery.cs new file mode 100644 index 0000000..729b149 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageUsagesQuery.cs @@ -0,0 +1,16 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 查询套餐使用统计(订阅关联数量、使用租户数量)。 +/// +public sealed record GetTenantPackageUsagesQuery : IRequest> +{ + /// + /// 需要统计的套餐 ID 列表(为空表示统计全部)。 + /// + public long[]? TenantPackageIds { get; init; } +} +