fix: make SMS retry side effects idempotent
This commit is contained in:
@@ -18,7 +18,8 @@ function createPrismaMock() {
|
||||
return Promise.resolve({ count: 1 });
|
||||
}),
|
||||
update: jest.fn().mockImplementation(({ data }) => {
|
||||
if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
|
||||
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
|
||||
else if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
|
||||
if (data.creditCents !== undefined) accountState.creditCents = data.creditCents;
|
||||
return Promise.resolve({ ...accountState });
|
||||
}),
|
||||
@@ -26,6 +27,7 @@ function createPrismaMock() {
|
||||
accountTransaction: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
||||
},
|
||||
rechargeOrder: {
|
||||
@@ -125,7 +127,7 @@ describe('BillingService', () => {
|
||||
expect(order).toEqual(expect.objectContaining({ amountCents: 500, status: 'paid' }));
|
||||
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1' },
|
||||
data: { balanceCents: 1500 },
|
||||
data: { balanceCents: { increment: 500 } },
|
||||
});
|
||||
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
@@ -302,6 +304,46 @@ describe('BillingService', () => {
|
||||
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
|
||||
});
|
||||
|
||||
it('serializes and replays concurrent refunds with one balance mutation', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
let transactionChain = Promise.resolve<unknown>(undefined);
|
||||
let persistedTransaction: Record<string, unknown> | null = null;
|
||||
prisma.$transaction.mockImplementation((callback) => {
|
||||
const run = transactionChain.then(() => callback(prisma));
|
||||
transactionChain = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
});
|
||||
prisma.accountTransaction.findUnique.mockImplementation(() => Promise.resolve(persistedTransaction));
|
||||
prisma.accountTransaction.create.mockImplementation(({ data }) => {
|
||||
persistedTransaction = { id: 'tx-refund-once', ...data };
|
||||
return Promise.resolve(persistedTransaction);
|
||||
});
|
||||
const service = new BillingService(prisma as never);
|
||||
const refund = {
|
||||
tenantId: 'tenant-1',
|
||||
amountCents: 1053,
|
||||
idempotencyKey: 'sms-refund:MSG-LONG-RACE',
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: 'MSG-LONG-RACE',
|
||||
remark: '最终失败退款',
|
||||
};
|
||||
|
||||
const results = await Promise.all([
|
||||
service.refund(refund),
|
||||
service.refund(refund),
|
||||
service.refund(refund),
|
||||
]);
|
||||
|
||||
expect(results.map((result) => result.id)).toEqual([
|
||||
'tx-refund-once',
|
||||
'tx-refund-once',
|
||||
'tx-refund-once',
|
||||
]);
|
||||
expect(prisma.tenantAccount.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.accountTransaction.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.accountState.balanceCents).toBe(2053);
|
||||
});
|
||||
|
||||
it('creates SMS billing records linked to message and task identifiers', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface UpdateCreditLimitDto {
|
||||
export interface CreateAccountTransactionDto {
|
||||
tenantId: string;
|
||||
transactionType: string;
|
||||
idempotencyKey?: string;
|
||||
amountCents?: number;
|
||||
balanceAfter?: number;
|
||||
relatedType?: string;
|
||||
@@ -67,6 +68,7 @@ export interface EstimateSmsCostDto {
|
||||
|
||||
export interface BillingActionDto {
|
||||
tenantId: string;
|
||||
idempotencyKey?: string;
|
||||
amountCents?: number;
|
||||
relatedType?: string;
|
||||
relatedId?: string;
|
||||
@@ -454,25 +456,46 @@ export class BillingService {
|
||||
}
|
||||
|
||||
private async applyAccountDelta(data: CreateAccountTransactionDto) {
|
||||
const account = await this.getAccountOrCreate(data.tenantId);
|
||||
const nextBalance = moneyToNumber(account.balanceCents) + (data.amountCents ?? 0);
|
||||
await this.prisma.tenantAccount.update({
|
||||
where: { tenantId: data.tenantId },
|
||||
data: {
|
||||
balanceCents: nextBalance,
|
||||
},
|
||||
});
|
||||
|
||||
return this.prisma.accountTransaction.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
transactionType: data.transactionType,
|
||||
amountCents: data.amountCents ?? 0,
|
||||
balanceAfter: nextBalance,
|
||||
relatedType: data.relatedType,
|
||||
relatedId: data.relatedId,
|
||||
remark: data.remark,
|
||||
},
|
||||
const amountCents = data.amountCents ?? 0;
|
||||
const idempotencyKey = data.idempotencyKey?.trim() || null;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + data.tenantId}, 0))`;
|
||||
if (idempotencyKey) {
|
||||
const existing = await tx.accountTransaction.findUnique({ where: { idempotencyKey } });
|
||||
if (existing) {
|
||||
if (
|
||||
existing.tenantId !== data.tenantId
|
||||
|| existing.transactionType !== data.transactionType
|
||||
|| moneyToNumber(existing.amountCents) !== amountCents
|
||||
|| existing.relatedType !== (data.relatedType ?? null)
|
||||
|| existing.relatedId !== (data.relatedId ?? null)
|
||||
) {
|
||||
throw new ConflictException('账务幂等键已用于另一笔交易');
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
await tx.tenantAccount.upsert({
|
||||
where: { tenantId: data.tenantId },
|
||||
update: {},
|
||||
create: { tenantId: data.tenantId, balanceCents: 0, creditCents: 0, status: 'active' },
|
||||
});
|
||||
const account = await tx.tenantAccount.update({
|
||||
where: { tenantId: data.tenantId },
|
||||
data: { balanceCents: { increment: amountCents } },
|
||||
});
|
||||
return tx.accountTransaction.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
transactionType: data.transactionType,
|
||||
idempotencyKey,
|
||||
amountCents,
|
||||
balanceAfter: account.balanceCents,
|
||||
relatedType: data.relatedType,
|
||||
relatedId: data.relatedId,
|
||||
remark: data.remark,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user