feat: refine operations UI and transport limits
This commit is contained in:
@@ -7,6 +7,9 @@ function createPrismaMock() {
|
||||
tenant: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
|
||||
},
|
||||
user: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
tenantAccount: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||
@@ -181,9 +184,10 @@ describe('BillingService', () => {
|
||||
it('returns the historical balance after each manual recharge', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.rechargeOrder.findMany.mockResolvedValue([
|
||||
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000 },
|
||||
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000, operatorId: 'admin-1' },
|
||||
{ id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 },
|
||||
]);
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'admin-1', displayName: '运营人员张三', username: 'admin' }]);
|
||||
prisma.accountTransaction.findMany.mockResolvedValue([
|
||||
{ relatedId: 'order-1', balanceAfter: 3000 },
|
||||
{ relatedId: 'order-2', balanceAfter: 2700 },
|
||||
@@ -191,8 +195,8 @@ describe('BillingService', () => {
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
await expect(service.listManualRechargeRecords()).resolves.toEqual([
|
||||
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000 }),
|
||||
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700 }),
|
||||
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000, operatorName: '运营人员张三' }),
|
||||
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700, operatorName: null }),
|
||||
]);
|
||||
expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
@@ -201,6 +205,10 @@ describe('BillingService', () => {
|
||||
},
|
||||
select: { relatedId: true, balanceAfter: true },
|
||||
});
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ['admin-1'] } },
|
||||
select: { id: true, displayName: true, username: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('allows negative manual recharge amounts for balance correction', async () => {
|
||||
|
||||
@@ -163,18 +163,27 @@ export class BillingService {
|
||||
return orders;
|
||||
}
|
||||
|
||||
const transactions = await this.prisma.accountTransaction.findMany({
|
||||
where: {
|
||||
relatedType: 'recharge_order',
|
||||
relatedId: { in: orderIds },
|
||||
},
|
||||
select: { relatedId: true, balanceAfter: true },
|
||||
});
|
||||
const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))];
|
||||
const [transactions, operators] = await Promise.all([
|
||||
this.prisma.accountTransaction.findMany({
|
||||
where: {
|
||||
relatedType: 'recharge_order',
|
||||
relatedId: { in: orderIds },
|
||||
},
|
||||
select: { relatedId: true, balanceAfter: true },
|
||||
}),
|
||||
operatorIds.length ? this.prisma.user.findMany({
|
||||
where: { id: { in: operatorIds } },
|
||||
select: { id: true, displayName: true, username: true },
|
||||
}) : [],
|
||||
]);
|
||||
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)]));
|
||||
const operatorNameById = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
|
||||
|
||||
return orders.map((order) => ({
|
||||
...order,
|
||||
balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null,
|
||||
operatorName: order.operatorId ? operatorNameById.get(order.operatorId) ?? null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -195,13 +204,25 @@ export class BillingService {
|
||||
this.prisma.rechargeOrder.count({ where }),
|
||||
]);
|
||||
const orderIds = orders.map((order) => order.id);
|
||||
const transactions = orderIds.length ? await this.prisma.accountTransaction.findMany({
|
||||
where: { relatedType: 'recharge_order', relatedId: { in: orderIds } },
|
||||
select: { relatedId: true, balanceAfter: true },
|
||||
}) : [];
|
||||
const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))];
|
||||
const [transactions, operators] = await Promise.all([
|
||||
orderIds.length ? this.prisma.accountTransaction.findMany({
|
||||
where: { relatedType: 'recharge_order', relatedId: { in: orderIds } },
|
||||
select: { relatedId: true, balanceAfter: true },
|
||||
}) : [],
|
||||
operatorIds.length ? this.prisma.user.findMany({
|
||||
where: { id: { in: operatorIds } },
|
||||
select: { id: true, displayName: true, username: true },
|
||||
}) : [],
|
||||
]);
|
||||
const balances = new Map(transactions.map((item) => [item.relatedId, moneyToNumber(item.balanceAfter)]));
|
||||
const operatorNames = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
|
||||
return {
|
||||
items: orders.map((order) => ({ ...order, balanceAfterCents: balances.get(order.id) ?? null })),
|
||||
items: orders.map((order) => ({
|
||||
...order,
|
||||
balanceAfterCents: balances.get(order.id) ?? null,
|
||||
operatorName: order.operatorId ? operatorNames.get(order.operatorId) ?? null : null,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { configureHttpBodyParsers, DEFAULT_JSON_BODY_LIMIT, IMPORT_JSON_BODY_LIMIT } from './http-body-limits';
|
||||
|
||||
const express = require('express') as () => {
|
||||
use(...args: unknown[]): void;
|
||||
post(path: string, handler: (request: { body?: unknown; rawBody?: Buffer }, response: { json(body: unknown): void }) => void): void;
|
||||
listen(port: number, host: string, callback: () => void): { close(callback: (error?: Error) => void): void; address(): { port: number } | string | null };
|
||||
};
|
||||
const expressModule = require('express') as { json(options: { limit: string }): (...args: unknown[]) => unknown; urlencoded(options: { limit: string; extended: boolean }): (...args: unknown[]) => unknown };
|
||||
const http = require('node:http') as typeof import('node:http');
|
||||
|
||||
describe('configureHttpBodyParsers', () => {
|
||||
it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => {
|
||||
const use = jest.fn();
|
||||
const useBodyParser = jest.fn();
|
||||
|
||||
configureHttpBodyParsers({ use, useBodyParser } as never);
|
||||
|
||||
expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb');
|
||||
expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb');
|
||||
expect(use).toHaveBeenCalledTimes(1);
|
||||
expect(use).toHaveBeenCalledWith('/api/client/send/imports', expect.any(Function));
|
||||
expect(useBodyParser).toHaveBeenNthCalledWith(1, 'json', { limit: '2mb' });
|
||||
expect(useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { limit: '2mb', extended: true });
|
||||
});
|
||||
|
||||
it('accepts a 3 MiB import JSON body but rejects the same ordinary JSON body', async () => {
|
||||
const serverApp = express();
|
||||
configureHttpBodyParsers({
|
||||
use: serverApp.use.bind(serverApp),
|
||||
useBodyParser(type: 'json' | 'urlencoded', options: { limit: string; extended?: boolean }) {
|
||||
serverApp.use(type === 'json'
|
||||
? expressModule.json({ limit: options.limit })
|
||||
: expressModule.urlencoded({ limit: options.limit, extended: options.extended ?? true }));
|
||||
},
|
||||
} as never);
|
||||
serverApp.post('/api/client/send/imports/preview', (request, response) => response.json({ size: request.rawBody?.length ?? 0 }));
|
||||
serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true }));
|
||||
|
||||
const server = await new Promise<ReturnType<typeof serverApp.listen>>((resolve) => {
|
||||
const listening = serverApp.listen(0, '127.0.0.1', () => resolve(listening));
|
||||
});
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('test server did not expose a TCP port');
|
||||
const body = JSON.stringify({ content: 'x'.repeat(3 * 1024 * 1024) });
|
||||
const importResponse = await postJSON(address.port, '/api/client/send/imports/preview', body);
|
||||
expect(importResponse.status).toBe(200);
|
||||
expect(JSON.parse(importResponse.body)).toEqual({ size: Buffer.byteLength(body) });
|
||||
await expect(postJSON(address.port, '/api/ordinary', body)).resolves.toMatchObject({ status: 413 });
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function postJSON(port: number, path: string, body: string) {
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const request = http.request({ hostname: '127.0.0.1', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
|
||||
});
|
||||
request.once('error', reject);
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
|
||||
const express = require('express') as {
|
||||
json(options: {
|
||||
limit: string;
|
||||
verify(request: { rawBody?: Buffer }, response: unknown, buffer: Buffer): void;
|
||||
}): (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
export const DEFAULT_JSON_BODY_LIMIT = '2mb';
|
||||
export const IMPORT_JSON_BODY_LIMIT = '25mb';
|
||||
|
||||
export function configureHttpBodyParsers(app: NestExpressApplication) {
|
||||
// Import preview/confirmation temporarily carries the source CSV/TSV in
|
||||
// JSON. Give only these endpoints the larger boundary; keeping ordinary
|
||||
// JSON at 2 MiB limits the duplicate raw-buffer + parsed-object footprint.
|
||||
app.use('/api/client/send/imports', express.json({
|
||||
limit: IMPORT_JSON_BODY_LIMIT,
|
||||
verify(request, _response, buffer) {
|
||||
request.rawBody = buffer;
|
||||
},
|
||||
}));
|
||||
app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT });
|
||||
app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true });
|
||||
}
|
||||
+4
-1
@@ -1,8 +1,10 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { OpenApiModule } from './open-api/open-api.module';
|
||||
import { configureHttpBodyParsers } from './http-body-limits';
|
||||
|
||||
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
configurable: true,
|
||||
@@ -16,8 +18,9 @@ Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true, bodyParser: false });
|
||||
app.setGlobalPrefix('api');
|
||||
configureHttpBodyParsers(app);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('CMPP Platform API')
|
||||
|
||||
Reference in New Issue
Block a user