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
+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> : {};
}