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
@@ -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 ?? '');