using MediatR; using System.Data; using System.Data.Common; using TakeoutSaaS.Application.App.Billings.Dto; using TakeoutSaaS.Application.App.Billings.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.Billings.Handlers; /// /// 分页查询账单列表处理器。 /// public sealed class GetBillingListQueryHandler( IDapperExecutor dapperExecutor) : IRequestHandler> { /// /// 处理分页查询账单列表请求。 /// /// 查询命令。 /// 取消标记。 /// 分页账单列表 DTO。 public async Task> Handle(GetBillingListQuery request, CancellationToken cancellationToken) { // 1. 参数规范化 var page = request.PageNumber <= 0 ? 1 : request.PageNumber; var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize; var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim(); var minAmount = request.MinAmount; var maxAmount = request.MaxAmount; var offset = (page - 1) * pageSize; // 1.1 金额区间规范化(避免 min > max 导致结果为空) if (minAmount.HasValue && maxAmount.HasValue && minAmount.Value > maxAmount.Value) { (minAmount, maxAmount) = (maxAmount, minAmount); } // 2. 排序白名单(防 SQL 注入) var orderBy = request.SortBy?.Trim() switch { "DueDate" => "b.\"DueDate\"", "AmountDue" => "b.\"AmountDue\"", "PeriodStart" => "b.\"PeriodStart\"", "PeriodEnd" => "b.\"PeriodEnd\"", "CreatedAt" => "b.\"CreatedAt\"", _ => "b.\"CreatedAt\"" }; // 3. 查询总数 + 列表 return await dapperExecutor.QueryAsync( DatabaseConstants.AppDataSource, DatabaseConnectionRole.Read, async (connection, token) => { // 3.1 统计总数 var total = await ExecuteScalarIntAsync( connection, BuildCountSql(), [ ("tenantId", request.TenantId), ("status", request.Status.HasValue ? (int)request.Status.Value : null), ("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null), ("startDate", request.StartDate), ("endDate", request.EndDate), ("minAmount", minAmount), ("maxAmount", maxAmount), ("keyword", keyword) ], token); // 3.2 查询列表 var listSql = BuildListSql(orderBy, request.SortDesc); await using var listCommand = CreateCommand( connection, listSql, [ ("tenantId", request.TenantId), ("status", request.Status.HasValue ? (int)request.Status.Value : null), ("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null), ("startDate", request.StartDate), ("endDate", request.EndDate), ("minAmount", minAmount), ("maxAmount", maxAmount), ("keyword", keyword), ("offset", offset), ("limit", pageSize) ]); await using var reader = await listCommand.ExecuteReaderAsync(token); var now = DateTime.UtcNow; var items = new List(); while (await reader.ReadAsync(token)) { var dueDate = reader.GetDateTime(13); var status = (TenantBillingStatus)reader.GetInt32(12); var amountDue = reader.GetDecimal(8); var discountAmount = reader.GetDecimal(9); var taxAmount = reader.GetDecimal(10); var totalAmount = amountDue - discountAmount + taxAmount; // 3.2.1 逾期辅助字段 var isOverdue = status is TenantBillingStatus.Overdue || (status is TenantBillingStatus.Pending && dueDate < now); var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0; items.Add(new BillingListDto { Id = reader.GetInt64(0), TenantId = reader.GetInt64(1), TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2), SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3), StatementNo = reader.GetString(4), BillingType = (BillingType)reader.GetInt32(5), PeriodStart = reader.GetDateTime(6), PeriodEnd = reader.GetDateTime(7), AmountDue = amountDue, DiscountAmount = discountAmount, TaxAmount = taxAmount, TotalAmount = totalAmount, AmountPaid = reader.GetDecimal(11), Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14), Status = status, DueDate = dueDate, IsOverdue = isOverdue, OverdueDays = overdueDays, CreatedAt = reader.GetDateTime(15), UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16) }); } // 3.3 返回分页 return new PagedResult(items, page, pageSize, total); }, cancellationToken); } private static string BuildCountSql() { return """ select count(*) from public.tenant_billing_statements b join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null where b."DeletedAt" is null and (@tenantId::bigint is null or b."TenantId" = @tenantId) and (@status::int is null or b."Status" = @status) and (@billingType::int is null or b."BillingType" = @billingType) and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate) and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate) and (@minAmount::numeric is null or b."AmountDue" >= @minAmount) and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount) and ( @keyword::text is null or b."StatementNo" ilike ('%' || @keyword::text || '%') or t."Name" ilike ('%' || @keyword::text || '%') ); """; } private static string BuildListSql(string orderBy, bool sortDesc) { var direction = sortDesc ? "desc" : "asc"; return $""" select b."Id", b."TenantId", t."Name" as "TenantName", b."SubscriptionId", b."StatementNo", b."BillingType", b."PeriodStart", b."PeriodEnd", b."AmountDue", b."DiscountAmount", b."TaxAmount", b."AmountPaid", b."Status", b."DueDate", b."Currency", b."CreatedAt", b."UpdatedAt" from public.tenant_billing_statements b join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null where b."DeletedAt" is null and (@tenantId::bigint is null or b."TenantId" = @tenantId) and (@status::int is null or b."Status" = @status) and (@billingType::int is null or b."BillingType" = @billingType) and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate) and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate) and (@minAmount::numeric is null or b."AmountDue" >= @minAmount) and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount) and ( @keyword::text is null or b."StatementNo" ilike ('%' || @keyword::text || '%') or t."Name" ilike ('%' || @keyword::text || '%') ) order by {orderBy} {direction} 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; } }