ads-stats.controller.ts 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. import {
  2. BadRequestException,
  3. Body,
  4. Controller,
  5. Headers,
  6. HttpCode,
  7. Logger,
  8. Post,
  9. Req,
  10. } from '@nestjs/common';
  11. import {
  12. ApiBody,
  13. ApiOperation,
  14. ApiResponse,
  15. ApiTags,
  16. ApiBadRequestResponse,
  17. ApiProperty,
  18. ApiPropertyOptional,
  19. } from '@nestjs/swagger';
  20. import { Request } from 'express';
  21. import { StatsAdClickPublisherService } from './stats-ad-click.publisher.service';
  22. import { extractClientIp } from '../../utils/client-ip.util';
  23. import { Transform } from 'class-transformer';
  24. import { IsOptional, IsString, Matches } from 'class-validator';
  25. export class AdClickRequestDto {
  26. @ApiProperty({ description: '用户唯一设备ID', example: 'xxxxxx' })
  27. @IsString()
  28. uid!: string;
  29. @ApiProperty({ description: '渠道ID', example: 'AAA' })
  30. @IsString()
  31. channelId!: string;
  32. @ApiProperty({
  33. description: '广告 Mongo ObjectId',
  34. example: '64b7c2f967ce4d6799c047d1',
  35. })
  36. @IsString()
  37. adsId!: string; // ✅ required
  38. @ApiPropertyOptional({
  39. description: 'Optional IPv4 hint (empty string/null accepted).',
  40. example: '1.2.3.4',
  41. })
  42. @Transform(({ value }) => {
  43. if (value === '' || value === null || value === undefined) {
  44. return undefined;
  45. }
  46. return typeof value === 'string' ? value.trim() : value;
  47. })
  48. @IsOptional()
  49. @Matches(
  50. /^(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}$/,
  51. { message: 'ipv4 must be a valid IPv4 address' },
  52. )
  53. ipv4?: string;
  54. }
  55. @ApiTags('Ads Stats')
  56. @Controller('stats')
  57. export class AdsStatsController {
  58. private readonly logger = new Logger(AdsStatsController.name);
  59. constructor(private readonly publisher: StatsAdClickPublisherService) {}
  60. @Post('ad-click')
  61. @HttpCode(202)
  62. @ApiOperation({
  63. summary: '广告点击上报',
  64. description: '用于广告点击统计。uid、channelId、adsId 必填。',
  65. })
  66. @ApiBody({
  67. type: AdClickRequestDto,
  68. description: 'uid、channelId、adsId 必填(adsId 为 Mongo ObjectId)',
  69. examples: {
  70. example: {
  71. summary: '示例',
  72. value: {
  73. uid: 'xxxxxx',
  74. channelId: 'AAA',
  75. adsId: '64b7c2f967ce4d6799c047d1',
  76. },
  77. },
  78. },
  79. })
  80. @ApiResponse({
  81. status: 202,
  82. description: '已接收(异步处理)',
  83. schema: { example: { ok: true } },
  84. })
  85. @ApiBadRequestResponse({
  86. description: '参数错误(缺少 uid/channelId/adsId)',
  87. })
  88. publishAdClick(
  89. @Req() req: Request,
  90. @Body() body: AdClickRequestDto,
  91. ): { ok: true } {
  92. if (!body?.uid || !body?.channelId || !body?.adsId) {
  93. throw new BadRequestException('uid, channelId and adsId are required');
  94. }
  95. const clientIp =
  96. // `ipv4` is a frontend-provided hint, used only when present & validated;
  97. // backend extraction remains the fallback.
  98. body.ipv4 || extractClientIp(req);
  99. this.publisher.publishAdClick({
  100. uid: body.uid,
  101. channelId: body.channelId,
  102. adsId: body.adsId,
  103. headers: req.headers,
  104. clientIp,
  105. });
  106. return { ok: true };
  107. }
  108. }