import { BadRequestException, Body, Controller, Headers, HttpCode, Logger, Post, Req, } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiResponse, ApiTags, ApiBadRequestResponse, ApiProperty, ApiPropertyOptional, } from '@nestjs/swagger'; import { Request } from 'express'; import { StatsAdClickPublisherService } from './stats-ad-click.publisher.service'; import { extractClientIp } from '../../utils/client-ip.util'; import { Transform } from 'class-transformer'; import { IsOptional, IsString, Matches } from 'class-validator'; export class AdClickRequestDto { @ApiProperty({ description: '用户唯一设备ID', example: 'xxxxxx' }) @IsString() uid!: string; @ApiProperty({ description: '渠道ID', example: 'AAA' }) @IsString() channelId!: string; @ApiProperty({ description: '广告 Mongo ObjectId', example: '64b7c2f967ce4d6799c047d1', }) @IsString() adsId!: string; // ✅ required @ApiPropertyOptional({ description: 'Optional IPv4 hint (empty string/null accepted).', example: '1.2.3.4', }) @Transform(({ value }) => { if (value === '' || value === null || value === undefined) { return undefined; } return typeof value === 'string' ? value.trim() : value; }) @IsOptional() @Matches( /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$/, { message: 'ipv4 must be a valid IPv4 address' }, ) ipv4?: string; } @ApiTags('Ads Stats') @Controller('stats') export class AdsStatsController { private readonly logger = new Logger(AdsStatsController.name); constructor(private readonly publisher: StatsAdClickPublisherService) {} @Post('ad-click') @HttpCode(202) @ApiOperation({ summary: '广告点击上报', description: '用于广告点击统计。uid、channelId、adsId 必填。', }) @ApiBody({ type: AdClickRequestDto, description: 'uid、channelId、adsId 必填(adsId 为 Mongo ObjectId)', examples: { example: { summary: '示例', value: { uid: 'xxxxxx', channelId: 'AAA', adsId: '64b7c2f967ce4d6799c047d1', }, }, }, }) @ApiResponse({ status: 202, description: '已接收(异步处理)', schema: { example: { ok: true } }, }) @ApiBadRequestResponse({ description: '参数错误(缺少 uid/channelId/adsId)', }) publishAdClick( @Req() req: Request, @Body() body: AdClickRequestDto, ): { ok: true } { if (!body?.uid || !body?.channelId || !body?.adsId) { throw new BadRequestException('uid, channelId and adsId are required'); } const clientIp = // `ipv4` is a frontend-provided hint, used only when present & validated; // backend extraction remains the fallback. body.ipv4 || extractClientIp(req); this.publisher.publishAdClick({ uid: body.uid, channelId: body.channelId, adsId: body.adsId, headers: req.headers, clientIp, }); return { ok: true }; } }