fix(mining-admin): 修复审计日志 IP 地址记录为内网 IP 的问题,补全全操作的 IP 审计
【根本原因】
- main.ts 缺少 `app.set('trust proxy', 1)`,Express 在 Nginx/Kong 代理后 req.ip
返回的是代理服务器内网 IP(127.0.0.1),而非真实客户端 IP。
- Nginx 已正确设置 X-Forwarded-For / X-Real-IP,但后端从未读取这些 header。
【修复内容】
1. main.ts
- 新增 `app.set('trust proxy', 1)`,使 Express 信任 Nginx 第一跳的
X-Forwarded-For,req.ip 从此返回真实客户端 IP。
2. shared/utils/get-client-ip.ts(新建工具函数)
- 优先读取 X-Forwarded-For(取逗号分隔的第一个 IP,支持多跳代理)
- 其次读取 X-Real-IP
- 兜底使用 req.ip
- 全服务统一使用此函数,避免各处重复逻辑。
3. auth.controller.ts / auth.service.ts
- LOGIN:将 req.ip 改为 getClientIp(req)(已记录 IP,修正来源)
- LOGOUT:之前完全不记录 IP/UA,现在补全传入并存入审计日志。
4. config.service.ts / config.controller.ts
- setConfig / deleteConfig 新增 ipAddress / userAgent 可选参数。
- 新增 recordAuditLog 通用方法,供 Controller 记录任意审计事件。
- 所有写操作(setTransferEnabled、setP2pTransferFee、setConfig、
deleteConfig)均传入真实 IP 和 UA。
- activateMining / deactivateMining 之前完全无审计日志,
现补录 ACTIVATE / DEACTIVATE 类型的 MINING 审计条目。
5. capability-admin.service.ts / capability.controller.ts
- setCapability / setCapabilities / writeAuditLog 均新增
ipAddress / userAgent 参数,Controller 层传入真实 IP。
6. pre-planting-restriction.service.ts / controller
- unlockRestriction 新增 ipAddress / userAgent 参数并写入审计日志。
7. manual-mining.service.ts / controller
- execute 新增 ipAddress / userAgent 参数并写入审计日志。
8. batch-mining.service.ts / controller
- execute 新增 ipAddress / userAgent 参数并写入审计日志
(upload-execute 和 execute 两个入口均已更新)。
【影响范围】
- 仅 mining-admin-service,无数据库 Schema 变更(ipAddress/userAgent 字段已存在)。
- 所有现有接口签名向后兼容(新增参数均为可选)。
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
92c71b5e97
commit
510a890b33
|
|
@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiProperty } from '@nestjs/swagg
|
|||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
import { AuthService } from '../../application/services/auth.service';
|
||||
import { Public } from '../../shared/guards/admin-auth.guard';
|
||||
import { getClientIp } from '../../shared/utils/get-client-ip';
|
||||
|
||||
class LoginDto {
|
||||
@ApiProperty({ description: '用户名' })
|
||||
|
|
@ -26,7 +27,7 @@ export class AuthController {
|
|||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '管理员登录' })
|
||||
async login(@Body() dto: LoginDto, @Req() req: any) {
|
||||
return this.authService.login(dto.username, dto.password, req.ip, req.headers['user-agent']);
|
||||
return this.authService.login(dto.username, dto.password, getClientIp(req), req.headers['user-agent']);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
|
|
@ -45,7 +46,7 @@ export class AuthController {
|
|||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '退出登录' })
|
||||
async logout(@Req() req: any) {
|
||||
await this.authService.logout(req.admin.id);
|
||||
await this.authService.logout(req.admin.id, getClientIp(req), req.headers['user-agent']);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { BatchMiningService, BatchMiningItem } from '../../application/services/batch-mining.service';
|
||||
import { getClientIp } from '../../shared/utils/get-client-ip';
|
||||
|
||||
@ApiTags('Batch Mining')
|
||||
@ApiBearerAuth()
|
||||
|
|
@ -257,6 +258,8 @@ export class BatchMiningController {
|
|||
reason: body.reason,
|
||||
},
|
||||
admin.id,
|
||||
getClientIp(req),
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
|
||||
this.logger.log(`[POST /batch-mining/upload-execute] 执行成功: successCount=${result.successCount}, totalAmount=${result.totalAmount}`);
|
||||
|
|
@ -331,6 +334,8 @@ export class BatchMiningController {
|
|||
reason: body.reason,
|
||||
},
|
||||
admin.id,
|
||||
getClientIp(req),
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
this.logger.log(`[POST /batch-mining/execute] 执行成功`);
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { CapabilityAdminService, SetCapabilityDto } from '../../application/services/capability-admin.service';
|
||||
import { getClientIp } from '../../shared/utils/get-client-ip';
|
||||
|
||||
@ApiTags('Capabilities')
|
||||
@ApiBearerAuth()
|
||||
|
|
@ -30,7 +31,7 @@ export class CapabilityController {
|
|||
@Req() req: any,
|
||||
) {
|
||||
const adminId = req.admin?.id || req.admin?.username || 'unknown';
|
||||
return this.capabilityAdminService.setCapability(accountSequence, dto, adminId);
|
||||
return this.capabilityAdminService.setCapability(accountSequence, dto, adminId, getClientIp(req), req.headers['user-agent']);
|
||||
}
|
||||
|
||||
@Put('users/:accountSequence/bulk')
|
||||
|
|
@ -46,6 +47,8 @@ export class CapabilityController {
|
|||
accountSequence,
|
||||
body.capabilities,
|
||||
adminId,
|
||||
getClientIp(req),
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiParam } from '@nestj
|
|||
import { ConfigService } from '@nestjs/config';
|
||||
import { ConfigManagementService } from '../../application/services/config.service';
|
||||
import { Public } from '../../shared/guards/admin-auth.guard';
|
||||
import { getClientIp } from '../../shared/utils/get-client-ip';
|
||||
|
||||
class SetConfigDto { category: string; key: string; value: string; description?: string; }
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ export class ConfigController {
|
|||
@Post('transfer-enabled')
|
||||
@ApiOperation({ summary: '设置划转开关状态' })
|
||||
async setTransferEnabled(@Body() body: { enabled: boolean }, @Req() req: any) {
|
||||
await this.configService.setConfig(req.admin.id, 'system', 'transfer_enabled', String(body.enabled), '划转开关');
|
||||
await this.configService.setConfig(req.admin.id, 'system', 'transfer_enabled', String(body.enabled), '划转开关', getClientIp(req), req.headers['user-agent']);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
|
@ -187,6 +188,7 @@ export class ConfigController {
|
|||
}
|
||||
const result = await response.json();
|
||||
this.logger.log(`Mining activated by admin ${req.admin?.id}`);
|
||||
await this.configService.recordAuditLog(req.admin.id, 'ACTIVATE', 'MINING', undefined, undefined, getClientIp(req), req.headers['user-agent']);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) throw error;
|
||||
|
|
@ -208,6 +210,7 @@ export class ConfigController {
|
|||
}
|
||||
const result = await response.json();
|
||||
this.logger.log(`Mining deactivated by admin ${req.admin?.id}`);
|
||||
await this.configService.recordAuditLog(req.admin.id, 'DEACTIVATE', 'MINING', undefined, undefined, getClientIp(req), req.headers['user-agent']);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to deactivate mining', error);
|
||||
|
|
@ -246,14 +249,16 @@ export class ConfigController {
|
|||
throw new BadRequestException('最小划转金额必须大于手续费');
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const ua = req.headers['user-agent'];
|
||||
await Promise.all([
|
||||
this.configService.setConfig(
|
||||
req.admin.id, 'trading', 'p2p_transfer_fee',
|
||||
body.fee, 'P2P划转手续费(积分值)',
|
||||
body.fee, 'P2P划转手续费(积分值)', ip, ua,
|
||||
),
|
||||
this.configService.setConfig(
|
||||
req.admin.id, 'trading', 'min_p2p_transfer_amount',
|
||||
body.minTransferAmount, 'P2P最小划转金额(积分值)',
|
||||
body.minTransferAmount, 'P2P最小划转金额(积分值)', ip, ua,
|
||||
),
|
||||
]);
|
||||
|
||||
|
|
@ -287,14 +292,14 @@ export class ConfigController {
|
|||
@Post()
|
||||
@ApiOperation({ summary: '设置配置' })
|
||||
async setConfig(@Body() dto: SetConfigDto, @Req() req: any) {
|
||||
await this.configService.setConfig(req.admin.id, dto.category, dto.key, dto.value, dto.description);
|
||||
await this.configService.setConfig(req.admin.id, dto.category, dto.key, dto.value, dto.description, getClientIp(req), req.headers['user-agent']);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete(':category/:key')
|
||||
@ApiOperation({ summary: '删除配置' })
|
||||
async deleteConfig(@Param('category') category: string, @Param('key') key: string, @Req() req: any) {
|
||||
await this.configService.deleteConfig(req.admin.id, category, key);
|
||||
await this.configService.deleteConfig(req.admin.id, category, key, getClientIp(req), req.headers['user-agent']);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { ManualMiningService } from '../../application/services/manual-mining.service';
|
||||
import { getClientIp } from '../../shared/utils/get-client-ip';
|
||||
|
||||
@ApiTags('Manual Mining')
|
||||
@ApiBearerAuth()
|
||||
|
|
@ -88,6 +89,8 @@ export class ManualMiningController {
|
|||
reason: body.reason,
|
||||
},
|
||||
admin.id,
|
||||
getClientIp(req),
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Controller, Get, Post, Param, Body, Req } from '@nestjs/common';
|
|||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiBody } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
import { PrePlantingRestrictionService } from '../../application/services/pre-planting-restriction.service';
|
||||
import { getClientIp } from '../../shared/utils/get-client-ip';
|
||||
|
||||
class UnlockRestrictionDto {
|
||||
@IsString()
|
||||
|
|
@ -40,7 +41,7 @@ export class PrePlantingRestrictionController {
|
|||
@Req() req: any,
|
||||
) {
|
||||
const adminId = req.admin?.id || req.admin?.username || 'ADMIN_MANUAL';
|
||||
await this.restrictionService.unlockRestriction(adminId, accountSequence, body?.reason);
|
||||
await this.restrictionService.unlockRestriction(adminId, accountSequence, body?.reason, getClientIp(req), req.headers['user-agent']);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ export class AuthService {
|
|||
};
|
||||
}
|
||||
|
||||
async logout(adminId: string): Promise<void> {
|
||||
async logout(adminId: string, ipAddress?: string, userAgent?: string): Promise<void> {
|
||||
await this.prisma.auditLog.create({
|
||||
data: { adminId, action: 'LOGOUT', resource: 'AUTH' },
|
||||
data: { adminId, action: 'LOGOUT', resource: 'AUTH', ipAddress, userAgent },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,8 @@ export class BatchMiningService {
|
|||
async execute(
|
||||
request: BatchMiningRequest,
|
||||
adminId: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
): Promise<any> {
|
||||
const url = `${this.miningServiceUrl}/api/v2/mining/admin/batch-mining/execute`;
|
||||
this.logger.log(`[execute] 开始执行批量补发, URL: ${url}`);
|
||||
|
|
@ -183,6 +185,8 @@ export class BatchMiningService {
|
|||
totalAmount: data.totalAmount,
|
||||
reason: request.reason,
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
this.logger.log(`[execute] 审计日志记录成功`);
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ export class CapabilityAdminService {
|
|||
accountSequence: string,
|
||||
dto: SetCapabilityDto,
|
||||
adminId: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
): Promise<CapabilityItem[]> {
|
||||
if (!ALL_CAPABILITIES.includes(dto.capability as any)) {
|
||||
throw new BadRequestException(`无效的能力: ${dto.capability}`);
|
||||
|
|
@ -120,7 +122,7 @@ export class CapabilityAdminService {
|
|||
}
|
||||
|
||||
// 写本地审计日志
|
||||
await this.writeAuditLog(adminId, accountSequence, dto);
|
||||
await this.writeAuditLog(adminId, accountSequence, dto, ipAddress, userAgent);
|
||||
|
||||
// 返回最新能力列表
|
||||
return this.getCapabilities(accountSequence);
|
||||
|
|
@ -138,6 +140,8 @@ export class CapabilityAdminService {
|
|||
accountSequence: string,
|
||||
items: SetCapabilityDto[],
|
||||
adminId: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
): Promise<CapabilityItem[]> {
|
||||
// 验证所有能力
|
||||
for (const item of items) {
|
||||
|
|
@ -170,7 +174,7 @@ export class CapabilityAdminService {
|
|||
|
||||
// 写本地审计日志
|
||||
for (const item of items) {
|
||||
await this.writeAuditLog(adminId, accountSequence, item);
|
||||
await this.writeAuditLog(adminId, accountSequence, item, ipAddress, userAgent);
|
||||
}
|
||||
|
||||
return this.getCapabilities(accountSequence);
|
||||
|
|
@ -219,6 +223,8 @@ export class CapabilityAdminService {
|
|||
adminId: string,
|
||||
accountSequence: string,
|
||||
dto: SetCapabilityDto,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.prisma.auditLog.create({
|
||||
|
|
@ -233,6 +239,8 @@ export class CapabilityAdminService {
|
|||
reason: dto.reason || null,
|
||||
expiresAt: dto.expiresAt || null,
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export class ConfigManagementService {
|
|||
return this.prisma.systemConfig.findUnique({ where: { category_key: { category, key } } });
|
||||
}
|
||||
|
||||
async setConfig(adminId: string, category: string, key: string, value: string, description?: string): Promise<void> {
|
||||
async setConfig(adminId: string, category: string, key: string, value: string, description?: string, ipAddress?: string, userAgent?: string): Promise<void> {
|
||||
const existing = await this.getConfig(category, key);
|
||||
|
||||
await this.prisma.systemConfig.upsert({
|
||||
|
|
@ -33,18 +33,26 @@ export class ConfigManagementService {
|
|||
resourceId: `${category}:${key}`,
|
||||
oldValue: existing ? { value: existing.value } : undefined,
|
||||
newValue: { value },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConfig(adminId: string, category: string, key: string): Promise<void> {
|
||||
async recordAuditLog(adminId: string, action: string, resource: string, resourceId?: string, newValue?: any, ipAddress?: string, userAgent?: string): Promise<void> {
|
||||
await this.prisma.auditLog.create({
|
||||
data: { adminId, action, resource, resourceId, newValue, ipAddress, userAgent },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConfig(adminId: string, category: string, key: string, ipAddress?: string, userAgent?: string): Promise<void> {
|
||||
const existing = await this.getConfig(category, key);
|
||||
if (!existing) return;
|
||||
|
||||
await this.prisma.systemConfig.delete({ where: { category_key: { category, key } } });
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: { adminId, action: 'DELETE', resource: 'CONFIG', resourceId: `${category}:${key}`, oldValue: existing },
|
||||
data: { adminId, action: 'DELETE', resource: 'CONFIG', resourceId: `${category}:${key}`, oldValue: existing, ipAddress, userAgent },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ export class ManualMiningService {
|
|||
async execute(
|
||||
request: ManualMiningExecuteRequest,
|
||||
adminId: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
|
|
@ -109,6 +111,8 @@ export class ManualMiningService {
|
|||
amount: result.amount,
|
||||
reason: request.reason,
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export class PrePlantingRestrictionService {
|
|||
* @param accountSequence 被解除限制的用户账户序列
|
||||
* @param reason 解除原因(可选)
|
||||
*/
|
||||
async unlockRestriction(adminId: string, accountSequence: string, reason?: string): Promise<void> {
|
||||
async unlockRestriction(adminId: string, accountSequence: string, reason?: string, ipAddress?: string, userAgent?: string): Promise<void> {
|
||||
// 1. 调用 contribution-service 写入 override
|
||||
const url = `${this.contributionServiceUrl}/api/v2/pre-planting/sell-restriction/${accountSequence}/unlock`;
|
||||
const response = await fetch(url, {
|
||||
|
|
@ -82,6 +82,8 @@ export class PrePlantingRestrictionService {
|
|||
resource: 'PRE_PLANTING_SELL_RESTRICTION',
|
||||
resourceId: accountSequence,
|
||||
newValue: { reason: reason ?? null, unlockedBy: adminId },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import { join } from 'path';
|
|||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// 信任 Nginx/Kong 代理传入的 X-Forwarded-For,使 req.ip 返回真实客户端 IP
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }));
|
||||
app.enableCors({ origin: process.env.CORS_ORIGIN || '*', credentials: true });
|
||||
app.setGlobalPrefix('api/v2', {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* 从请求中提取真实客户端 IP。
|
||||
* 优先级:X-Forwarded-For(取第一个) > X-Real-IP > req.ip
|
||||
*/
|
||||
export function getClientIp(req: any): string {
|
||||
const xff = req.headers?.['x-forwarded-for'];
|
||||
if (xff) {
|
||||
const first = typeof xff === 'string' ? xff : xff[0];
|
||||
return first.split(',')[0].trim();
|
||||
}
|
||||
const xri = req.headers?.['x-real-ip'];
|
||||
if (xri) {
|
||||
return typeof xri === 'string' ? xri : xri[0];
|
||||
}
|
||||
return req.ip || 'unknown';
|
||||
}
|
||||
Loading…
Reference in New Issue