From 2ed814fbe738401f5486f22b6ca96eebe2676e26 Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Mon, 15 Dec 2025 21:46:20 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A5=97=E9=A4=90=E5=88=B0=E6=9C=9F?=
=?UTF-8?q?=E5=88=86=E5=B8=83=E4=B8=8E=E5=88=B0=E6=9C=9F=E7=A7=9F=E6=88=B7?=
=?UTF-8?q?=E7=AD=9B=E9=80=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controllers/TenantPackagesController.cs | 3 +
.../App/Tenants/Dto/TenantPackageUsageDto.cs | 15 +++++
.../GetTenantPackageTenantsQueryHandler.cs | 59 ++++++++++++++++++-
.../GetTenantPackageUsagesQueryHandler.cs | 33 +++++++++--
.../Queries/GetTenantPackageTenantsQuery.cs | 6 +-
5 files changed, 107 insertions(+), 9 deletions(-)
diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs
index 03c5630..a7327d2 100644
--- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs
+++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs
@@ -62,6 +62,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro
///
/// 套餐 ID。
/// 关键词(可选)。
+ /// 可选:未来 N 天内到期筛选。
/// 页码(从 1 开始)。
/// 每页大小。
/// 取消标记。
@@ -72,6 +73,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro
public async Task>> Tenants(
long tenantPackageId,
[FromQuery] string? keyword,
+ [FromQuery] int? expiringWithinDays,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
@@ -81,6 +83,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro
{
TenantPackageId = tenantPackageId,
Keyword = keyword,
+ ExpiringWithinDays = expiringWithinDays,
Page = page,
PageSize = pageSize
}, cancellationToken);
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs
index c613598..cc3f951 100644
--- a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs
@@ -34,4 +34,19 @@ public sealed class TenantPackageUsageDto
/// ARR(Annual Recurring Revenue)粗看:按“当前有效订阅数 × 套餐年付等效价”估算。
///
public decimal Arr { get; init; }
+
+ ///
+ /// 未来 7 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。
+ ///
+ public int ExpiringTenantCount7Days { get; init; }
+
+ ///
+ /// 未来 15 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。
+ ///
+ public int ExpiringTenantCount15Days { get; init; }
+
+ ///
+ /// 未来 30 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。
+ ///
+ public int ExpiringTenantCount30Days { get; init; }
}
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs
index 95b730b..997a0ed 100644
--- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs
@@ -26,6 +26,8 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx
// 2. (空行后) 以当前时间为准筛选“有效订阅”
var now = DateTime.UtcNow;
+ var expiringDays = request.ExpiringWithinDays is > 0 ? request.ExpiringWithinDays : null;
+ var expiryEnd = expiringDays.HasValue ? now.AddDays(expiringDays.Value) : (DateTime?)null;
var offset = (page - 1) * pageSize;
// 3. (空行后) 查询总数 + 列表
@@ -41,18 +43,20 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx
[
("packageId", request.TenantPackageId),
("now", now),
+ ("expiryEnd", expiryEnd),
("keyword", keyword)
],
token);
// 3.2 (空行后) 查询列表
- var listSql = BuildListSql();
+ var listSql = BuildListSql(expiryEnd.HasValue);
await using var listCommand = CreateCommand(
connection,
listSql,
[
("packageId", request.TenantPackageId),
("now", now),
+ ("expiryEnd", expiryEnd),
("keyword", keyword),
("offset", offset),
("limit", pageSize)
@@ -103,12 +107,59 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx
and s."Status" = 1
and s."EffectiveFrom" <= @now
and s."EffectiveTo" >= @now
+ and (
+ @expiryEnd::timestamp with time zone is null
+ or s."EffectiveTo" <= @expiryEnd
+ )
);
""";
}
- private static string BuildListSql()
+ private static string BuildListSql(bool orderByExpiryAsc)
{
+ if (orderByExpiryAsc)
+ {
+ 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
+ and (
+ @expiryEnd::timestamp with time zone is null
+ or s."EffectiveTo" <= @expiryEnd
+ )
+ order by s."EffectiveTo" asc
+ limit 1
+ ) s on true
+ where t."DeletedAt" is null
+ and (
+ @keyword::text is null
+ or t."Name" ilike ('%' || @keyword::text || '%')
+ or t."Code" ilike ('%' || @keyword::text || '%')
+ or coalesce(t."ContactName", '') ilike ('%' || @keyword::text || '%')
+ or coalesce(t."ContactPhone", '') ilike ('%' || @keyword::text || '%')
+ )
+ order by s."EffectiveTo" asc
+ offset @offset
+ limit @limit;
+ """;
+ }
+
return """
select
t."Id" as "TenantId",
@@ -129,6 +180,10 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx
and s."Status" = 1
and s."EffectiveFrom" <= @now
and s."EffectiveTo" >= @now
+ and (
+ @expiryEnd::timestamp with time zone is null
+ or s."EffectiveTo" <= @expiryEnd
+ )
order by s."EffectiveTo" desc
limit 1
) s on true
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs
index 0eb2750..cf91d7f 100644
--- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs
@@ -23,9 +23,12 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
.Distinct()
.ToArray();
- // 2. (空行后) 构造 SQL(以当前时间为准统计“有效订阅/使用租户”)
+ // 2. (空行后) 构造 SQL(以当前时间为准统计“有效订阅/使用租户/到期分布”)
var now = DateTime.UtcNow;
- var sql = BuildSql(ids, out var parameters, now);
+ var date7 = now.AddDays(7);
+ var date15 = now.AddDays(15);
+ var date30 = now.AddDays(30);
+ var sql = BuildSql(ids, out var parameters, now, date7, date15, date30);
// 3. (空行后) 查询统计结果
return await dapperExecutor.QueryAsync(
@@ -47,7 +50,10 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
ActiveTenantCount = reader.GetInt32(2),
TotalSubscriptionCount = reader.GetInt32(3),
Mrr = reader.IsDBNull(4) ? 0m : reader.GetDecimal(4),
- Arr = reader.IsDBNull(5) ? 0m : reader.GetDecimal(5)
+ Arr = reader.IsDBNull(5) ? 0m : reader.GetDecimal(5),
+ ExpiringTenantCount7Days = reader.IsDBNull(6) ? 0 : reader.GetInt32(6),
+ ExpiringTenantCount15Days = reader.IsDBNull(7) ? 0 : reader.GetInt32(7),
+ ExpiringTenantCount30Days = reader.IsDBNull(8) ? 0 : reader.GetInt32(8)
});
}
@@ -56,7 +62,13 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
cancellationToken);
}
- private static string BuildSql(long[]? ids, out (string Name, object? Value)[] parameters, DateTime now)
+ private static string BuildSql(
+ long[]? ids,
+ out (string Name, object? Value)[] parameters,
+ DateTime now,
+ DateTime date7,
+ DateTime date15,
+ DateTime date30)
{
// 1. 基础查询:先按订阅表聚合,再回连套餐表计算 MRR/ARR
var builder = new System.Text.StringBuilder();
@@ -66,6 +78,9 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
"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(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now and "EffectiveTo" <= @date7) as "ExpiringTenantCount7Days",
+ count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now and "EffectiveTo" <= @date15) as "ExpiringTenantCount15Days",
+ count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now and "EffectiveTo" <= @date30) as "ExpiringTenantCount30Days",
count(*) as "TotalSubscriptionCount"
from public.tenant_subscriptions
where "DeletedAt" is null
@@ -73,7 +88,10 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
var list = new List<(string Name, object? Value)>
{
- ("now", now)
+ ("now", now),
+ ("date7", date7),
+ ("date15", date15),
+ ("date30", date30)
};
// 2. (空行后) 可选按套餐 ID 过滤
@@ -105,7 +123,10 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
s."ActiveTenantCount" as "ActiveTenantCount",
s."TotalSubscriptionCount" as "TotalSubscriptionCount",
(s."ActiveSubscriptionCount"::numeric * coalesce(p."MonthlyPrice", (p."YearlyPrice" / 12.0), 0))::numeric(18, 2) as "Mrr",
- (s."ActiveSubscriptionCount"::numeric * coalesce(p."YearlyPrice", (p."MonthlyPrice" * 12), 0))::numeric(18, 2) as "Arr"
+ (s."ActiveSubscriptionCount"::numeric * coalesce(p."YearlyPrice", (p."MonthlyPrice" * 12), 0))::numeric(18, 2) as "Arr",
+ s."ExpiringTenantCount7Days" as "ExpiringTenantCount7Days",
+ s."ExpiringTenantCount15Days" as "ExpiringTenantCount15Days",
+ s."ExpiringTenantCount30Days" as "ExpiringTenantCount30Days"
from stats s
left join public.tenant_packages p on p."Id" = s."TenantPackageId" and p."DeletedAt" is null;
""");
diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs
index b16615b..3e05511 100644
--- a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs
+++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs
@@ -28,5 +28,9 @@ public sealed record GetTenantPackageTenantsQuery : IRequest
public int PageSize { get; init; } = 20;
-}
+ ///
+ /// 可选:未来 N 天内到期筛选(按“当前有效订阅”口径,且到期时间在 now ~ now+N 天内)。
+ ///
+ public int? ExpiringWithinDays { get; init; }
+}