feat: harden platform workflows and UI governance
This commit is contained in:
@@ -2,10 +2,12 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import {
|
||||
BillingService,
|
||||
BillingActionDto,
|
||||
CreateManualRechargeDto,
|
||||
ManualRechargePreflightDto,
|
||||
CreateBillingRuleDto,
|
||||
CreateSmsBillingRecordDto,
|
||||
CreateTenantAccountDto,
|
||||
@@ -46,8 +48,13 @@ export class BillingController {
|
||||
|
||||
@Post('manual-recharges')
|
||||
@RequireRecentAuthentication()
|
||||
createManualRecharge(@Body() body: CreateManualRechargeDto) {
|
||||
return this.billing.createManualRecharge(body);
|
||||
createManualRecharge(@Body() body: CreateManualRechargeDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.billing.createManualRecharge({ ...body, operatorId });
|
||||
}
|
||||
|
||||
@Post('manual-recharges/preflight')
|
||||
manualRechargePreflight(@Body() body: ManualRechargePreflightDto) {
|
||||
return this.billing.manualRechargePreflight(body);
|
||||
}
|
||||
|
||||
@Post('estimate')
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0 };
|
||||
return {
|
||||
const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0, updatedAt: new Date('2026-07-21T10:00:00.000Z') };
|
||||
const prisma = {
|
||||
accountState,
|
||||
tenant: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
|
||||
},
|
||||
tenantAccount: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: 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;
|
||||
accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1);
|
||||
return Promise.resolve({ count: 1 });
|
||||
}),
|
||||
update: jest.fn().mockImplementation(({ data }) => {
|
||||
if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
|
||||
if (data.creditCents !== undefined) accountState.creditCents = data.creditCents;
|
||||
@@ -16,10 +25,12 @@ function createPrismaMock() {
|
||||
},
|
||||
accountTransaction: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
||||
},
|
||||
rechargeOrder: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'order-1', ...data })),
|
||||
},
|
||||
smsBillingRecord: {
|
||||
@@ -31,9 +42,14 @@ function createPrismaMock() {
|
||||
create: jest.fn(),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({ id: 'operation-1' }),
|
||||
},
|
||||
$executeRaw: jest.fn(),
|
||||
};
|
||||
return Object.assign(prisma, {
|
||||
$transaction: jest.fn((callback: (client: typeof prisma) => unknown) => callback(prisma)),
|
||||
});
|
||||
}
|
||||
|
||||
describe('BillingService', () => {
|
||||
@@ -129,6 +145,8 @@ describe('BillingService', () => {
|
||||
const order = await service.createManualRecharge({
|
||||
tenantId: 'tenant-1',
|
||||
amountCents: 2000,
|
||||
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
|
||||
idempotencyKey: 'manual-recharge-1',
|
||||
operatorId: 'admin-1',
|
||||
remark: '线下转账人工充值',
|
||||
});
|
||||
@@ -153,6 +171,7 @@ describe('BillingService', () => {
|
||||
action: 'billing.manual_recharge',
|
||||
resource: 'recharge_order',
|
||||
resourceId: 'order-1',
|
||||
detail: expect.objectContaining({ idempotencyKey: 'manual-recharge-1', previousBalanceCents: 1000, balanceAfterCents: 3000 }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -189,14 +208,16 @@ describe('BillingService', () => {
|
||||
const order = await service.createManualRecharge({
|
||||
tenantId: 'tenant-1',
|
||||
amountCents: -300,
|
||||
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
|
||||
idempotencyKey: 'manual-correction-1',
|
||||
operatorId: 'admin-1',
|
||||
remark: '人工冲正',
|
||||
});
|
||||
|
||||
expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' }));
|
||||
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1' },
|
||||
data: { balanceCents: 700 },
|
||||
expect(prisma.tenantAccount.updateMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', updatedAt: new Date('2026-07-21T10:00:00.000Z') },
|
||||
data: { balanceCents: { increment: -300 } },
|
||||
});
|
||||
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
@@ -214,6 +235,53 @@ describe('BillingService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preflights a manual balance correction with the persisted account version and predicted balance', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
await expect(service.manualRechargePreflight({ tenantId: 'tenant-1', amountCents: -250 })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
tenant: { id: 'tenant-1', name: '示例企业', code: 'TENANT-1' },
|
||||
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
|
||||
balanceCents: 1000,
|
||||
amountCents: -250,
|
||||
balanceAfterCents: 750,
|
||||
direction: 'correction',
|
||||
allowedActions: ['confirm'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('replays a manual recharge by idempotency key without creating another order', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.operationLog.findFirst.mockResolvedValue({
|
||||
id: 'operation-existing', tenantId: 'tenant-1', resourceId: 'order-existing',
|
||||
detail: { idempotencyKey: 'manual-recharge-replay', amountCents: 2000 },
|
||||
});
|
||||
prisma.rechargeOrder.findUnique.mockResolvedValue({ id: 'order-existing', tenantId: 'tenant-1', amountCents: 2000 });
|
||||
prisma.accountTransaction.findFirst.mockResolvedValue({ balanceAfter: 3000 });
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
await expect(service.createManualRecharge({
|
||||
tenantId: 'tenant-1', amountCents: 2000, expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z', idempotencyKey: 'manual-recharge-replay',
|
||||
})).resolves.toEqual(expect.objectContaining({ operationId: 'operation-existing', replayed: true, balanceAfterCents: 3000 }));
|
||||
expect(prisma.rechargeOrder.create).not.toHaveBeenCalled();
|
||||
expect(prisma.tenantAccount.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an outdated account version before creating financial records', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.tenantAccount.updateMany.mockResolvedValue({ count: 0 });
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
await expect(service.createManualRecharge({
|
||||
tenantId: 'tenant-1', amountCents: 2000, expectedAccountUpdatedAt: '2026-07-21T09:59:00.000Z', idempotencyKey: 'manual-recharge-stale',
|
||||
})).rejects.toThrow('企业余额已变化,请重新核对后再充值');
|
||||
expect(prisma.rechargeOrder.create).not.toHaveBeenCalled();
|
||||
expect(prisma.accountTransaction.create).not.toHaveBeenCalled();
|
||||
expect(prisma.operationLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes freeze, charge, release, refund, and adjustment transactions', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -45,10 +45,17 @@ export interface CreateRechargeOrderDto {
|
||||
export interface CreateManualRechargeDto {
|
||||
tenantId: string;
|
||||
amountCents: number;
|
||||
expectedAccountUpdatedAt: string;
|
||||
idempotencyKey: string;
|
||||
operatorId?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface ManualRechargePreflightDto {
|
||||
tenantId: string;
|
||||
amountCents: number;
|
||||
}
|
||||
|
||||
export interface EstimateSmsCostDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
@@ -187,28 +194,130 @@ export class BillingService {
|
||||
}
|
||||
|
||||
async createManualRecharge(data: CreateManualRechargeDto) {
|
||||
const order = await this.createRechargeOrder({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: data.amountCents,
|
||||
payMethod: 'manual_topup',
|
||||
operatorId: data.operatorId,
|
||||
remark: data.remark,
|
||||
assertMoneyUnits(data.amountCents, '充值金额', { allowNegative: true, allowZero: false });
|
||||
const idempotencyKey = data.idempotencyKey?.trim();
|
||||
if (!idempotencyKey || idempotencyKey.length < 8 || idempotencyKey.length > 128) {
|
||||
throw new BadRequestException('人工充值幂等键长度必须为 8 至 128 个字符');
|
||||
}
|
||||
const expectedUpdatedAt = new Date(data.expectedAccountUpdatedAt);
|
||||
if (Number.isNaN(expectedUpdatedAt.getTime())) throw new BadRequestException('账户版本无效,请重新核对充值信息');
|
||||
|
||||
const replay = await this.prisma.operationLog.findFirst({
|
||||
where: { action: 'billing.manual_recharge', detail: { path: ['idempotencyKey'], equals: idempotencyKey } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
userId: data.operatorId,
|
||||
action: 'billing.manual_recharge',
|
||||
resource: 'recharge_order',
|
||||
resourceId: order.id,
|
||||
detail: {
|
||||
if (replay) return this.manualRechargeReplay(replay, data);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'manual-recharge:' + idempotencyKey}))`;
|
||||
const existing = await tx.operationLog.findFirst({
|
||||
where: { action: 'billing.manual_recharge', detail: { path: ['idempotencyKey'], equals: idempotencyKey } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (existing) {
|
||||
const detail = asRecord(existing.detail);
|
||||
if (existing.tenantId !== data.tenantId || Number(detail.amountCents) !== data.amountCents) {
|
||||
throw new ConflictException('该幂等键已用于另一笔人工充值');
|
||||
}
|
||||
const order = existing.resourceId ? await tx.rechargeOrder.findUnique({ where: { id: existing.resourceId } }) : null;
|
||||
if (!order) throw new ConflictException('幂等充值记录不完整,请联系管理员核查');
|
||||
const transaction = await tx.accountTransaction.findFirst({
|
||||
where: { relatedType: 'recharge_order', relatedId: order.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return { ...order, balanceAfterCents: moneyToNumber(transaction?.balanceAfter), operationId: existing.id, replayed: true };
|
||||
}
|
||||
|
||||
const tenant = await tx.tenant.findFirst({ where: { id: data.tenantId, status: { not: 'deleted' } }, select: { id: true } });
|
||||
if (!tenant) throw new NotFoundException('充值企业不存在或已删除');
|
||||
const account = await tx.tenantAccount.findUnique({ where: { tenantId: data.tenantId } });
|
||||
if (!account) throw new ConflictException('企业账户尚未初始化,请重新核对充值信息');
|
||||
const previousBalanceCents = moneyToNumber(account.balanceCents);
|
||||
const changed = await tx.tenantAccount.updateMany({
|
||||
where: { tenantId: data.tenantId, updatedAt: expectedUpdatedAt },
|
||||
data: { balanceCents: { increment: data.amountCents } },
|
||||
});
|
||||
if (changed.count !== 1) throw new ConflictException('企业余额已变化,请重新核对后再充值');
|
||||
const balanceAfterCents = previousBalanceCents + data.amountCents;
|
||||
const order = await tx.rechargeOrder.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
orderNo: `MR${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`,
|
||||
amountCents: data.amountCents,
|
||||
orderNo: order.orderNo,
|
||||
status: 'paid',
|
||||
payMethod: 'manual_topup',
|
||||
paidAt: new Date(),
|
||||
operatorId: data.operatorId,
|
||||
remark: data.remark,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.accountTransaction.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
transactionType: 'recharge',
|
||||
amountCents: data.amountCents,
|
||||
balanceAfter: balanceAfterCents,
|
||||
relatedType: 'recharge_order',
|
||||
relatedId: order.id,
|
||||
remark: data.remark,
|
||||
},
|
||||
});
|
||||
const operation = await tx.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
userId: data.operatorId,
|
||||
action: 'billing.manual_recharge',
|
||||
resource: 'recharge_order',
|
||||
resourceId: order.id,
|
||||
detail: {
|
||||
idempotencyKey,
|
||||
amountCents: data.amountCents,
|
||||
previousBalanceCents,
|
||||
balanceAfterCents,
|
||||
orderNo: order.orderNo,
|
||||
remark: data.remark,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return { ...order, balanceAfterCents, operationId: operation.id, replayed: false };
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||
}
|
||||
|
||||
async manualRechargePreflight(data: ManualRechargePreflightDto) {
|
||||
assertMoneyUnits(data.amountCents, '充值金额', { allowNegative: true, allowZero: false });
|
||||
const tenant = await this.prisma.tenant.findFirst({
|
||||
where: { id: data.tenantId, status: { not: 'deleted' } },
|
||||
select: { id: true, name: true, code: true },
|
||||
});
|
||||
return order;
|
||||
if (!tenant) throw new NotFoundException('充值企业不存在或已删除');
|
||||
const account = await this.getAccountOrCreate(data.tenantId);
|
||||
const balanceCents = moneyToNumber(account.balanceCents);
|
||||
return {
|
||||
tenant,
|
||||
accountId: account.id,
|
||||
expectedAccountUpdatedAt: account.updatedAt.toISOString(),
|
||||
balanceCents,
|
||||
creditCents: moneyToNumber(account.creditCents),
|
||||
amountCents: data.amountCents,
|
||||
balanceAfterCents: balanceCents + data.amountCents,
|
||||
direction: data.amountCents > 0 ? 'topup' : 'correction',
|
||||
allowedActions: ['confirm'],
|
||||
blockedReasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async manualRechargeReplay(log: { id: string; tenantId: string | null; resourceId: string | null; detail: Prisma.JsonValue | null }, data: CreateManualRechargeDto) {
|
||||
const detail = asRecord(log.detail);
|
||||
if (log.tenantId !== data.tenantId || Number(detail.amountCents) !== data.amountCents) {
|
||||
throw new ConflictException('该幂等键已用于另一笔人工充值');
|
||||
}
|
||||
const order = log.resourceId ? await this.prisma.rechargeOrder.findUnique({ where: { id: log.resourceId } }) : null;
|
||||
if (!order) throw new ConflictException('幂等充值记录不完整,请联系管理员核查');
|
||||
const transaction = await this.prisma.accountTransaction.findFirst({
|
||||
where: { relatedType: 'recharge_order', relatedId: order.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return { ...order, balanceAfterCents: moneyToNumber(transaction?.balanceAfter), operationId: log.id, replayed: true };
|
||||
}
|
||||
|
||||
estimateSmsCost(data: EstimateSmsCostDto) {
|
||||
@@ -375,3 +484,7 @@ function estimateBillingUnits(content: string) {
|
||||
}
|
||||
return Math.ceil(length / 67);
|
||||
}
|
||||
|
||||
function asRecord(value: Prisma.JsonValue | null | undefined): Record<string, Prisma.JsonValue> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, Prisma.JsonValue> : {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user