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:
hailin 2026-03-09 22:32:29 -07:00
parent 92c71b5e97
commit 510a890b33
14 changed files with 80 additions and 17 deletions

View File

@ -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 };
}
}

View File

@ -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;

View File

@ -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'],
);
}

View File

@ -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 };
}
}

View File

@ -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'],
);
}

View File

@ -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 };
}
}

View File

@ -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 },
});
}

View File

@ -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] 审计日志记录成功`);

View File

@ -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) {

View File

@ -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 },
});
}
}

View File

@ -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,
},
});

View File

@ -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,
},
});
}

View File

@ -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', {

View File

@ -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';
}