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;
|
||||
}
|
||||
Reference in New Issue
Block a user