Files
TakeoutSaaS.AdminApi/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs
MSuMshk a5abd6ef90 fix(billing): 修复账单详情查询的数据库并发错误
问题:
- Npgsql.NpgsqlOperationInProgressException: A command is already in progress
- 在同一个数据库连接上,billReader 未释放就执行 paymentReader

根因:
- GetBillingDetailQueryHandler 中先查询账单并打开 billReader
- 读取账单数据后未释放 reader
- 直接在同一连接上执行支付记录查询,触发并发异常

解决方案:
- 将账单字段先读取到本地变量
- 主动 DisposeAsync 释放 billReader
- 再执行支付记录查询
- 最后用本地变量组装 BillingDetailDto
2025-12-18 11:45:44 +08:00

237 lines
9.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MediatR;
using System.Data;
using System.Data.Common;
using System.Text.Json;
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.Exceptions;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 查询账单详情处理器。
/// </summary>
public sealed class GetBillingDetailQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<GetBillingDetailQuery, BillingDetailDto>
{
/// <summary>
/// 处理查询账单详情请求。
/// </summary>
/// <param name="request">查询命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单详情 DTO。</returns>
public async Task<BillingDetailDto> Handle(GetBillingDetailQuery request, CancellationToken cancellationToken)
{
// 1. 查询账单 + 支付记录(同一连接,避免多次往返)
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 1.1 查询账单
await using var billCommand = CreateCommand(
connection,
BuildBillingSql(),
[
("billingId", request.BillingId)
]);
await using var billReader = await billCommand.ExecuteReaderAsync(token);
if (!await billReader.ReadAsync(token))
{
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
}
// 1.2 (空行后) 读取账单行数据到内存(释放 Reader避免同连接并发执行命令
var billingId = billReader.GetInt64(0);
var tenantId = billReader.GetInt64(1);
var tenantName = billReader.IsDBNull(2) ? string.Empty : billReader.GetString(2);
long? subscriptionId = billReader.IsDBNull(3) ? null : billReader.GetInt64(3);
var statementNo = billReader.GetString(4);
var billingType = (BillingType)billReader.GetInt32(5);
var status = (TenantBillingStatus)billReader.GetInt32(6);
var periodStart = billReader.GetDateTime(7);
var periodEnd = billReader.GetDateTime(8);
var amountDue = billReader.GetDecimal(9);
var discountAmount = billReader.GetDecimal(10);
var taxAmount = billReader.GetDecimal(11);
var amountPaid = billReader.GetDecimal(12);
var currency = billReader.IsDBNull(13) ? "CNY" : billReader.GetString(13);
var dueDate = billReader.GetDateTime(14);
DateTime? reminderSentAt = billReader.IsDBNull(15) ? null : billReader.GetDateTime(15);
DateTime? overdueNotifiedAt = billReader.IsDBNull(16) ? null : billReader.GetDateTime(16);
var notes = billReader.IsDBNull(17) ? null : billReader.GetString(17);
var lineItemsJson = billReader.IsDBNull(18) ? null : billReader.GetString(18);
var createdAt = billReader.GetDateTime(19);
long? createdBy = billReader.IsDBNull(20) ? null : billReader.GetInt64(20);
DateTime? updatedAt = billReader.IsDBNull(21) ? null : billReader.GetDateTime(21);
long? updatedBy = billReader.IsDBNull(22) ? null : billReader.GetInt64(22);
// 1.3 (空行后) 主动释放账单 Reader确保后续查询不会触发 Npgsql 并发命令异常
await billReader.DisposeAsync();
// 1.4 (空行后) 反序列化账单明细
var lineItems = new List<BillingLineItemDto>();
if (!string.IsNullOrWhiteSpace(lineItemsJson))
{
try
{
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(lineItemsJson) ?? [];
}
catch
{
lineItems = [];
}
}
// 1.5 (空行后) 查询支付记录
var payments = new List<PaymentRecordDto>();
await using var paymentCommand = CreateCommand(
connection,
BuildPaymentsSql(),
[
("billingId", request.BillingId)
]);
await using var paymentReader = await paymentCommand.ExecuteReaderAsync(token);
while (await paymentReader.ReadAsync(token))
{
payments.Add(new PaymentRecordDto
{
Id = paymentReader.GetInt64(0),
TenantId = paymentReader.GetInt64(1),
BillingId = paymentReader.GetInt64(2),
Amount = paymentReader.GetDecimal(3),
Method = (TenantPaymentMethod)paymentReader.GetInt32(4),
Status = (TenantPaymentStatus)paymentReader.GetInt32(5),
TransactionNo = paymentReader.IsDBNull(6) ? null : paymentReader.GetString(6),
ProofUrl = paymentReader.IsDBNull(7) ? null : paymentReader.GetString(7),
Notes = paymentReader.IsDBNull(8) ? null : paymentReader.GetString(8),
VerifiedBy = paymentReader.IsDBNull(9) ? null : paymentReader.GetInt64(9),
VerifiedAt = paymentReader.IsDBNull(10) ? null : paymentReader.GetDateTime(10),
RefundReason = paymentReader.IsDBNull(11) ? null : paymentReader.GetString(11),
RefundedAt = paymentReader.IsDBNull(12) ? null : paymentReader.GetDateTime(12),
PaidAt = paymentReader.IsDBNull(13) ? null : paymentReader.GetDateTime(13),
IsVerified = !paymentReader.IsDBNull(10),
CreatedAt = paymentReader.GetDateTime(14)
});
}
// 1.6 (空行后) 组装详情 DTO
var totalAmount = amountDue - discountAmount + taxAmount;
return new BillingDetailDto
{
Id = billingId,
TenantId = tenantId,
TenantName = tenantName,
SubscriptionId = subscriptionId,
StatementNo = statementNo,
BillingType = billingType,
Status = status,
PeriodStart = periodStart,
PeriodEnd = periodEnd,
AmountDue = amountDue,
DiscountAmount = discountAmount,
TaxAmount = taxAmount,
TotalAmount = totalAmount,
AmountPaid = amountPaid,
Currency = currency,
DueDate = dueDate,
ReminderSentAt = reminderSentAt,
OverdueNotifiedAt = overdueNotifiedAt,
LineItemsJson = lineItemsJson,
LineItems = lineItems,
Payments = payments,
Notes = notes,
CreatedAt = createdAt,
CreatedBy = createdBy,
UpdatedAt = updatedAt,
UpdatedBy = updatedBy
};
},
cancellationToken);
}
private static string BuildBillingSql()
{
return """
select
b."Id",
b."TenantId",
t."Name" as "TenantName",
b."SubscriptionId",
b."StatementNo",
b."BillingType",
b."Status",
b."PeriodStart",
b."PeriodEnd",
b."AmountDue",
b."DiscountAmount",
b."TaxAmount",
b."AmountPaid",
b."Currency",
b."DueDate",
b."ReminderSentAt",
b."OverdueNotifiedAt",
b."Notes",
b."LineItemsJson",
b."CreatedAt",
b."CreatedBy",
b."UpdatedAt",
b."UpdatedBy"
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 b."Id" = @billingId
limit 1;
""";
}
private static string BuildPaymentsSql()
{
return """
select
p."Id",
p."TenantId",
p."BillingStatementId",
p."Amount",
p."Method",
p."Status",
p."TransactionNo",
p."ProofUrl",
p."Notes",
p."VerifiedBy",
p."VerifiedAt",
p."RefundReason",
p."RefundedAt",
p."PaidAt",
p."CreatedAt"
from public.tenant_payments p
where p."DeletedAt" is null
and p."BillingStatementId" = @billingId
order by p."CreatedAt" desc;
""";
}
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;
}
}