perf: batch paid CMPP accounting and weighted routes
This commit is contained in:
@@ -13,10 +13,12 @@ function createPrismaMock() {
|
||||
tenantAccount: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||
findUniqueOrThrow: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||
create: jest.fn(),
|
||||
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||
updateMany: jest.fn().mockImplementation(({ data }) => {
|
||||
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
|
||||
else if (data.balanceCents?.decrement !== undefined) accountState.balanceCents -= data.balanceCents.decrement;
|
||||
accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1);
|
||||
return Promise.resolve({ count: 1 });
|
||||
}),
|
||||
@@ -31,7 +33,9 @@ function createPrismaMock() {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
findUniqueOrThrow: jest.fn().mockImplementation(({ where }) => Promise.resolve({ id: 'tx-charged', idempotencyKey: where.idempotencyKey })),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
rechargeOrder: {
|
||||
findMany: jest.fn(),
|
||||
@@ -94,7 +98,7 @@ describe('BillingService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('allows sending only when cash balance plus credit is greater than zero', async () => {
|
||||
it('allows sending only when cash balance plus credit covers the required amount', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
@@ -102,7 +106,7 @@ describe('BillingService', () => {
|
||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
||||
);
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
|
||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
||||
expect.objectContaining({ availableAmount: 1000, canSend: false }),
|
||||
);
|
||||
await service.updateCreditLimit('tenant-1', { creditCents: -1000, operatorId: 'admin-1' });
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1 })).resolves.toEqual(
|
||||
@@ -110,7 +114,7 @@ describe('BillingService', () => {
|
||||
);
|
||||
await service.updateCreditLimit('tenant-1', { creditCents: 500, operatorId: 'admin-1' });
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 999999 })).resolves.toEqual(
|
||||
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: true }),
|
||||
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: false }),
|
||||
);
|
||||
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度最多支持人民币小数点后 4 位');
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
@@ -312,6 +316,27 @@ describe('BillingService', () => {
|
||||
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
|
||||
});
|
||||
|
||||
it('settles a frozen SMS charge with one account lock and an idempotent ledger pair', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const rows = new Map<string, Record<string, unknown>>();
|
||||
prisma.accountTransaction.findUnique.mockImplementation(({ where }) => Promise.resolve(rows.get(where.idempotencyKey) ?? null));
|
||||
prisma.accountTransaction.findUniqueOrThrow.mockImplementation(({ where }) => Promise.resolve(rows.get(where.idempotencyKey)));
|
||||
prisma.accountTransaction.createMany.mockImplementation(({ data }) => {
|
||||
data.forEach((row: Record<string, unknown>) => rows.set(String(row.idempotencyKey), { id: `tx-${row.transactionType}`, ...row }));
|
||||
return Promise.resolve({ count: data.length });
|
||||
});
|
||||
const service = new BillingService(prisma as never);
|
||||
const input = { tenantId: 'tenant-1', amountCents: 325, messageId: 'msg-paid-1', taskId: 'task-paid-1' };
|
||||
|
||||
const first = await service.settleFrozenCharge(input);
|
||||
const replay = await service.settleFrozenCharge(input);
|
||||
|
||||
expect(first).toEqual(expect.objectContaining({ transactionType: 'charged', amountCents: -325 }));
|
||||
expect(replay).toEqual(first);
|
||||
expect(prisma.accountTransaction.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.tenantAccount.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serializes and replays concurrent refunds with one balance mutation', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
let transactionChain = Promise.resolve<unknown>(undefined);
|
||||
|
||||
@@ -416,7 +416,7 @@ export class BillingService {
|
||||
availableAmount,
|
||||
balanceCents,
|
||||
creditCents,
|
||||
canSend: availableAmount > 0,
|
||||
canSend: availableAmount >= requiredAmount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -436,6 +436,51 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
|
||||
async settleFrozenCharge(data: { tenantId: string; amountCents: number; messageId: string; taskId: string; remark?: string }) {
|
||||
const amountCents = data.amountCents ?? 0;
|
||||
if (amountCents <= 0) return null;
|
||||
const releaseKey = `sms-charge-release:${data.messageId}`;
|
||||
const chargeKey = `sms-charge:${data.messageId}`;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + data.tenantId}, 0))`;
|
||||
const [release, charge] = await Promise.all([
|
||||
tx.accountTransaction.findUnique({ where: { idempotencyKey: releaseKey } }),
|
||||
tx.accountTransaction.findUnique({ where: { idempotencyKey: chargeKey } }),
|
||||
]);
|
||||
if (charge) return charge;
|
||||
const account = await tx.tenantAccount.findUniqueOrThrow({ where: { tenantId: data.tenantId } });
|
||||
const balance = moneyToNumber(account.balanceCents);
|
||||
if (release) {
|
||||
const updated = await tx.tenantAccount.update({
|
||||
where: { tenantId: data.tenantId },
|
||||
data: { balanceCents: { decrement: amountCents } },
|
||||
});
|
||||
return tx.accountTransaction.create({
|
||||
data: {
|
||||
tenantId: data.tenantId, transactionType: 'charged', idempotencyKey: chargeKey,
|
||||
amountCents: -amountCents, balanceAfter: updated.balanceCents,
|
||||
relatedType: 'sms_message_record', relatedId: data.messageId, remark: '提交成功扣费',
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.accountTransaction.createMany({
|
||||
data: [
|
||||
{
|
||||
tenantId: data.tenantId, transactionType: 'released', idempotencyKey: releaseKey,
|
||||
amountCents, balanceAfter: balance + amountCents,
|
||||
relatedType: 'sms_batch_task', relatedId: data.taskId, remark: data.remark,
|
||||
},
|
||||
{
|
||||
tenantId: data.tenantId, transactionType: 'charged', idempotencyKey: chargeKey,
|
||||
amountCents: -amountCents, balanceAfter: balance,
|
||||
relatedType: 'sms_message_record', relatedId: data.messageId, remark: '提交成功扣费',
|
||||
},
|
||||
],
|
||||
});
|
||||
return tx.accountTransaction.findUniqueOrThrow({ where: { idempotencyKey: chargeKey } });
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted });
|
||||
}
|
||||
|
||||
release(data: BillingActionDto) {
|
||||
return this.applyAccountDelta({
|
||||
...data,
|
||||
|
||||
Reference in New Issue
Block a user