feat: complete cmpp platform phases 0-5

This commit is contained in:
hectorzhao
2026-07-01 13:22:04 +08:00
parent 824a8b334f
commit ee926fea04
86 changed files with 10490 additions and 14 deletions
+146
View File
@@ -0,0 +1,146 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import {
BillingService,
BillingActionDto,
CreateRechargeOrderDto,
CreateAccountTransactionDto,
CreateBillingPlanDto,
CreateBillingRuleDto,
CreateSmsBillingRecordDto,
CreateTenantAccountDto,
EstimateSmsCostDto,
} from './billing.service';
@ApiTags('billing')
@Controller('admin/billing')
export class BillingController {
constructor(private readonly billing: BillingService) {}
@Get('plans')
listPlans() {
return this.billing.listPlans();
}
@Post('plans')
createPlan(@Body() body: CreateBillingPlanDto) {
return this.billing.createPlan(body);
}
@Get('accounts')
listAccounts() {
return this.billing.listAccounts();
}
@Post('accounts')
createAccount(@Body() body: CreateTenantAccountDto) {
return this.billing.createAccount(body);
}
@Get('transactions')
listTransactions(@TenantId() tenantId?: string) {
return this.billing.listTransactions(tenantId);
}
@Post('transactions')
createTransaction(@Body() body: CreateAccountTransactionDto) {
return this.billing.createTransaction(body);
}
@Get('recharges')
listRechargeOrders(@TenantId() tenantId?: string) {
return this.billing.listRechargeOrders(tenantId);
}
@Post('recharges')
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
return this.billing.createRechargeOrder(body);
}
@Post('estimate')
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
return this.billing.estimateSmsCost(body);
}
@Post('check')
checkAccount(@Body() body: BillingActionDto) {
return this.billing.checkAccount(body);
}
@Post('freeze')
freeze(@Body() body: BillingActionDto) {
return this.billing.freeze(body);
}
@Post('charge')
charge(@Body() body: BillingActionDto) {
return this.billing.charge(body);
}
@Post('release')
release(@Body() body: BillingActionDto) {
return this.billing.release(body);
}
@Post('refund')
refund(@Body() body: BillingActionDto) {
return this.billing.refund(body);
}
@Post('adjust')
adjust(@Body() body: BillingActionDto) {
return this.billing.adjust(body);
}
@Get('sms-billing-records')
listSmsBillingRecords(@TenantId() tenantId?: string) {
return this.billing.listSmsBillingRecords(tenantId);
}
@Post('sms-billing-records')
createSmsBillingRecord(@Body() body: CreateSmsBillingRecordDto) {
return this.billing.createSmsBillingRecord(body);
}
@Get('rules')
listRules() {
return this.billing.listRules();
}
@Post('rules')
createRule(@Body() body: CreateBillingRuleDto) {
return this.billing.createRule(body);
}
}
@ApiTags('client-billing')
@Controller('client/billing')
export class ClientBillingController {
constructor(private readonly billing: BillingService) {}
@Get('plans')
listPlans() {
return this.billing.listPlans();
}
@Get('transactions')
listTransactions(@TenantId() tenantId?: string) {
return this.billing.listTransactions(tenantId);
}
@Post('orders')
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
return this.billing.createRechargeOrder(body);
}
@Get('orders')
listRechargeOrders(@TenantId() tenantId?: string) {
return this.billing.listRechargeOrders(tenantId);
}
@Post('estimate')
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
return this.billing.estimateSmsCost(body);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BillingController, ClientBillingController } from './billing.controller';
import { BillingService } from './billing.service';
@Module({
controllers: [BillingController, ClientBillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}
+349
View File
@@ -0,0 +1,349 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateBillingPlanDto {
name: string;
priceCents: number;
smsUnits: number;
validDays: number;
status?: string;
description?: string;
}
export interface CreateTenantAccountDto {
tenantId: string;
balanceCents?: number;
smsUnits?: number;
creditCents?: number;
status?: string;
}
export interface CreateAccountTransactionDto {
tenantId: string;
transactionType: string;
amountCents?: number;
smsUnits?: 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;
planId?: string;
amountCents?: number;
smsUnits?: number;
payMethod?: string;
operatorId?: string;
remark?: string;
}
export interface EstimateSmsCostDto {
tenantId: string;
applicationId?: string;
content: string;
phoneCount: number;
unitPrice?: number;
taskId?: string;
}
export interface BillingActionDto {
tenantId: string;
amountCents?: number;
smsUnits?: 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) {}
listPlans() {
return this.prisma.billingPlan.findMany({ orderBy: { createdAt: 'desc' }, take: 100 });
}
createPlan(data: CreateBillingPlanDto) {
return this.prisma.billingPlan.create({
data: {
name: data.name,
priceCents: data.priceCents,
smsUnits: data.smsUnits,
validDays: data.validDays,
status: data.status ?? 'active',
description: data.description,
},
});
}
listAccounts() {
return this.prisma.tenantAccount.findMany({
include: { tenant: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
createAccount(data: CreateTenantAccountDto) {
const createData: Prisma.TenantAccountUncheckedCreateInput = {
tenantId: data.tenantId,
balanceCents: data.balanceCents ?? 0,
smsUnits: data.smsUnits ?? 0,
creditCents: data.creditCents ?? 0,
status: data.status ?? 'active',
};
return this.prisma.tenantAccount.create({ data: createData });
}
listTransactions(tenantId?: string) {
return this.prisma.accountTransaction.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
take: 100,
});
}
createTransaction(data: CreateAccountTransactionDto) {
const createData: Prisma.AccountTransactionUncheckedCreateInput = {
tenantId: data.tenantId,
transactionType: data.transactionType,
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
balanceAfter: data.balanceAfter ?? 0,
relatedType: data.relatedType,
relatedId: data.relatedId,
remark: data.remark,
};
return this.prisma.accountTransaction.create({ data: createData });
}
listRechargeOrders(tenantId?: string) {
return this.prisma.rechargeOrder.findMany({
where: tenantId ? { tenantId } : undefined,
include: { plan: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
async createRechargeOrder(data: CreateRechargeOrderDto) {
const plan = data.planId ? await this.prisma.billingPlan.findUnique({ where: { id: data.planId } }) : null;
const amountCents = data.amountCents ?? plan?.priceCents ?? 0;
const smsUnits = data.smsUnits ?? plan?.smsUnits ?? 0;
const order = await this.prisma.rechargeOrder.create({
data: {
tenantId: data.tenantId,
planId: data.planId,
orderNo: `R${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`,
amountCents,
smsUnits,
status: 'paid',
payMethod: data.payMethod ?? 'manual',
paidAt: new Date(),
operatorId: data.operatorId,
remark: data.remark,
},
});
await this.applyAccountDelta({
tenantId: data.tenantId,
transactionType: 'recharge',
amountCents,
smsUnits,
relatedType: 'recharge_order',
relatedId: order.id,
remark: data.remark,
});
return order;
}
estimateSmsCost(data: EstimateSmsCostDto) {
const billingUnits = estimateBillingUnits(data.content);
const unitPrice = data.unitPrice ?? 0;
const totalUnits = billingUnits * data.phoneCount;
return {
tenantId: data.tenantId,
applicationId: data.applicationId,
taskId: data.taskId,
contentLength: [...data.content].length,
phoneCount: data.phoneCount,
billingUnitsPerMessage: billingUnits,
totalBillingUnits: totalUnits,
unitPrice,
amountCents: totalUnits * unitPrice,
};
}
async checkAccount(data: BillingActionDto) {
const account = await this.getAccountOrCreate(data.tenantId);
const requiredAmount = data.amountCents ?? 0;
const requiredUnits = data.smsUnits ?? 0;
const availableAmount = account.balanceCents + account.creditCents;
return {
tenantId: data.tenantId,
requiredAmount,
requiredUnits,
availableAmount,
availableSmsUnits: account.smsUnits,
canSend: availableAmount >= requiredAmount && account.smsUnits >= requiredUnits,
};
}
freeze(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'frozen',
amountCents: -(data.amountCents ?? 0),
smsUnits: -(data.smsUnits ?? 0),
});
}
charge(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'charged',
amountCents: -(data.amountCents ?? 0),
smsUnits: -(data.smsUnits ?? 0),
});
}
release(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'released',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
refund(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'refunded',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
adjust(data: BillingActionDto) {
return this.applyAccountDelta({
...data,
transactionType: 'adjusted',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 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' },
take: 100,
});
}
listRules() {
return this.prisma.billingRule.findMany({ orderBy: { createdAt: 'desc' }, take: 100 });
}
createRule(data: CreateBillingRuleDto) {
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, smsUnits: 0, creditCents: 0, status: 'active' },
});
}
private async applyAccountDelta(data: CreateAccountTransactionDto) {
const account = await this.getAccountOrCreate(data.tenantId);
const nextBalance = account.balanceCents + (data.amountCents ?? 0);
const nextUnits = account.smsUnits + (data.smsUnits ?? 0);
await this.prisma.tenantAccount.update({
where: { tenantId: data.tenantId },
data: {
balanceCents: nextBalance,
smsUnits: nextUnits,
},
});
return this.prisma.accountTransaction.create({
data: {
tenantId: data.tenantId,
transactionType: data.transactionType,
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
balanceAfter: nextBalance,
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);
}