问题: - Npgsql.NpgsqlOperationInProgressException: A command is already in progress - 在同一个数据库连接上,billReader 未释放就执行 paymentReader 根因: - GetBillingDetailQueryHandler 中先查询账单并打开 billReader - 读取账单数据后未释放 reader - 直接在同一连接上执行支付记录查询,触发并发异常 解决方案: - 将账单字段先读取到本地变量 - 主动 DisposeAsync 释放 billReader - 再执行支付记录查询 - 最后用本地变量组装 BillingDetailDto
237 lines
9.8 KiB
C#
237 lines
9.8 KiB
C#
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;
|
||
}
|
||
}
|