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
+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;
}