fix: simplify balance billing and govern operation logs

This commit is contained in:
hectorzhao
2026-07-14 17:40:32 +08:00
parent f35691f185
commit 28dad93e3e
40 changed files with 740 additions and 395 deletions
@@ -0,0 +1,37 @@
CREATE INDEX "OperationLog_createdAt_idx"
ON "OperationLog"("createdAt");
CREATE INDEX "OperationLog_resource_createdAt_idx"
ON "OperationLog"("resource", "createdAt");
CREATE TABLE "OperationLogArchive" (
"originalId" TEXT NOT NULL,
"tenantId" TEXT,
"userId" TEXT,
"action" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"resourceId" TEXT,
"ipAddress" TEXT,
"userAgent" TEXT,
"detail" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL,
"archiveMonth" TEXT NOT NULL,
"archivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OperationLogArchive_pkey" PRIMARY KEY ("originalId")
);
CREATE INDEX "OperationLogArchive_tenantId_createdAt_idx"
ON "OperationLogArchive"("tenantId", "createdAt");
CREATE INDEX "OperationLogArchive_userId_createdAt_idx"
ON "OperationLogArchive"("userId", "createdAt");
CREATE INDEX "OperationLogArchive_createdAt_idx"
ON "OperationLogArchive"("createdAt");
CREATE INDEX "OperationLogArchive_resource_createdAt_idx"
ON "OperationLogArchive"("resource", "createdAt");
CREATE INDEX "OperationLogArchive_archiveMonth_idx"
ON "OperationLogArchive"("archiveMonth");
@@ -0,0 +1,14 @@
ALTER TABLE "RechargeOrder" DROP CONSTRAINT IF EXISTS "RechargeOrder_planId_fkey";
DROP TABLE IF EXISTS "BillingPlan";
ALTER TABLE "TenantAccount"
DROP COLUMN "smsUnits",
DROP COLUMN "creditCents";
ALTER TABLE "AccountTransaction"
DROP COLUMN "smsUnits";
ALTER TABLE "RechargeOrder"
DROP COLUMN "planId",
DROP COLUMN "smsUnits";
+24 -21
View File
@@ -154,6 +154,29 @@ model OperationLog {
@@index([tenantId, createdAt])
@@index([userId, createdAt])
@@index([createdAt])
@@index([resource, createdAt])
}
model OperationLogArchive {
originalId String @id
tenantId String?
userId String?
action String
resource String
resourceId String?
ipAddress String?
userAgent String?
detail Json?
createdAt DateTime
archiveMonth String
archivedAt DateTime @default(now())
@@index([tenantId, createdAt])
@@index([userId, createdAt])
@@index([createdAt])
@@index([resource, createdAt])
@@index([archiveMonth])
}
model FileObject {
@@ -240,26 +263,10 @@ model DrainageField {
channelReportFields ChannelReportField[]
}
model BillingPlan {
id String @id @default(cuid())
name String
priceCents Int
smsUnits Int
validDays Int
status String @default("active")
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rechargeOrders RechargeOrder[]
}
model TenantAccount {
id String @id @default(cuid())
tenantId String
balanceCents Int @default(0)
smsUnits Int @default(0)
creditCents Int @default(0)
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -274,7 +281,6 @@ model AccountTransaction {
tenantId String
transactionType String
amountCents Int @default(0)
smsUnits Int @default(0)
balanceAfter Int @default(0)
relatedType String?
relatedId String?
@@ -301,10 +307,8 @@ model BillingRule {
model RechargeOrder {
id String @id @default(cuid())
tenantId String
planId String?
orderNo String @unique
amountCents Int
smsUnits Int @default(0)
status String @default("created")
payMethod String?
paidAt DateTime?
@@ -313,8 +317,7 @@ model RechargeOrder {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
plan BillingPlan? @relation(fields: [planId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id])
@@index([tenantId, createdAt])
}
+7 -3
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { AuditService, CreateOperationLogDto } from './audit.service';
@@ -9,8 +9,12 @@ export class AuditController {
constructor(private readonly audit: AuditService) {}
@Get()
list(@TenantId() tenantId?: string) {
return this.audit.list(tenantId);
list(
@TenantId() tenantId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.audit.list(tenantId, Number(page), Number(pageSize));
}
@Post()
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
import { OperationLogRetentionService } from './operation-log-retention.service';
@Module({
controllers: [AuditController],
providers: [AuditService],
providers: [AuditService, OperationLogRetentionService],
exports: [AuditService],
})
export class AuditModule {}
+26
View File
@@ -0,0 +1,26 @@
import { AuditService } from './audit.service';
describe('AuditService', () => {
it('returns bounded paginated operation logs', async () => {
const prisma = {
operationLog: {
findMany: jest.fn().mockResolvedValue([{ id: 'log-1' }]),
count: jest.fn().mockResolvedValue(1),
},
};
const service = new AuditService(prisma as never);
await expect(service.list('tenant-1', 2, 1_000)).resolves.toEqual({
items: [{ id: 'log-1' }],
total: 1,
page: 2,
pageSize: 100,
});
expect(prisma.operationLog.findMany).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: 100,
take: 100,
});
});
});
+19 -5
View File
@@ -17,11 +17,20 @@ export interface CreateOperationLogDto {
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
list(tenantId?: string) {
return this.prisma.operationLog.findMany({
where: tenantId ? { tenantId } : undefined,
orderBy: { createdAt: 'desc' },
});
async list(tenantId?: string, pageInput?: number, pageSizeInput?: number) {
const page = positiveInteger(pageInput, 1);
const pageSize = Math.min(100, positiveInteger(pageSizeInput, 20));
const where = tenantId ? { tenantId } : undefined;
const [items, total] = await Promise.all([
this.prisma.operationLog.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.operationLog.count({ where }),
]);
return { items, total, page, pageSize };
}
create(data: CreateOperationLogDto) {
@@ -38,3 +47,8 @@ export class AuditService {
return this.prisma.operationLog.create({ data: createData });
}
}
function positiveInteger(value: number | undefined, fallback: number) {
const normalized = Number(value);
return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback;
}
@@ -0,0 +1,28 @@
import { OperationLogRetentionService } from './operation-log-retention.service';
describe('OperationLogRetentionService', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it('archives expired logs in bounded batches using the configured retention window', async () => {
process.env.OPERATION_LOG_RETENTION_DAYS = '90';
process.env.OPERATION_LOG_ARCHIVE_BATCH_SIZE = '2';
process.env.OPERATION_LOG_ARCHIVE_MAX_BATCHES = '3';
const prisma = {
$executeRaw: jest.fn()
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(1),
};
const service = new OperationLogRetentionService(prisma as never);
await expect(service.archiveExpiredLogs(new Date('2026-07-14T00:00:00.000Z'))).resolves.toEqual({
archived: 3,
cutoff: new Date('2026-04-15T00:00:00.000Z'),
retentionDays: 90,
});
expect(prisma.$executeRaw).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,115 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_RETENTION_DAYS = 180;
const DEFAULT_BATCH_SIZE = 1_000;
const DEFAULT_MAX_BATCHES = 20;
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1_000;
const INITIAL_DELAY_MS = 60_000;
@Injectable()
export class OperationLogRetentionService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(OperationLogRetentionService.name);
private initialTimer?: ReturnType<typeof setTimeout>;
private intervalTimer?: ReturnType<typeof setInterval>;
private running = false;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
if (!operationLogArchiveEnabled()) {
return;
}
this.initialTimer = setTimeout(() => void this.runSafely(), INITIAL_DELAY_MS);
this.initialTimer.unref?.();
this.intervalTimer = setInterval(() => void this.runSafely(), positiveIntegerEnv('OPERATION_LOG_ARCHIVE_INTERVAL_MS', DEFAULT_INTERVAL_MS));
this.intervalTimer.unref?.();
}
onModuleDestroy() {
if (this.initialTimer) clearTimeout(this.initialTimer);
if (this.intervalTimer) clearInterval(this.intervalTimer);
}
async archiveExpiredLogs(now = new Date()) {
const retentionDays = positiveIntegerEnv('OPERATION_LOG_RETENTION_DAYS', DEFAULT_RETENTION_DAYS);
const batchSize = Math.min(10_000, positiveIntegerEnv('OPERATION_LOG_ARCHIVE_BATCH_SIZE', DEFAULT_BATCH_SIZE));
const maxBatches = Math.min(100, positiveIntegerEnv('OPERATION_LOG_ARCHIVE_MAX_BATCHES', DEFAULT_MAX_BATCHES));
const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1_000);
let total = 0;
for (let batch = 0; batch < maxBatches; batch += 1) {
const moved = await this.archiveBatch(cutoff, batchSize);
total += moved;
if (moved < batchSize) {
break;
}
}
return { archived: total, cutoff, retentionDays };
}
private async archiveBatch(cutoff: Date, batchSize: number) {
return this.prisma.$executeRaw(Prisma.sql`
WITH candidates AS (
SELECT "id", "tenantId", "userId", "action", "resource", "resourceId",
"ipAddress", "userAgent", "detail", "createdAt"
FROM "OperationLog"
WHERE "createdAt" < ${cutoff}
ORDER BY "createdAt" ASC, "id" ASC
LIMIT ${batchSize}
FOR UPDATE SKIP LOCKED
), archived AS (
INSERT INTO "OperationLogArchive" (
"originalId", "tenantId", "userId", "action", "resource", "resourceId",
"ipAddress", "userAgent", "detail", "createdAt", "archiveMonth", "archivedAt"
)
SELECT "id", "tenantId", "userId", "action", "resource", "resourceId",
"ipAddress", "userAgent", "detail", "createdAt", TO_CHAR("createdAt", 'YYYY-MM'), NOW()
FROM candidates
ON CONFLICT ("originalId") DO NOTHING
RETURNING "originalId"
)
DELETE FROM "OperationLog" source
USING candidates
WHERE source."id" = candidates."id"
AND (
EXISTS (
SELECT 1 FROM archived
WHERE archived."originalId" = source."id"
)
OR EXISTS (
SELECT 1 FROM "OperationLogArchive" archive
WHERE archive."originalId" = source."id"
)
)
`);
}
private async runSafely() {
if (this.running) {
return;
}
this.running = true;
try {
const result = await this.archiveExpiredLogs();
if (result.archived > 0) {
this.logger.log(`Archived ${result.archived} operation logs older than ${result.cutoff.toISOString()}`);
}
} catch (error) {
this.logger.error('Operation log archival failed', error instanceof Error ? error.stack : String(error));
} finally {
this.running = false;
}
}
}
function operationLogArchiveEnabled() {
const configured = String(process.env.OPERATION_LOG_ARCHIVE_ENABLED ?? 'true').trim().toLowerCase();
return configured !== 'false' && configured !== '0';
}
function positiveIntegerEnv(name: string, fallback: number) {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 ? value : fallback;
}
-27
View File
@@ -6,8 +6,6 @@ import {
BillingService,
BillingActionDto,
CreateManualRechargeDto,
CreateRechargeOrderDto,
CreateBillingPlanDto,
CreateBillingRuleDto,
CreateSmsBillingRecordDto,
CreateTenantAccountDto,
@@ -19,16 +17,6 @@ import {
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();
@@ -44,11 +32,6 @@ export class BillingController {
return this.billing.listRechargeOrders(tenantId);
}
@Post('recharges')
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
return this.billing.createRechargeOrder(body);
}
@Get('manual-recharges')
listManualRechargeRecords(@TenantId() tenantId?: string) {
return this.billing.listManualRechargeRecords(tenantId);
@@ -123,16 +106,6 @@ export class BillingController {
export class ClientBillingController {
constructor(private readonly billing: BillingService) {}
@Get('plans')
listPlans() {
return this.billing.listPlans();
}
@Post('orders')
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
return this.billing.createRechargeOrder(body);
}
@Get('orders')
listRechargeOrders(@TenantId() tenantId?: string) {
return this.billing.listRechargeOrders(tenantId);
+16 -29
View File
@@ -1,21 +1,15 @@
import { BillingService } from './billing.service';
function createPrismaMock() {
const accountState = { tenantId: 'tenant-1', balanceCents: 1000, smsUnits: 20, creditCents: 200 };
const accountState = { tenantId: 'tenant-1', balanceCents: 1000 };
return {
accountState,
billingPlan: {
findMany: jest.fn(),
create: jest.fn(),
findUnique: jest.fn(),
},
tenantAccount: {
findMany: jest.fn(),
create: jest.fn(),
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
update: jest.fn().mockImplementation(({ data }) => {
accountState.balanceCents = data.balanceCents;
accountState.smsUnits = data.smsUnits;
return Promise.resolve({ ...accountState });
}),
},
@@ -68,38 +62,33 @@ describe('BillingService', () => {
);
});
it('checks balance, credit, and package units before sending', async () => {
it('checks only the cash balance before sending', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1100, smsUnits: 20 })).resolves.toEqual(
expect.objectContaining({ availableAmount: 1200, availableSmsUnits: 20, canSend: true }),
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 5 })).resolves.toEqual(
expect.objectContaining({ availableAmount: 1000, canSend: true }),
);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1300, smsUnits: 20 })).resolves.toEqual(
expect.objectContaining({ canSend: false }),
);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 21 })).resolves.toEqual(
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
expect.objectContaining({ canSend: false }),
);
});
it('creates recharge orders and account transactions from plans', async () => {
it('creates cash recharge orders and account transactions without plans', async () => {
const prisma = createPrismaMock();
prisma.billingPlan.findUnique.mockResolvedValue({ id: 'plan-1', priceCents: 500, smsUnits: 100 });
const service = new BillingService(prisma as never);
const order = await service.createRechargeOrder({ tenantId: 'tenant-1', planId: 'plan-1', remark: 'manual top up' });
const order = await service.createRechargeOrder({ tenantId: 'tenant-1', amountCents: 500, remark: 'manual top up' });
expect(order).toEqual(expect.objectContaining({ amountCents: 500, smsUnits: 100, status: 'paid' }));
expect(order).toEqual(expect.objectContaining({ amountCents: 500, status: 'paid' }));
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
data: { balanceCents: 1500, smsUnits: 120 },
data: { balanceCents: 1500 },
});
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
transactionType: 'recharge',
amountCents: 500,
smsUnits: 100,
balanceAfter: 1500,
relatedType: 'recharge_order',
relatedId: 'order-1',
@@ -114,7 +103,6 @@ describe('BillingService', () => {
const order = await service.createManualRecharge({
tenantId: 'tenant-1',
amountCents: 2000,
smsUnits: 0,
operatorId: 'admin-1',
remark: '线下转账人工充值',
});
@@ -175,7 +163,6 @@ describe('BillingService', () => {
const order = await service.createManualRecharge({
tenantId: 'tenant-1',
amountCents: -300,
smsUnits: 0,
operatorId: 'admin-1',
remark: '人工冲正',
});
@@ -183,7 +170,7 @@ describe('BillingService', () => {
expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' }));
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
data: { balanceCents: 700, smsUnits: 20 },
data: { balanceCents: 700 },
});
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
@@ -205,11 +192,11 @@ describe('BillingService', () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
await service.freeze({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 2, relatedType: 'sms_batch_task', relatedId: 'task-1' });
await service.charge({ tenantId: 'tenant-1', amountCents: 50, smsUnits: 1, relatedType: 'sms_message_record', relatedId: 'msg-1' });
await service.release({ tenantId: 'tenant-1', amountCents: 25, smsUnits: 1 });
await service.refund({ tenantId: 'tenant-1', amountCents: 10, smsUnits: 1 });
await service.adjust({ tenantId: 'tenant-1', amountCents: 5, smsUnits: 0 });
await service.freeze({ tenantId: 'tenant-1', amountCents: 100, relatedType: 'sms_batch_task', relatedId: 'task-1' });
await service.charge({ tenantId: 'tenant-1', amountCents: 50, relatedType: 'sms_message_record', relatedId: 'msg-1' });
await service.release({ tenantId: 'tenant-1', amountCents: 25 });
await service.refund({ tenantId: 'tenant-1', amountCents: 10 });
await service.adjust({ tenantId: 'tenant-1', amountCents: 5 });
expect(prisma.accountTransaction.create.mock.calls.map(([arg]) => arg.data.transactionType)).toEqual([
'frozen',
@@ -218,7 +205,7 @@ describe('BillingService', () => {
'refunded',
'adjusted',
]);
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890, smsUnits: 19 }));
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
});
it('creates SMS billing records linked to message and task identifiers', async () => {
+5 -60
View File
@@ -2,20 +2,9 @@ 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;
}
@@ -23,7 +12,6 @@ export interface CreateAccountTransactionDto {
tenantId: string;
transactionType: string;
amountCents?: number;
smsUnits?: number;
balanceAfter?: number;
relatedType?: string;
relatedId?: string;
@@ -40,9 +28,7 @@ export interface CreateBillingRuleDto {
export interface CreateRechargeOrderDto {
tenantId: string;
planId?: string;
amountCents?: number;
smsUnits?: number;
amountCents: number;
payMethod?: string;
operatorId?: string;
remark?: string;
@@ -51,7 +37,6 @@ export interface CreateRechargeOrderDto {
export interface CreateManualRechargeDto {
tenantId: string;
amountCents: number;
smsUnits?: number;
operatorId?: string;
remark?: string;
}
@@ -68,7 +53,6 @@ export interface EstimateSmsCostDto {
export interface BillingActionDto {
tenantId: string;
amountCents?: number;
smsUnits?: number;
relatedType?: string;
relatedId?: string;
remark?: string;
@@ -88,23 +72,6 @@ export interface CreateSmsBillingRecordDto {
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
listPlans() {
return this.prisma.billingPlan.findMany({ orderBy: { createdAt: 'desc' } });
}
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 },
@@ -116,8 +83,6 @@ export class BillingService {
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 });
@@ -126,7 +91,6 @@ export class BillingService {
listRechargeOrders(tenantId?: string) {
return this.prisma.rechargeOrder.findMany({
where: tenantId ? { tenantId } : undefined,
include: { plan: true },
orderBy: { createdAt: 'desc' },
});
}
@@ -137,7 +101,6 @@ export class BillingService {
tenantId,
payMethod: 'manual_topup',
},
include: { plan: true },
orderBy: { createdAt: 'desc' },
});
const orderIds = orders.map((order) => order.id);
@@ -161,16 +124,12 @@ export class BillingService {
}
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 amountCents = data.amountCents;
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(),
@@ -183,7 +142,6 @@ export class BillingService {
tenantId: data.tenantId,
transactionType: 'recharge',
amountCents,
smsUnits,
relatedType: 'recharge_order',
relatedId: order.id,
remark: data.remark,
@@ -196,7 +154,6 @@ export class BillingService {
const order = await this.createRechargeOrder({
tenantId: data.tenantId,
amountCents: data.amountCents,
smsUnits: data.smsUnits ?? 0,
payMethod: 'manual_topup',
operatorId: data.operatorId,
remark: data.remark,
@@ -210,7 +167,6 @@ export class BillingService {
resourceId: order.id,
detail: {
amountCents: data.amountCents,
smsUnits: data.smsUnits ?? 0,
orderNo: order.orderNo,
remark: data.remark,
} as Prisma.InputJsonValue,
@@ -239,15 +195,12 @@ export class BillingService {
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;
const availableAmount = account.balanceCents;
return {
tenantId: data.tenantId,
requiredAmount,
requiredUnits,
availableAmount,
availableSmsUnits: account.smsUnits,
canSend: availableAmount >= requiredAmount && account.smsUnits >= requiredUnits,
canSend: availableAmount >= requiredAmount,
};
}
@@ -256,7 +209,6 @@ export class BillingService {
...data,
transactionType: 'frozen',
amountCents: -(data.amountCents ?? 0),
smsUnits: -(data.smsUnits ?? 0),
});
}
@@ -265,7 +217,6 @@ export class BillingService {
...data,
transactionType: 'charged',
amountCents: -(data.amountCents ?? 0),
smsUnits: -(data.smsUnits ?? 0),
});
}
@@ -274,7 +225,6 @@ export class BillingService {
...data,
transactionType: 'released',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
@@ -283,7 +233,6 @@ export class BillingService {
...data,
transactionType: 'refunded',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
@@ -292,7 +241,6 @@ export class BillingService {
...data,
transactionType: 'adjusted',
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
});
}
@@ -348,19 +296,17 @@ export class BillingService {
return this.prisma.tenantAccount.upsert({
where: { tenantId },
update: {},
create: { tenantId, balanceCents: 0, smsUnits: 0, creditCents: 0, status: 'active' },
create: { tenantId, balanceCents: 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,
},
});
@@ -369,7 +315,6 @@ export class BillingService {
tenantId: data.tenantId,
transactionType: data.transactionType,
amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
balanceAfter: nextBalance,
relatedType: data.relatedType,
relatedId: data.relatedId,
@@ -92,8 +92,13 @@ export class AdminOperationsController {
}
@Get('audit-logs')
auditLogs(@Query('tenantId') tenantId?: string, @Query('userId') userId?: string) {
return this.operations.auditLogs({ tenantId, userId });
auditLogs(
@Query('tenantId') tenantId?: string,
@Query('userId') userId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.operations.auditLogs({ tenantId, userId, page: Number(page), pageSize: Number(pageSize) });
}
@Get('audit-summary')
+31 -1
View File
@@ -26,7 +26,7 @@ function createPrismaMock() {
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }),
},
accountTransaction: {
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }),
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20 } }),
},
tenantAccount: {
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 1000, tenant: { name: '租户A' } }]),
@@ -331,6 +331,36 @@ describe('OperationsService', () => {
);
});
it('applies operation-log level filters before database pagination', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await service.systemLogs({ level: 'error', page: 2, pageSize: 5 });
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
AND: expect.objectContaining({ OR: expect.any(Array) }),
}),
skip: 5,
take: 5,
}));
expect(prisma.operationLog.count).toHaveBeenCalledWith({
where: expect.objectContaining({ AND: expect.objectContaining({ OR: expect.any(Array) }) }),
});
});
it('caps legacy audit-log reads with pagination', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.auditLogs({ page: 1, pageSize: 1_000 })).resolves.toEqual(expect.objectContaining({
total: 1,
page: 1,
pageSize: 100,
}));
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 }));
});
it('returns paginated gateway submit dead letters', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
+65 -16
View File
@@ -180,8 +180,8 @@ export class OperationsService {
_count: { _all: true },
}),
this.prisma.accountTransaction.aggregate({
where: { tenantId: query.tenantId },
_sum: { amountCents: true, smsUnits: true },
where: { tenantId: query.tenantId, transactionType: 'refunded', createdAt: { gte: sinceToday } },
_sum: { amountCents: true },
_count: { _all: true },
}),
this.prisma.cmppConnectionState.groupBy({
@@ -296,21 +296,31 @@ export class OperationsService {
});
}
auditLogs(query: { tenantId?: string; userId?: string }) {
return this.prisma.operationLog.findMany({
where: { tenantId: query.tenantId, userId: query.userId },
orderBy: { createdAt: 'desc' },
});
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
const where: Prisma.OperationLogWhereInput = { tenantId: query.tenantId, userId: query.userId };
const [items, total] = await Promise.all([
this.prisma.operationLog.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.operationLog.count({ where }),
]);
return { items, total, page, pageSize };
}
async systemLogs(query: OperationLogQuery) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 10));
const where: Prisma.OperationLogWhereInput = {
tenantId: query.tenantId,
userId: query.userId,
createdAt: createdAtRange(query.range),
resource: query.module && query.module !== 'all' ? query.module : undefined,
AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined,
OR: query.keyword ? [
{ action: { contains: query.keyword } },
{ resource: { contains: query.keyword } },
@@ -324,7 +334,7 @@ export class OperationsService {
this.prisma.operationLog.findMany({
where,
include: { tenant: true, user: true },
orderBy: { createdAt: 'desc' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
@@ -336,12 +346,9 @@ export class OperationsService {
orderBy: { resource: 'asc' },
}),
]);
const normalizedItems = items
.map((item) => normalizeOperationLog(item))
.filter((item) => !query.level || query.level === 'all' || item.level === query.level);
return {
items: normalizedItems,
total: query.level && query.level !== 'all' ? normalizedItems.length : total,
items: items.map((item) => normalizeOperationLog(item)),
total,
page,
pageSize,
modules: modules.map((item) => item.resource),
@@ -722,7 +729,7 @@ export class OperationsService {
relatedId: query.taskId,
},
_count: { _all: true },
_sum: { amountCents: true, smsUnits: true },
_sum: { amountCents: true },
}),
]);
const messageAmount = messages._sum.amountCents ?? 0;
@@ -969,6 +976,48 @@ function groupDownstreamByApplication(
return [...summaryMap.values()];
}
function positiveInteger(value: number | undefined, fallback: number) {
const normalized = Number(value);
return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback;
}
function operationLogLevelWhere(level: string): Prisma.OperationLogWhereInput {
const error: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'failed' } },
{ action: { contains: 'reject' } },
{ detail: { path: ['result'], string_contains: 'fail' } },
{ detail: { path: ['status'], string_contains: 'fail' } },
],
};
const warning: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'warning' } },
{ action: { contains: 'risk' } },
],
};
const success: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'approve' } },
{ action: { contains: 'recharge' } },
{ action: { contains: 'connected' } },
],
};
if (level === 'error') {
return error;
}
if (level === 'warning') {
return { AND: [{ NOT: error }, warning] };
}
if (level === 'success') {
return { AND: [{ NOT: error }, { NOT: warning }, success] };
}
if (level === 'info') {
return { NOT: { OR: [error, warning, success] } };
}
return {};
}
function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
const detail = (log.detail ?? {}) as Record<string, unknown>;
const result = String(detail.result ?? detail.status ?? '');
+34 -5
View File
@@ -249,11 +249,13 @@ function createPrismaMock() {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
gatewayDownstreamRecoveryStatus: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue({
id: 'recover-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
account: '100001',
gatewayInstanceId: 'gateway-a',
state: 'waiting_connection',
lockOwner: 'gateway-a',
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
@@ -341,7 +343,7 @@ describe('SendChainService', () => {
expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }),
]),
});
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, smsUnits: 2, relatedId: 'task-1' }));
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, relatedId: 'task-1' }));
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
});
@@ -376,7 +378,7 @@ describe('SendChainService', () => {
dispatched: 1,
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
});
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' }));
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' }));
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { batchTaskId: 'task-1', status: 'scheduled' },
data: { status: 'queued' },
@@ -854,8 +856,8 @@ describe('SendChainService', () => {
where: { id: 'record-1' },
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }),
});
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' }));
expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'MSG-1' }));
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' }));
expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'MSG-1' }));
expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }),
});
@@ -1412,12 +1414,39 @@ describe('SendChainService', () => {
}));
expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
action: 'gateway.downstream_recovery_status_sync',
action: 'gateway.downstream_recovery_status_changed',
resource: 'gateway_downstream_recovery_status',
}),
}));
});
it('does not append recovery audit logs when only periodic timestamps change', async () => {
const { service, prisma } = createService();
prisma.gatewayDownstreamRecoveryStatus.findUnique.mockResolvedValue({
state: 'waiting_connection',
gatewayInstanceId: 'gateway-a',
lockOwner: 'gateway-a',
failureCategory: 'client_disconnected',
lastError: 'downstream client is not connected',
lastSkipReason: null,
});
await service.recordGatewayDownstreamRecoveryStatus({
account: '100001',
gatewayInstanceId: 'gateway-a',
state: 'waiting_connection',
lastAttemptAt: '2026-07-08T12:01:00.000Z',
nextRetryAt: '2026-07-08T12:11:00.000Z',
attemptCount: 3,
lockOwner: 'gateway-a',
lockExpiresAt: '2026-07-08T12:01:30.000Z',
lastError: 'downstream client is not connected',
});
expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalled();
expect(prisma.operationLog.create).not.toHaveBeenCalled();
});
it('marks downstream delivery as failed after reaching retry limit', async () => {
const { service, prisma } = createService();
const previous = process.env.CMPP_DOWNSTREAM_MAX_RETRIES;
+60 -46
View File
@@ -279,10 +279,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const accountCheck = await this.billing.checkAccount({
tenantId: data.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
});
if (!accountCheck.canSend) {
throw new BadRequestException('企业账户余额、套餐余量或授信额度不足');
throw new BadRequestException('企业账户余额不足');
}
}
const task = await this.prisma.smsBatchTask.create({
@@ -305,11 +304,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
createdById: data.createdById,
},
});
if (shouldReserveBalance && billing.amountCents + billing.totalBillingUnits > 0) {
if (shouldReserveBalance && billing.amountCents > 0) {
await this.billing.freeze({
tenantId: data.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: '发送任务创建冻结',
@@ -637,16 +635,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
take: 100000,
});
const amountCents = messages.reduce((sum, message) => sum + message.amountCents, 0);
const smsUnits = messages.reduce((sum, message) => sum + message.billingUnits, 0);
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents, smsUnits });
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
if (!accountCheck.canSend) {
throw new BadRequestException('定时任务到点时企业账户余额、套餐余量或授信额度不足');
throw new BadRequestException('定时任务到点时企业账户余额不足');
}
if (amountCents + smsUnits > 0) {
if (amountCents > 0) {
await this.billing.freeze({
tenantId: task.tenantId,
amountCents,
smsUnits,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: '定时任务到点冻结',
@@ -1114,9 +1110,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
const recoveryStatuses = (this.prisma as PrismaService & {
gatewayDownstreamRecoveryStatus: {
findUnique: (args: Record<string, unknown>) => Promise<any>;
upsert: (args: Record<string, unknown>) => Promise<any>;
};
}).gatewayDownstreamRecoveryStatus;
const previous = await recoveryStatuses.findUnique({
where: { account },
select: {
state: true,
gatewayInstanceId: true,
lockOwner: true,
failureCategory: true,
lastError: true,
lastSkipReason: true,
},
});
const application = await this.prisma.smsApplication.findUnique({
where: { cmppAccount: account },
select: { id: true, tenantId: true, name: true },
@@ -1167,27 +1175,30 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
lockOwner?: string | null;
lockExpiresAt?: Date | null;
};
await this.prisma.operationLog.create({
data: {
tenantId: updated.tenantId ?? undefined,
action: 'gateway.downstream_recovery_status_sync',
resource: 'gateway_downstream_recovery_status',
resourceId: updated.id,
detail: {
account,
state: updated.state,
lockOwner: normalizedUpdated.lockOwner,
lockExpiresAt: normalizedUpdated.lockExpiresAt,
attemptCount: updated.attemptCount,
nextRetryAt: updated.nextRetryAt,
failureCategory: normalizedUpdated.failureCategory,
applicationId: updated.applicationId,
applicationName: application?.name,
lastError: updated.lastError,
lastSkipReason: updated.lastSkipReason,
if (hasRecoveryAuditStateChanged(previous, updated)) {
await this.prisma.operationLog.create({
data: {
tenantId: updated.tenantId ?? undefined,
action: 'gateway.downstream_recovery_status_changed',
resource: 'gateway_downstream_recovery_status',
resourceId: updated.id,
detail: {
account,
previousState: previous?.state ?? null,
state: updated.state,
gatewayInstanceId: updated.gatewayInstanceId,
lockOwner: normalizedUpdated.lockOwner,
attemptCount: updated.attemptCount,
nextRetryAt: updated.nextRetryAt,
failureCategory: normalizedUpdated.failureCategory,
applicationId: updated.applicationId,
applicationName: application?.name,
lastError: updated.lastError,
lastSkipReason: updated.lastSkipReason,
},
},
},
});
});
}
return updated;
}
@@ -1701,16 +1712,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const accountCheck = await this.billing.checkAccount({
tenantId: application.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
});
if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足');
await reject('BALANCE', '企业账户余额不足');
} else {
if (billing.amountCents + billing.totalBillingUnits > 0) {
if (billing.amountCents > 0) {
await this.billing.freeze({
tenantId: application.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 模板不匹配待审核短信冻结',
@@ -1764,16 +1773,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const accountCheck = await this.billing.checkAccount({
tenantId: application.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
});
if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足');
await reject('BALANCE', '企业账户余额不足');
} else {
if (billing.amountCents + billing.totalBillingUnits > 0) {
if (billing.amountCents > 0) {
await this.billing.freeze({
tenantId: application.tenantId,
amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 入站短信冻结',
@@ -2253,16 +2260,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
amountCents: number;
}) {
const amountCents = message.amountCents ?? 0;
const smsUnits = message.billingUnits ?? 0;
const billingUnits = message.billingUnits ?? 0;
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
if (exists?.billingStatus === 'charged') {
return;
}
if (amountCents + smsUnits > 0) {
if (amountCents > 0) {
await this.billing.release({
tenantId: message.tenantId,
amountCents,
smsUnits,
relatedType: 'sms_batch_task',
relatedId: message.batchTaskId,
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
@@ -2271,7 +2277,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const transaction = await this.billing.charge({
tenantId: message.tenantId,
amountCents,
smsUnits,
relatedType: 'sms_message_record',
relatedId: message.messageId,
remark: '提交成功扣费',
@@ -2283,7 +2288,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
messageId: message.messageId,
phoneNumber: message.phoneNumber,
contentLength: [...message.content].length,
billingUnits: smsUnits,
billingUnits,
unitPrice: message.unitPrice ?? 0,
amountCents,
billingStatus: 'charged',
@@ -2300,7 +2305,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number; billingUnits: number },
remark: string,
) {
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
if ((message.amountCents ?? 0) <= 0) {
return;
}
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
@@ -2316,7 +2321,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
await this.billing.release({
tenantId: message.tenantId,
amountCents: message.amountCents,
smsUnits: message.billingUnits,
relatedType: 'sms_message_record',
relatedId: message.messageId,
remark: `${remark}: ${message.messageId}`,
@@ -2327,7 +2331,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
message: { tenantId: string; messageId: string; amountCents: number; billingUnits: number },
remark: string,
) {
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
if ((message.amountCents ?? 0) <= 0) {
return;
}
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } });
@@ -2341,7 +2345,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const transaction = await this.billing.refund({
tenantId: message.tenantId,
amountCents: message.amountCents,
smsUnits: message.billingUnits,
relatedType: 'sms_message_record',
relatedId: message.messageId,
remark,
@@ -2919,6 +2922,17 @@ function octetString(value: string, fixedLength: number) {
return value + '\0'.repeat(fixedLength - value.length);
}
function hasRecoveryAuditStateChanged(
previous: Record<string, unknown> | null,
current: Record<string, unknown>,
) {
if (!previous) {
return true;
}
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason']
.some((key) => (previous[key] ?? null) !== (current[key] ?? null));
}
function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
const explicit = String(data.failureCategory ?? '').trim();
if (explicit) {
@@ -437,6 +437,36 @@ describe('SmsConfigService', () => {
});
});
it.each([
['heartbeat', 'lastHeartbeatAt'],
['submit', 'lastSubmitAt'],
['deliver', 'lastDeliverAt'],
] as const)('updates downstream %s state without appending high-frequency operation logs', async (status, timestampField) => {
const prisma = createPrismaMock();
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({
id: 'downstream-1',
connectedAt: new Date('2026-07-11T11:00:00.000Z'),
lastHeartbeatAt: new Date('2026-07-11T11:00:00.000Z'),
lastSubmitAt: null,
lastDeliverAt: null,
lastError: null,
});
const service = new SmsConfigService(prisma as never);
await service.recordDownstreamConnectionEvent({
account: '100001',
connectionId: 'gateway-1-1',
status,
observedAt: '2026-07-11T11:00:30.000Z',
});
expect(prisma.cmppDownstreamConnection.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'downstream-1' },
data: expect.objectContaining({ [timestampField]: new Date('2026-07-11T11:00:30.000Z') }),
}));
expect(prisma.operationLog.create).not.toHaveBeenCalled();
});
it('lists enterprise signatures with keyword filters and real relations', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
+9 -7
View File
@@ -593,13 +593,15 @@ export class SmsConfigService {
const connection = existing
? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload })
: await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } });
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
applicationId: application.id,
account: data.account,
remoteIp: data.remoteIp,
protocol: data.protocol,
status: connection.status,
});
if (data.status === 'connected' || data.status === 'disconnected') {
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
applicationId: application.id,
account: data.account,
remoteIp: data.remoteIp,
protocol: data.protocol,
status: connection.status,
});
}
return connection;
}
+12 -3
View File
@@ -23,11 +23,14 @@ function createPrismaMock() {
update: jest.fn().mockResolvedValue({ id: 'cert-1' }),
},
tenantAccount: {
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 12000, smsUnits: 300, creditCents: 5000, status: 'active' }]),
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 12000, status: 'active' }]),
},
smsMessageRecord: {
groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 350 } }]),
},
accountTransaction: {
groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 125 } }]),
},
};
}
@@ -78,7 +81,7 @@ describe('TenantsService', () => {
expect(prisma.tenant.update).not.toHaveBeenCalled();
});
it('lists management rows with real account and today spend fields', async () => {
it('lists management rows with real account, today spend and actual refund fields', async () => {
const prisma = createPrismaMock();
const service = new TenantsService(prisma as never);
@@ -86,8 +89,9 @@ describe('TenantsService', () => {
expect.objectContaining({
id: 'tenant-1',
name: '测试企业',
account: expect.objectContaining({ balanceCents: 12000, creditCents: 5000 }),
account: expect.objectContaining({ balanceCents: 12000 }),
todaySpendCents: 350,
todayRefundCents: 125,
}),
]);
expect(prisma.tenant.findMany).toHaveBeenCalledWith(expect.objectContaining({
@@ -99,5 +103,10 @@ describe('TenantsService', () => {
by: ['tenantId'],
_sum: { amountCents: true },
}));
expect(prisma.accountTransaction.groupBy).toHaveBeenCalledWith(expect.objectContaining({
by: ['tenantId'],
where: expect.objectContaining({ transactionType: 'refunded' }),
_sum: { amountCents: true },
}));
});
});
+8 -1
View File
@@ -45,7 +45,7 @@ export class TenantsService {
async listManagementRows() {
const sinceToday = startOfToday();
const [tenants, accounts, todaySpendGroups] = await Promise.all([
const [tenants, accounts, todaySpendGroups, todayRefundGroups] = await Promise.all([
this.prisma.tenant.findMany({
where: { status: { not: 'deleted' } },
include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } },
@@ -57,13 +57,20 @@ export class TenantsService {
where: { queuedAt: { gte: sinceToday } },
_sum: { amountCents: true },
}),
this.prisma.accountTransaction.groupBy({
by: ['tenantId'],
where: { transactionType: 'refunded', createdAt: { gte: sinceToday } },
_sum: { amountCents: true },
}),
]);
const accountsByTenant = new Map(accounts.map((account) => [account.tenantId, account]));
const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0]));
const todayRefundByTenant = new Map(todayRefundGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0]));
return tenants.map((tenant) => ({
...withEnterpriseProfile(tenant),
account: accountsByTenant.get(tenant.id) ?? null,
todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0,
todayRefundCents: todayRefundByTenant.get(tenant.id) ?? 0,
}));
}