Эх сурвалжийг харах

feat(stats-reporting): add ad type clicks DTO and service method for retrieving paginated ad click records

Dave 1 сар өмнө
parent
commit
94df97adb3

+ 48 - 0
apps/box-stats-api/src/feature/stats-reporting/dto/stats-reporting-adtype-clicks.dto.ts

@@ -0,0 +1,48 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsInt, IsOptional, IsString, Min } from 'class-validator';
+
+const DAY_DESC = 'Defaults to today (GMT+8) when fromSec/toSec are both absent.';
+
+export class StatsReportingAdTypeClicksDto {
+  @ApiPropertyOptional({ description: 'Page number (1-based pagination). Defaults to 1.' })
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  page?: number;
+
+  @ApiPropertyOptional({ description: 'Page size (clamped to 1..100). Defaults to 10.' })
+  @IsOptional()
+  @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. ${DAY_DESC}` })
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  fromSec?: number;
+
+  @ApiPropertyOptional({ description: `toSec UTC epoch seconds (exclusive). ${DAY_DESC}` })
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  toSec?: number;
+}

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

@@ -3,8 +3,10 @@ import {
   AdsStatsQueryDto,
   ChannelStatsQueryDto,
 } from './dto/stats-reporting.dto';
+import { StatsReportingAdTypeClicksDto } from './dto/stats-reporting-adtype-clicks.dto';
 import { StatsReportingService } from './stats-reporting.service';
 import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { alignDayStart } from './stats-reporting.time';
 
 const PaginatedResponseSchema = {
   type: 'object',
@@ -76,4 +78,51 @@ export class StatsReportingController {
   async getChannelDailyUsers(@Body() dto: ChannelStatsQueryDto) {
     return this.statsReportingService.getChannelDailyUsers(dto);
   }
+
+  @Post('adtype-clicks')
+  @ApiOperation({
+    summary: '广告点击记录 (AdType Click Records)',
+    description:
+      'Returns raw click records filtered by optional channel/device/adType + UTC fromSec/toSec. Defaults to GMT+8 today when the range is omitted. Results are intended to be ordered by channelId, adType, deviceType, ip ascending.',
+  })
+  @ApiBody({ type: StatsReportingAdTypeClicksDto })
+  @ApiResponse({
+    status: 200,
+    description: 'Paginated ad click records',
+    schema: PaginatedResponseSchema,
+  })
+  async getAdTypeClicks(
+    @Body() dto: StatsReportingAdTypeClicksDto,
+  ): Promise<any> {
+    const page = Number.isFinite(dto.page ?? NaN)
+      ? Math.max(1, Math.floor(dto.page!))
+      : 1;
+    const requestedSize = Number.isFinite(dto.size ?? NaN)
+      ? Math.floor(dto.size!)
+      : 10;
+    const size = Math.max(1, Math.min(100, requestedSize));
+
+    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) {
+      fromSec = alignDayStart(nowSec);
+      toSec = fromSec + 86400;
+    } else if (fromSec == null && toSec != null) {
+      fromSec = alignDayStart(toSec - 1);
+    } else if (fromSec != null && toSec == null) {
+      toSec = fromSec + 86400;
+    }
+
+    return this.statsReportingService.getAdTypeClicks({
+      page,
+      size,
+      channelId: dto.channelId,
+      deviceType: dto.deviceType,
+      adType: dto.adType,
+      fromSec: fromSec!,
+      toSec: toSec!,
+    });
+  }
 }

+ 116 - 10
apps/box-stats-api/src/feature/stats-reporting/stats-reporting.service.ts

@@ -37,9 +37,26 @@ interface NormalizedPagination {
   take: number;
 }
 
+interface AdTypeClicksParams {
+  page: number;
+  size: number;
+  channelId?: string;
+  deviceType?: string;
+  adType?: string;
+  fromSec: number;
+  toSec: number;
+}
+
+interface AdTypeClickRecord {
+  channelId: string;
+  deviceType: string | null;
+  adType: string | null;
+  clicks: number;
+}
+
 const DEFAULT_PAGE = 1;
 const DEFAULT_SIZE = 10;
-const MAX_SIZE = 200;
+const MAX_SIZE = 100;
 
 @Injectable()
 export class StatsReportingService {
@@ -181,6 +198,100 @@ export class StatsReportingService {
     return { page, size, total, items: toJsonSafe(items) };
   }
 
+  async getAdTypeClicks(
+    params: AdTypeClicksParams,
+  ): Promise<PaginatedResult<AdTypeClickRecord>> {
+    const { page, size } = params;
+    const skip = Math.max(0, (page - 1) * size);
+    const take = 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;
+    }
+
+    const buildGroupStage = () => ({
+      $group: {
+        _id: {
+          channelId: '$channelId',
+          deviceType: '$machine',
+          adType: '$adType',
+        },
+        clicks: { $sum: 1 },
+      },
+    });
+
+    const pipeline = [
+      { $match: match },
+      buildGroupStage(),
+      {
+        $project: {
+          _id: 0,
+          channelId: '$_id.channelId',
+          deviceType: '$_id.deviceType',
+          adType: '$_id.adType',
+          clicks: 1,
+        },
+      },
+      {
+        $sort: {
+          channelId: 1,
+          adType: 1,
+          deviceType: 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<AdTypeClickRecord>>,
+      (this.prisma.adClickEvents as any).aggregateRaw({
+        pipeline: countPipeline,
+      }) as Promise<Array<{ total: number }>>,
+    ]);
+
+    const total =
+      Array.isArray(countRows) && countRows.length > 0 ? countRows[0].total : 0;
+
+    const normalizedItems = (items ?? []).map((item) => ({
+      channelId: item.channelId ?? '',
+      deviceType: item.deviceType ?? null,
+      adType: item.adType ?? null,
+      clicks:
+        typeof item.clicks === 'number' ? item.clicks : Number(item.clicks),
+    }));
+
+    return {
+      page,
+      size,
+      total,
+      items: toJsonSafe(normalizedItems),
+    };
+  }
+
   private normalizePagination({
     page,
     size,
@@ -205,15 +316,10 @@ export class StatsReportingService {
   }
 
   private normalizeSize(value?: number) {
-    if (!Number.isFinite(value ?? NaN)) {
-      return DEFAULT_SIZE;
-    }
-
-    const normalized = Math.floor(value);
-    if (normalized < 1) {
-      return DEFAULT_SIZE;
-    }
+    const requestedSize = Number.isFinite(value ?? NaN)
+      ? Math.floor(value as number)
+      : DEFAULT_SIZE;
 
-    return Math.min(MAX_SIZE, normalized);
+    return Math.max(1, Math.min(MAX_SIZE, requestedSize));
   }
 }