feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
+9 -2
View File
@@ -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')
+74 -6
View File
@@ -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);
+132 -19
View File
@@ -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> : {};
}