feat: harden platform workflows and UI governance
This commit is contained in:
@@ -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