From 510a890b337de5da371182721653f8113a72b90a Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 9 Mar 2026 22:32:29 -0700 Subject: [PATCH] =?UTF-8?q?fix(mining-admin):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97=20IP=20=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=BA=E5=86=85=E7=BD=91=20IP=20=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E8=A1=A5=E5=85=A8=E5=85=A8=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E7=9A=84=20IP=20=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【根本原因】 - 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 --- .../src/api/controllers/auth.controller.ts | 5 +++-- .../api/controllers/batch-mining.controller.ts | 5 +++++ .../src/api/controllers/capability.controller.ts | 5 ++++- .../src/api/controllers/config.controller.ts | 15 ++++++++++----- .../api/controllers/manual-mining.controller.ts | 3 +++ .../pre-planting-restriction.controller.ts | 3 ++- .../src/application/services/auth.service.ts | 4 ++-- .../application/services/batch-mining.service.ts | 4 ++++ .../services/capability-admin.service.ts | 12 ++++++++++-- .../src/application/services/config.service.ts | 14 +++++++++++--- .../services/manual-mining.service.ts | 4 ++++ .../services/pre-planting-restriction.service.ts | 4 +++- .../services/mining-admin-service/src/main.ts | 3 +++ .../src/shared/utils/get-client-ip.ts | 16 ++++++++++++++++ 14 files changed, 80 insertions(+), 17 deletions(-) create mode 100644 backend/services/mining-admin-service/src/shared/utils/get-client-ip.ts diff --git a/backend/services/mining-admin-service/src/api/controllers/auth.controller.ts b/backend/services/mining-admin-service/src/api/controllers/auth.controller.ts index b85e943f..bdc0b18e 100644 --- a/backend/services/mining-admin-service/src/api/controllers/auth.controller.ts +++ b/backend/services/mining-admin-service/src/api/controllers/auth.controller.ts @@ -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 }; } } diff --git a/backend/services/mining-admin-service/src/api/controllers/batch-mining.controller.ts b/backend/services/mining-admin-service/src/api/controllers/batch-mining.controller.ts index 68b8008e..96f3babf 100644 --- a/backend/services/mining-admin-service/src/api/controllers/batch-mining.controller.ts +++ b/backend/services/mining-admin-service/src/api/controllers/batch-mining.controller.ts @@ -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; diff --git a/backend/services/mining-admin-service/src/api/controllers/capability.controller.ts b/backend/services/mining-admin-service/src/api/controllers/capability.controller.ts index f9f45dd4..c584f37d 100644 --- a/backend/services/mining-admin-service/src/api/controllers/capability.controller.ts +++ b/backend/services/mining-admin-service/src/api/controllers/capability.controller.ts @@ -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'], ); } diff --git a/backend/services/mining-admin-service/src/api/controllers/config.controller.ts b/backend/services/mining-admin-service/src/api/controllers/config.controller.ts index 37a3d42b..50222a21 100644 --- a/backend/services/mining-admin-service/src/api/controllers/config.controller.ts +++ b/backend/services/mining-admin-service/src/api/controllers/config.controller.ts @@ -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 }; } } diff --git a/backend/services/mining-admin-service/src/api/controllers/manual-mining.controller.ts b/backend/services/mining-admin-service/src/api/controllers/manual-mining.controller.ts index 44cd5374..0254bf9f 100644 --- a/backend/services/mining-admin-service/src/api/controllers/manual-mining.controller.ts +++ b/backend/services/mining-admin-service/src/api/controllers/manual-mining.controller.ts @@ -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'], ); } diff --git a/backend/services/mining-admin-service/src/api/controllers/pre-planting-restriction.controller.ts b/backend/services/mining-admin-service/src/api/controllers/pre-planting-restriction.controller.ts index 6aab835e..321af615 100644 --- a/backend/services/mining-admin-service/src/api/controllers/pre-planting-restriction.controller.ts +++ b/backend/services/mining-admin-service/src/api/controllers/pre-planting-restriction.controller.ts @@ -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 }; } } diff --git a/backend/services/mining-admin-service/src/application/services/auth.service.ts b/backend/services/mining-admin-service/src/application/services/auth.service.ts index 99b8bbdb..8ddb080d 100644 --- a/backend/services/mining-admin-service/src/application/services/auth.service.ts +++ b/backend/services/mining-admin-service/src/application/services/auth.service.ts @@ -49,9 +49,9 @@ export class AuthService { }; } - async logout(adminId: string): Promise { + async logout(adminId: string, ipAddress?: string, userAgent?: string): Promise { await this.prisma.auditLog.create({ - data: { adminId, action: 'LOGOUT', resource: 'AUTH' }, + data: { adminId, action: 'LOGOUT', resource: 'AUTH', ipAddress, userAgent }, }); } diff --git a/backend/services/mining-admin-service/src/application/services/batch-mining.service.ts b/backend/services/mining-admin-service/src/application/services/batch-mining.service.ts index 3bb0fda8..12c013da 100644 --- a/backend/services/mining-admin-service/src/application/services/batch-mining.service.ts +++ b/backend/services/mining-admin-service/src/application/services/batch-mining.service.ts @@ -137,6 +137,8 @@ export class BatchMiningService { async execute( request: BatchMiningRequest, adminId: string, + ipAddress?: string, + userAgent?: string, ): Promise { 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] 审计日志记录成功`); diff --git a/backend/services/mining-admin-service/src/application/services/capability-admin.service.ts b/backend/services/mining-admin-service/src/application/services/capability-admin.service.ts index d4eba01a..5ef1f68b 100644 --- a/backend/services/mining-admin-service/src/application/services/capability-admin.service.ts +++ b/backend/services/mining-admin-service/src/application/services/capability-admin.service.ts @@ -94,6 +94,8 @@ export class CapabilityAdminService { accountSequence: string, dto: SetCapabilityDto, adminId: string, + ipAddress?: string, + userAgent?: string, ): Promise { 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 { // 验证所有能力 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 { 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) { diff --git a/backend/services/mining-admin-service/src/application/services/config.service.ts b/backend/services/mining-admin-service/src/application/services/config.service.ts index 284ffb28..8848664a 100644 --- a/backend/services/mining-admin-service/src/application/services/config.service.ts +++ b/backend/services/mining-admin-service/src/application/services/config.service.ts @@ -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 { + async setConfig(adminId: string, category: string, key: string, value: string, description?: string, ipAddress?: string, userAgent?: string): Promise { 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 { + async recordAuditLog(adminId: string, action: string, resource: string, resourceId?: string, newValue?: any, ipAddress?: string, userAgent?: string): Promise { + 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 { 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 }, }); } } diff --git a/backend/services/mining-admin-service/src/application/services/manual-mining.service.ts b/backend/services/mining-admin-service/src/application/services/manual-mining.service.ts index bc0efd41..ba934062 100644 --- a/backend/services/mining-admin-service/src/application/services/manual-mining.service.ts +++ b/backend/services/mining-admin-service/src/application/services/manual-mining.service.ts @@ -76,6 +76,8 @@ export class ManualMiningService { async execute( request: ManualMiningExecuteRequest, adminId: string, + ipAddress?: string, + userAgent?: string, ): Promise { try { const response = await fetch( @@ -109,6 +111,8 @@ export class ManualMiningService { amount: result.amount, reason: request.reason, }, + ipAddress, + userAgent, }, }); diff --git a/backend/services/mining-admin-service/src/application/services/pre-planting-restriction.service.ts b/backend/services/mining-admin-service/src/application/services/pre-planting-restriction.service.ts index d9f55e66..68d4cb0b 100644 --- a/backend/services/mining-admin-service/src/application/services/pre-planting-restriction.service.ts +++ b/backend/services/mining-admin-service/src/application/services/pre-planting-restriction.service.ts @@ -57,7 +57,7 @@ export class PrePlantingRestrictionService { * @param accountSequence 被解除限制的用户账户序列 * @param reason 解除原因(可选) */ - async unlockRestriction(adminId: string, accountSequence: string, reason?: string): Promise { + async unlockRestriction(adminId: string, accountSequence: string, reason?: string, ipAddress?: string, userAgent?: string): Promise { // 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, }, }); } diff --git a/backend/services/mining-admin-service/src/main.ts b/backend/services/mining-admin-service/src/main.ts index 77633ee3..b87c1d24 100644 --- a/backend/services/mining-admin-service/src/main.ts +++ b/backend/services/mining-admin-service/src/main.ts @@ -8,6 +8,9 @@ import { join } from 'path'; async function bootstrap() { const app = await NestFactory.create(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', { diff --git a/backend/services/mining-admin-service/src/shared/utils/get-client-ip.ts b/backend/services/mining-admin-service/src/shared/utils/get-client-ip.ts new file mode 100644 index 00000000..e4d6a037 --- /dev/null +++ b/backend/services/mining-admin-service/src/shared/utils/get-client-ip.ts @@ -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'; +}