fix: simplify balance billing and govern operation logs
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? '');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user