Files
lislgosms/api/src/billing/billing.service.ts
T

626 lines
24 KiB
TypeScript

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';
export interface CreateTenantAccountDto {
tenantId: string;
balanceCents?: number;
creditCents?: number;
status?: string;
}
export interface UpdateCreditLimitDto {
creditCents: number;
operatorId?: string;
remark?: string;
}
export interface CreateAccountTransactionDto {
tenantId: string;
transactionType: string;
idempotencyKey?: string;
amountCents?: number;
balanceAfter?: number;
relatedType?: string;
relatedId?: string;
remark?: string;
}
export interface CreateBillingRuleDto {
code: string;
name: string;
chargeBasis?: string;
unitPrice: number;
status?: string;
}
export interface CreateRechargeOrderDto {
tenantId: string;
amountCents: number;
payMethod?: string;
operatorId?: string;
remark?: string;
}
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;
content: string;
phoneCount: number;
unitPrice?: number;
taskId?: string;
}
export interface BillingActionDto {
tenantId: string;
idempotencyKey?: string;
amountCents?: number;
relatedType?: string;
relatedId?: string;
remark?: string;
}
export interface CreateSmsBillingRecordDto {
tenantId: string;
applicationId?: string;
taskId?: string;
messageId?: string;
phoneNumber?: string;
content: string;
unitPrice?: number;
}
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
listAccounts() {
return this.prisma.tenantAccount.findMany({
include: { tenant: true },
orderBy: { createdAt: 'desc' },
});
}
createAccount(data: CreateTenantAccountDto) {
assertMoneyUnits(data.balanceCents ?? 0, '账户余额', { allowNegative: true });
assertMoneyUnits(data.creditCents ?? 0, '授信额度', { allowNegative: true });
const createData: Prisma.TenantAccountUncheckedCreateInput = {
tenantId: data.tenantId,
balanceCents: data.balanceCents ?? 0,
creditCents: data.creditCents ?? 0,
status: data.status ?? 'active',
};
return this.prisma.tenantAccount.create({ data: createData });
}
async updateCreditLimit(tenantId: string, data: UpdateCreditLimitDto) {
assertMoneyUnits(data.creditCents, '授信额度', { allowNegative: true });
const account = await this.getAccountOrCreate(tenantId);
const updated = await this.prisma.tenantAccount.update({
where: { tenantId },
data: { creditCents: data.creditCents },
});
await this.prisma.operationLog.create({
data: {
tenantId,
userId: data.operatorId,
action: 'billing.credit_limit_updated',
resource: 'tenant_account',
resourceId: account.id,
detail: {
previousCreditCents: moneyToNumber(account.creditCents),
creditCents: data.creditCents,
remark: data.remark,
} as Prisma.InputJsonValue,
},
});
return updated;
}
listRechargeOrders(tenantId?: string) {
return this.prisma.rechargeOrder.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
});
}
async listRechargeOrdersPage(query: { tenantId?: string; page?: number; pageSize?: number }) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.RechargeOrderWhereInput = query.tenantId ? { tenantId: query.tenantId } : {};
const [items, total] = await Promise.all([
this.prisma.rechargeOrder.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }),
this.prisma.rechargeOrder.count({ where }),
]);
return { items, total, page, pageSize };
}
async listManualRechargeRecords(tenantId?: string) {
const orders = await this.prisma.rechargeOrder.findMany({
where: {
tenantId,
payMethod: 'manual_topup',
},
orderBy: { createdAt: 'desc' },
});
const orderIds = orders.map((order) => order.id);
if (orderIds.length === 0) {
return orders;
}
const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))];
const [transactions, operators] = await Promise.all([
this.prisma.accountTransaction.findMany({
where: {
relatedType: 'recharge_order',
relatedId: { in: orderIds },
},
select: { relatedId: true, balanceAfter: true },
}),
operatorIds.length ? this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, displayName: true, username: true },
}) : [],
]);
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)]));
const operatorNameById = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
return orders.map((order) => ({
...order,
balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null,
operatorName: order.operatorId ? operatorNameById.get(order.operatorId) ?? null : null,
}));
}
async listManualRechargeRecordsPage(query: { tenantId?: string; enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.RechargeOrderWhereInput = {
tenantId: query.tenantId,
payMethod: 'manual_topup',
tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined,
createdAt: query.createdAtFrom || query.createdAtTo ? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
} : undefined,
};
const [orders, total] = await Promise.all([
this.prisma.rechargeOrder.findMany({ where, include: { tenant: true }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }),
this.prisma.rechargeOrder.count({ where }),
]);
const orderIds = orders.map((order) => order.id);
const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))];
const [transactions, operators] = await Promise.all([
orderIds.length ? this.prisma.accountTransaction.findMany({
where: { relatedType: 'recharge_order', relatedId: { in: orderIds } },
select: { relatedId: true, balanceAfter: true },
}) : [],
operatorIds.length ? this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, displayName: true, username: true },
}) : [],
]);
const balances = new Map(transactions.map((item) => [item.relatedId, moneyToNumber(item.balanceAfter)]));
const operatorNames = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
return {
items: orders.map((order) => ({
...order,
balanceAfterCents: balances.get(order.id) ?? null,
operatorName: order.operatorId ? operatorNames.get(order.operatorId) ?? null : null,
})),
total,
page,
pageSize,
};
}
async createRechargeOrder(data: CreateRechargeOrderDto) {
const amountCents = data.amountCents;
assertMoneyUnits(amountCents, '充值金额', { allowNegative: true, allowZero: false });
const order = await this.prisma.rechargeOrder.create({
data: {
tenantId: data.tenantId,
orderNo: `R${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`,
amountCents,
status: 'paid',
payMethod: data.payMethod ?? 'manual',
paidAt: new Date(),
operatorId: data.operatorId,
remark: data.remark,
},
});
await this.applyAccountDelta({
tenantId: data.tenantId,
transactionType: 'recharge',
amountCents,
relatedType: 'recharge_order',
relatedId: order.id,
remark: data.remark,
});
return order;
}
async createManualRecharge(data: CreateManualRechargeDto) {
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' },
});
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,
status: 'paid',
payMethod: 'manual_topup',
paidAt: new Date(),
operatorId: data.operatorId,
remark: data.remark,
},
});
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 },
});
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) {
const billingUnits = estimateBillingUnits(data.content);
const unitPrice = data.unitPrice ?? 0;
assertMoneyUnits(unitPrice, '短信单价');
const totalUnits = billingUnits * data.phoneCount;
const amountCents = totalUnits * unitPrice;
assertMoneyUnits(amountCents, '短信计费金额');
return {
tenantId: data.tenantId,
applicationId: data.applicationId,
taskId: data.taskId,
contentLength: [...data.content].length,
phoneCount: data.phoneCount,
billingUnitsPerMessage: billingUnits,
totalBillingUnits: totalUnits,
unitPrice,
amountCents,
};
}
async checkAccount(data: BillingActionDto) {
const account = await this.getAccountOrCreate(data.tenantId);
const requiredAmount = data.amountCents ?? 0;
const balanceCents = moneyToNumber(account.balanceCents);
const creditCents = moneyToNumber(account.creditCents);
const availableAmount = balanceCents + creditCents;
return {
tenantId: data.tenantId,
requiredAmount,
availableAmount,
balanceCents,
creditCents,
canSend: availableAmount >= requiredAmount,
};
}
freeze(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'frozen',
amountCents: -(data.amountCents ?? 0),
});
}
charge(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'charged',
amountCents: -(data.amountCents ?? 0),
});
}
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}`;
const existing = await this.prisma.accountTransaction.findMany({
where: { idempotencyKey: { in: [releaseKey, chargeKey] } },
});
const charge = existing.find((row) => row.idempotencyKey === chargeKey);
if (charge) return charge;
const release = existing.find((row) => row.idempotencyKey === releaseKey);
if (release) {
// Recover the legacy two-transaction boundary: a crash may have committed
// release before charge, so this path must perform the missing balance debit.
return this.applyAccountDelta({
tenantId: data.tenantId, transactionType: 'charged', idempotencyKey: chargeKey,
amountCents: -amountCents, relatedType: 'sms_message_record', relatedId: data.messageId,
remark: '提交成功扣费(恢复既有已释放冻结)',
});
}
const rows = await this.prisma.$queryRaw<Array<{ id: string; idempotencyKey: string }>>(Prisma.sql`
WITH account AS (
SELECT "balanceCents" FROM "TenantAccount" WHERE "tenantId" = ${data.tenantId}
), inserted AS (
INSERT INTO "AccountTransaction" (
id, "tenantId", "transactionType", "idempotencyKey", "amountCents",
"balanceAfter", "relatedType", "relatedId", remark, "createdAt"
)
SELECT gen_random_uuid()::text, ${data.tenantId}, ledger."transactionType", ledger."idempotencyKey",
ledger."amountCents", account."balanceCents" + ledger."balanceDelta",
ledger."relatedType", ledger."relatedId", ledger.remark, (NOW() AT TIME ZONE 'UTC')
FROM account
CROSS JOIN (VALUES
('released', ${releaseKey}, ${amountCents}::bigint, ${amountCents}::bigint, 'sms_batch_task', ${data.taskId}, ${data.remark ?? null}),
('charged', ${chargeKey}, ${-amountCents}::bigint, 0::bigint, 'sms_message_record', ${data.messageId}, '提交成功扣费')
) AS ledger("transactionType", "idempotencyKey", "amountCents", "balanceDelta", "relatedType", "relatedId", remark)
ON CONFLICT ("idempotencyKey") DO NOTHING
RETURNING id, "idempotencyKey"
)
SELECT id, "idempotencyKey" FROM inserted WHERE "idempotencyKey" = ${chargeKey}
UNION ALL
SELECT id, "idempotencyKey" FROM "AccountTransaction" WHERE "idempotencyKey" = ${chargeKey}
LIMIT 1
`);
if (rows[0]) return rows[0];
// A concurrent identical callback can win ON CONFLICT while remaining
// invisible to this statement's snapshot; one read repairs that MVCC edge.
return this.prisma.accountTransaction.findUniqueOrThrow({ where: { idempotencyKey: chargeKey } });
}
release(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'released',
amountCents: data.amountCents ?? 0,
});
}
refund(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'refunded',
amountCents: data.amountCents ?? 0,
});
}
adjust(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'adjusted',
amountCents: data.amountCents ?? 0,
});
}
async createSmsBillingRecord(data: CreateSmsBillingRecordDto) {
const estimate = this.estimateSmsCost({
tenantId: data.tenantId,
applicationId: data.applicationId,
taskId: data.taskId,
content: data.content,
phoneCount: 1,
unitPrice: data.unitPrice ?? 0,
});
return this.prisma.smsBillingRecord.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
taskId: data.taskId,
messageId: data.messageId,
phoneNumber: data.phoneNumber,
contentLength: estimate.contentLength,
billingUnits: estimate.billingUnitsPerMessage,
unitPrice: estimate.unitPrice,
amountCents: estimate.amountCents,
billingStatus: 'estimated',
},
});
}
listSmsBillingRecords(tenantId?: string, taskId?: string) {
return this.prisma.smsBillingRecord.findMany({
where: { tenantId, taskId },
orderBy: { createdAt: 'desc' },
});
}
listRules() {
return this.prisma.billingRule.findMany({ orderBy: { createdAt: 'desc' } });
}
createRule(data: CreateBillingRuleDto) {
assertMoneyUnits(data.unitPrice, '计费规则单价');
return this.prisma.billingRule.create({
data: {
code: data.code,
name: data.name,
chargeBasis: data.chargeBasis ?? 'submit_success',
unitPrice: data.unitPrice,
status: data.status ?? 'active',
},
});
}
private async getAccountOrCreate(tenantId: string) {
return this.prisma.tenantAccount.upsert({
where: { tenantId },
update: {},
create: { tenantId, balanceCents: 0, creditCents: 0, status: 'active' },
});
}
private async applyAccountDelta(data: CreateAccountTransactionDto) {
const amountCents = data.amountCents ?? 0;
const idempotencyKey = data.idempotencyKey?.trim() || null;
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + data.tenantId}, 0))`;
if (idempotencyKey) {
const existing = await tx.accountTransaction.findUnique({ where: { idempotencyKey } });
if (existing) {
if (
existing.tenantId !== data.tenantId
|| existing.transactionType !== data.transactionType
|| moneyToNumber(existing.amountCents) !== amountCents
|| existing.relatedType !== (data.relatedType ?? null)
|| existing.relatedId !== (data.relatedId ?? null)
) {
throw new ConflictException('账务幂等键已用于另一笔交易');
}
return existing;
}
}
await tx.tenantAccount.upsert({
where: { tenantId: data.tenantId },
update: {},
create: { tenantId: data.tenantId, balanceCents: 0, creditCents: 0, status: 'active' },
});
const account = await tx.tenantAccount.update({
where: { tenantId: data.tenantId },
data: { balanceCents: { increment: amountCents } },
});
return tx.accountTransaction.create({
data: {
tenantId: data.tenantId,
transactionType: data.transactionType,
idempotencyKey,
amountCents,
balanceAfter: account.balanceCents,
relatedType: data.relatedType,
relatedId: data.relatedId,
remark: data.remark,
},
});
});
}
}
function estimateBillingUnits(content: string) {
const length = [...content].length;
if (length <= 70) {
return 1;
}
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> : {};
}