Pārlūkot izejas kodu

feat(stats-reporting): add IP clicks DTO and service method for retrieving paginated IP click statistics

Dave 1 mēnesi atpakaļ
vecāks
revīzija
16de134dae

+ 62 - 0
apps/box-stats-api/src/feature/stats-reporting/dto/stats-reporting-ip-clicks.dto.ts

@@ -0,0 +1,62 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsInt, IsOptional, IsString, Min } from 'class-validator';
+
+const GMT8_DAY_DESC =
+  'Defaults to today (GMT+8) when both fromSec and toSec are omitted.';
+
+export class StatsReportingIpClicksDto {
+  @ApiProperty({
+    description: 'Page number (1-based pagination). Required and must be >=1.',
+    minimum: 1,
+  })
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  page: number;
+
+  @ApiProperty({
+    description: 'Page size. Required and must be >=1.',
+    minimum: 1,
+  })
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  size: number;
+
+  @ApiPropertyOptional({ description: 'Channel identifier filter.' })
+  @IsOptional()
+  @IsString()
+  channelId?: string;
+
+  @ApiPropertyOptional({ description: 'Device type filter (e.g. iOS, Android).' })
+  @IsOptional()
+  @IsString()
+  deviceType?: string;
+
+  @ApiPropertyOptional({ description: 'Ad type filter (BANNER, STARTUP, etc).' })
+  @IsOptional()
+  @IsString()
+  adType?: string;
+
+  @ApiPropertyOptional({
+    description: `fromSec UTC epoch seconds. ${GMT8_DAY_DESC}`,
+  })
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  fromSec?: number;
+
+  @ApiPropertyOptional({
+    description: `toSec UTC epoch seconds (exclusive). ${GMT8_DAY_DESC}`,
+  })
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  toSec?: number;
+
+  @ApiPropertyOptional({ description: 'Source IP address filter.' })
+  @IsOptional()
+  @IsString()
+  ip?: string;
+}

+ 36 - 0
apps/box-stats-api/src/feature/stats-reporting/stats-reporting.controller.ts

@@ -4,6 +4,7 @@ import {
   ChannelStatsQueryDto,
 } from './dto/stats-reporting.dto';
 import { StatsReportingAdTypeClicksDto } from './dto/stats-reporting-adtype-clicks.dto';
+import { StatsReportingIpClicksDto } from './dto/stats-reporting-ip-clicks.dto';
 import { StatsReportingService } from './stats-reporting.service';
 import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
 import { alignDayStart } from './stats-reporting.time';
@@ -125,4 +126,39 @@ export class StatsReportingController {
       toSec: toSec!,
     });
   }
+
+  @Post('ip-clicks')
+  @ApiOperation({ summary: 'IP 点击统计(按 IP 分组)' })
+  @ApiBody({ type: StatsReportingIpClicksDto })
+  @ApiResponse({
+    status: 200,
+    description: 'Paginated IP click statistics',
+    schema: PaginatedResponseSchema,
+  })
+  async getIpClicks(@Body() dto: StatsReportingIpClicksDto): Promise<any> {
+    const nowSec = Math.floor(Date.now() / 1000);
+    let fromSec = typeof dto.fromSec === 'number' ? dto.fromSec : undefined;
+    let toSec = typeof dto.toSec === 'number' ? dto.toSec : undefined;
+
+    if (fromSec == null && toSec == null) {
+      const defaultFromSec = alignDayStart(nowSec);
+      fromSec = defaultFromSec;
+      toSec = defaultFromSec + 86400;
+    } else if (fromSec == null && toSec != null) {
+      fromSec = alignDayStart(toSec - 1);
+    } else if (fromSec != null && toSec == null) {
+      toSec = fromSec + 86400;
+    }
+
+    return this.statsReportingService.getIpClicks({
+      page: dto.page,
+      size: dto.size,
+      channelId: dto.channelId,
+      deviceType: dto.deviceType,
+      adType: dto.adType,
+      ip: dto.ip,
+      fromSec: fromSec!,
+      toSec: toSec!,
+    });
+  }
 }

+ 130 - 0
apps/box-stats-api/src/feature/stats-reporting/stats-reporting.service.ts

@@ -54,6 +54,25 @@ interface AdTypeClickRecord {
   clicks: number;
 }
 
+interface IpClickStatsParams {
+  page: number;
+  size: number;
+  fromSec: number;
+  toSec: number;
+  channelId?: string;
+  deviceType?: string;
+  adType?: string;
+  ip?: string;
+}
+
+interface IpClickRecord {
+  ip: string;
+  channelId: string;
+  deviceType: string | null;
+  adType: string | null;
+  clicks: number;
+}
+
 const DEFAULT_PAGE = 1;
 const DEFAULT_SIZE = 10;
 const MAX_SIZE = 100;
@@ -198,6 +217,117 @@ export class StatsReportingService {
     return { page, size, total, items: toJsonSafe(items) };
   }
 
+  async getIpClicks(
+    params: IpClickStatsParams,
+  ): Promise<PaginatedResult<IpClickRecord>> {
+    const { page, size, skip, take } = this.normalizePagination({
+      page: params.page,
+      size: params.size,
+    });
+
+    const match: Record<string, unknown> = {
+      clickedAt: {
+        $gte: BigInt(params.fromSec),
+        $lt: BigInt(params.toSec),
+      },
+    };
+
+    if (params.channelId) {
+      match.channelId = params.channelId;
+    }
+
+    if (params.adType) {
+      match.adType = params.adType;
+    }
+
+    if (params.deviceType) {
+      match.machine = params.deviceType;
+    }
+
+    if (params.ip) {
+      match.ip = params.ip;
+    }
+
+    const buildGroupStage = () => ({
+      $group: {
+        _id: {
+          channelId: '$channelId',
+          adType: '$adType',
+          deviceType: '$machine',
+          ip: '$ip',
+        },
+        clicks: { $sum: 1 },
+      },
+    });
+
+    const pipeline = [
+      { $match: match },
+      buildGroupStage(),
+      {
+        $project: {
+          _id: 0,
+          channelId: '$_id.channelId',
+          adType: '$_id.adType',
+          deviceType: '$_id.deviceType',
+          ip: '$_id.ip',
+          clicks: 1,
+        },
+      },
+      {
+        $sort: {
+          channelId: 1,
+          adType: 1,
+          deviceType: 1,
+          ip: 1,
+        },
+      },
+      { $skip: skip },
+      { $limit: take },
+    ] as const;
+
+    const countPipeline = [
+      { $match: match },
+      buildGroupStage(),
+      { $count: 'total' },
+    ];
+
+    const [items, countRows] = await Promise.all([
+      (this.prisma.adClickEvents as any).aggregateRaw({
+        pipeline,
+      }) as Promise<Array<IpClickRecord>>,
+      (this.prisma.adClickEvents as any).aggregateRaw({
+        pipeline: countPipeline,
+      }) as Promise<Array<{ total: number | bigint }>>,
+    ]);
+
+    const countValue =
+      Array.isArray(countRows) && countRows.length > 0
+        ? countRows[0].total
+        : 0;
+    const total =
+      typeof countValue === 'bigint'
+        ? Number(countValue)
+        : typeof countValue === 'number'
+        ? countValue
+        : 0;
+
+    const normalizedItems = (items ?? []).map((item) => ({
+      channelId: item.channelId ?? '',
+      deviceType: item.deviceType ?? null,
+      adType: item.adType ?? null,
+      ip: item.ip ?? '',
+      clicks:
+        typeof item.clicks === 'number' ? item.clicks : Number(item.clicks),
+    }));
+
+    return {
+      page,
+      size,
+      total,
+      items: toJsonSafe(normalizedItems),
+    };
+  }
+
   async getAdTypeClicks(
     params: AdTypeClicksParams,
   ): Promise<PaginatedResult<AdTypeClickRecord>> {