Explorar o código

feat(ads): implement all ads retrieval and enhance DTOs for ad management

Dave hai 3 meses
pai
achega
a635d4adf4

+ 9 - 10
apps/box-app-api/src/feature/ads/ad.controller.ts

@@ -27,6 +27,7 @@ import {
   AdListResponseDto,
   AdClickDto,
   AdImpressionDto,
+  AllAdsResponseDto,
 } from './dto';
 import { AdUrlResponseDto } from './dto/ad-url-response.dto';
 import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -58,25 +59,23 @@ export class AdController {
    */
   @Post('list')
   @ApiOperation({
-    summary: '分页列表获取广告',
+    summary: '获取所有广告类型及广告列表',
     description:
-      '按广告类型分页获取广告列表。支持指定页码和每页数量。数据来源:Mongo Ads 模型。',
+      '获取所有广告类型(从系统参数)以及按广告类型分组的广告列表。支持分页。数据来源:Mongo Ads 模型 + 系统参数。',
   })
   @ApiResponse({
     status: 200,
-    description: '成功返回分页广告列表',
-    type: AdListResponseDto,
+    description: '成功返回所有广告类型和分组广告列表',
+    type: AllAdsResponseDto,
   })
   async listAdsByType(
     @Body() req: AdListRequestDto,
-  ): Promise<AdListResponseDto> {
-    const { page, size, adType } = req;
+  ): Promise<AllAdsResponseDto> {
+    const { page, size } = req;
 
-    this.logger.debug(
-      `listAdsByType: page=${page}, size=${size}, adType=${adType}`,
-    );
+    this.logger.debug(`listAdsByType: page=${page}, size=${size}`);
 
-    const response = await this.adService.listAdsByType(adType, page, size);
+    const response = await this.adService.listAdsByType(page, size);
 
     return response;
   }

+ 4 - 2
apps/box-app-api/src/feature/ads/ad.module.ts

@@ -1,5 +1,5 @@
 // apps/box-app-api/src/feature/ads/ad.module.ts
-import { Module } from '@nestjs/common';
+import { Module, forwardRef } from '@nestjs/common';
 import { HttpModule } from '@nestjs/axios';
 import { RedisModule } from '@box/db/redis/redis.module';
 import { SharedModule } from '@box/db/shared.module';
@@ -7,14 +7,16 @@ import { AdService } from './ad.service';
 import { AdController } from './ad.controller';
 import { AuthModule } from '../auth/auth.module';
 import { RabbitmqModule } from '../../rabbitmq/rabbitmq.module';
+import { SysParamsModule } from '../sys-params/sys-params.module';
 
 @Module({
   imports: [
     HttpModule, // 👈 for notifying mgnt-api cache sync
     RedisModule, // 👈 make RedisService available here
     SharedModule, // 👈 make MongoPrismaService available here
-    AuthModule, // 👈 make JwtAuthGuard available here
+    forwardRef(() => AuthModule), // 👈 avoid circular dependency
     RabbitmqModule, // 👈 make RabbitmqPublisherService available here
+    SysParamsModule, // 👈 make SysParamsService available here
   ],
   providers: [AdService],
   controllers: [AdController],

+ 155 - 3
apps/box-app-api/src/feature/ads/ad.service.ts

@@ -6,11 +6,17 @@ import { RedisService } from '@box/db/redis/redis.service';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import { CacheKeys } from '@box/common/cache/cache-keys';
 import { AdDto } from './dto/ad.dto';
-import { AdListResponseDto, AdItemDto } from './dto';
+import {
+  AdListResponseDto,
+  AdItemDto,
+  AllAdsResponseDto,
+  AdsByTypeDto,
+} from './dto';
 import { AdType } from '@box/common/ads/ad-types';
 import { AdUrlResponseDto } from './dto/ad-url-response.dto';
 import { AdClickDto } from './dto/ad-click.dto';
 import { AdImpressionDto } from './dto/ad-impression.dto';
+import { SysParamsService } from '../sys-params/sys-params.service';
 import {
   RabbitmqPublisherService,
   StatsAdClickEventPayload,
@@ -68,6 +74,7 @@ export class AdService {
     private readonly rabbitmqPublisher: RabbitmqPublisherService,
     private readonly configService: ConfigService,
     private readonly httpService: HttpService,
+    private readonly sysParamsService: SysParamsService,
   ) {
     // Get mgnt-api base URL for cache rebuild notifications
     this.mgntApiBaseUrl =
@@ -216,9 +223,150 @@ export class AdService {
   }
 
   /**
-   * Get paginated list of ads by type from Redis pool.
+   * Get all ads grouped by ad type.
+   * Returns a list of all ad types (from SysParamsService.getAdTypes)
+   * and ads grouped by each ad type.
+   *
+   * Flow:
+   * 1. Fetch all ad types from SysParamsService
+   * 2. For each ad type, fetch ads from Redis pool with pagination
+   * 3. Return adTypes list and adsList grouped by type
+   */
+  async listAdsByType(page: number, size: number): Promise<AllAdsResponseDto> {
+    // Step 1: Get all ad types
+    const adTypes = await this.sysParamsService.getAdTypes();
+
+    // Step 2: For each ad type, fetch ads
+    const adsList: AdsByTypeDto[] = [];
+
+    for (const adTypeInfo of adTypes) {
+      const poolKey = CacheKeys.appAdPoolByType(adTypeInfo.adType);
+
+      // Get the entire pool from Redis
+      let poolEntries: AdPoolEntry[] = [];
+      try {
+        const jsonData = await this.redis.getJson<AdPoolEntry[]>(poolKey);
+        if (jsonData && Array.isArray(jsonData)) {
+          poolEntries = jsonData;
+        } else {
+          this.logger.warn(
+            `Ad pool cache miss or invalid for adType=${adTypeInfo.adType}, key=${poolKey}`,
+          );
+        }
+      } catch (err) {
+        if (err instanceof Error && err.message?.includes('WRONGTYPE')) {
+          this.logger.warn(
+            `Ad pool key ${poolKey} has wrong type, deleting incompatible key`,
+          );
+          try {
+            await this.redis.del(poolKey);
+            this.logger.log(
+              `Deleted incompatible ad pool key ${poolKey}. It will be rebuilt on next cache warmup.`,
+            );
+          } catch (delErr) {
+            this.logger.error(
+              `Failed to delete incompatible key ${poolKey}`,
+              delErr instanceof Error ? delErr.stack : String(delErr),
+            );
+          }
+        } else {
+          this.logger.error(
+            `Failed to read ad pool for adType=${adTypeInfo.adType}, key=${poolKey}`,
+            err instanceof Error ? err.stack : String(err),
+          );
+        }
+      }
+
+      if (!Array.isArray(poolEntries) || poolEntries.length === 0) {
+        // No ads for this type, add empty entry
+        adsList.push({
+          adType: adTypeInfo.adType,
+          items: [],
+          total: 0,
+        });
+        continue;
+      }
+
+      const total = poolEntries.length;
+
+      // Apply pagination
+      const start = (page - 1) * size;
+      const stop = start + size - 1;
+
+      const items: AdItemDto[] = [];
+
+      if (start < total) {
+        // Slice the pool entries for this page
+        const pagedEntries = poolEntries.slice(start, stop + 1);
+        const adIds = pagedEntries.map((entry) => entry.id);
+
+        // Query MongoDB for full ad details
+        try {
+          const now = BigInt(Date.now());
+          const ads = await this.mongoPrisma.ads.findMany({
+            where: {
+              id: { in: adIds },
+              status: 1,
+              startDt: { lte: now },
+              OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
+            },
+          });
+
+          // Create a map of ads by ID for fast lookup
+          const adMap = new Map(ads.map((ad) => [ad.id, ad]));
+
+          // Reorder results to match the pool order and map to AdItemDto
+          for (const entry of pagedEntries) {
+            const ad = adMap.get(entry.id);
+            if (!ad) {
+              this.logger.debug(
+                `Ad not found in MongoDB for adId=${entry.id} from pool`,
+              );
+              continue;
+            }
+
+            items.push({
+              id: ad.id,
+              advertiser: ad.advertiser ?? '',
+              title: ad.title ?? '',
+              adsContent: ad.adsContent ?? null,
+              adsCoverImg: ad.adsCoverImg ?? null,
+              adsUrl: ad.adsUrl ?? null,
+              startDt: ad.startDt.toString(),
+              expiryDt: ad.expiryDt.toString(),
+              seq: ad.seq ?? 0,
+            });
+          }
+        } catch (err) {
+          this.logger.error(
+            `Failed to query ads from MongoDB for adIds=${adIds.join(',')}`,
+            err instanceof Error ? err.stack : String(err),
+          );
+        }
+      }
+
+      adsList.push({
+        adType: adTypeInfo.adType,
+        items,
+        total,
+      });
+    }
+
+    return {
+      adTypes,
+      adsList,
+      page,
+      size,
+    };
+  }
+
+  /**
+   * Get paginated list of ads by specific type from Redis pool.
    * Reads the prebuilt ad pool from Redis, applies pagination, and fetches full ad details.
    *
+   * @deprecated Use listAdsByType() instead for getting all ads grouped by type.
+   * This method is kept for backward compatibility.
+   *
    * Flow:
    * 1. Get total count from pool
    * 2. Compute start/stop indices for LRANGE
@@ -227,7 +375,7 @@ export class AdService {
    * 5. Reorder results to match Redis pool order
    * 6. Map to AdItemDto and return response
    */
-  async listAdsByType(
+  async listAdsBySpecificType(
     adType: string,
     page: number,
     size: number,
@@ -584,6 +732,8 @@ export class AdService {
       adType: body.adType,
       clickedAt,
       ip,
+      channelId: body.channelId,
+      machine: body.machine,
     };
 
     // Fire-and-forget: don't await, log errors asynchronously
@@ -627,6 +777,8 @@ export class AdService {
       impressionAt,
       visibleDurationMs: body.visibleDurationMs,
       ip,
+      channelId: body.channelId,
+      machine: body.machine,
     };
 
     // Fire-and-forget: don't await, log errors asynchronously

+ 19 - 1
apps/box-app-api/src/feature/ads/dto/ad-click.dto.ts

@@ -1,5 +1,5 @@
 import { ApiProperty } from '@nestjs/swagger';
-import { IsNotEmpty, IsString } from 'class-validator';
+import { IsNotEmpty, IsString, IsOptional } from 'class-validator';
 
 export class AdClickDto {
   @ApiProperty({ description: '广告 ID', example: '652e7bcf4f1a2b4f98ad1234' })
@@ -11,4 +11,22 @@ export class AdClickDto {
   @IsNotEmpty()
   @IsString()
   adType: string;
+
+  @ApiProperty({
+    description: '渠道 ID',
+    example: '652e7bcf4f1a2b4f98ch5678',
+    required: false,
+  })
+  @IsOptional()
+  @IsString()
+  channelId?: string;
+
+  @ApiProperty({
+    description: '设备信息(品牌、系统版本等)',
+    example: 'iPhone 14 Pro, iOS 17.0',
+    required: false,
+  })
+  @IsOptional()
+  @IsString()
+  machine?: string;
 }

+ 18 - 0
apps/box-app-api/src/feature/ads/dto/ad-impression.dto.ts

@@ -23,4 +23,22 @@ export class AdImpressionDto {
   @IsInt()
   @Min(0)
   visibleDurationMs?: number;
+
+  @ApiProperty({
+    description: '渠道 ID',
+    example: '652e7bcf4f1a2b4f98ch5678',
+    required: false,
+  })
+  @IsOptional()
+  @IsString()
+  channelId?: string;
+
+  @ApiProperty({
+    description: '设备信息(品牌、系统版本等)',
+    example: 'iPhone 14 Pro, iOS 17.0',
+    required: false,
+  })
+  @IsOptional()
+  @IsString()
+  machine?: string;
 }

+ 16 - 0
apps/box-app-api/src/feature/ads/dto/ad-type.dto.ts

@@ -0,0 +1,16 @@
+// apps/box-app-api/src/feature/ads/dto/ad-type.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+
+export class AdTypeDto {
+  @ApiProperty({ description: '广告类型标识' })
+  adType: string;
+
+  @ApiProperty({ description: '广告模块名称' })
+  name: string;
+
+  @ApiProperty({ description: '模块描述', nullable: true })
+  desc: string | null;
+
+  @ApiProperty({ description: '排序序号' })
+  seq: number;
+}

+ 18 - 0
apps/box-app-api/src/feature/ads/dto/ads-by-type.dto.ts

@@ -0,0 +1,18 @@
+// apps/box-app-api/src/feature/ads/dto/ads-by-type.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+import { AdItemDto } from './ad-item.dto';
+
+export class AdsByTypeDto {
+  @ApiProperty({ description: '广告类型标识' })
+  adType: string;
+
+  @ApiProperty({
+    description: '该类型的广告列表',
+    type: AdItemDto,
+    isArray: true,
+  })
+  items: AdItemDto[];
+
+  @ApiProperty({ description: '该类型的广告总数' })
+  total: number;
+}

+ 26 - 0
apps/box-app-api/src/feature/ads/dto/all-ads-response.dto.ts

@@ -0,0 +1,26 @@
+// apps/box-app-api/src/feature/ads/dto/all-ads-response.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+import { AdTypeDto } from './ad-type.dto';
+import { AdsByTypeDto } from './ads-by-type.dto';
+
+export class AllAdsResponseDto {
+  @ApiProperty({
+    description: '所有广告类型列表(按seq排序)',
+    type: AdTypeDto,
+    isArray: true,
+  })
+  adTypes: AdTypeDto[];
+
+  @ApiProperty({
+    description: '按广告类型分组的广告列表',
+    type: AdsByTypeDto,
+    isArray: true,
+  })
+  adsList: AdsByTypeDto[];
+
+  @ApiProperty({ description: '当前页码' })
+  page: number;
+
+  @ApiProperty({ description: '每页数量' })
+  size: number;
+}

+ 3 - 0
apps/box-app-api/src/feature/ads/dto/index.ts

@@ -7,3 +7,6 @@ export { AdListResponseDto } from './ad-list-response.dto';
 export { AdUrlResponseDto } from './ad-url-response.dto';
 export { AdClickDto } from './ad-click.dto';
 export { AdImpressionDto } from './ad-impression.dto';
+export { AdTypeDto } from './ad-type.dto';
+export { AdsByTypeDto } from './ads-by-type.dto';
+export { AllAdsResponseDto } from './all-ads-response.dto';

+ 2 - 0
apps/box-app-api/src/feature/auth/auth.controller.ts

@@ -42,6 +42,8 @@ export class AuthController {
       userAgent,
       appVersion: body.appVersion,
       os: body.os,
+      channelId: body.channelId,
+      machine: body.machine,
       // add any other auth-related fields as needed
     });
 

+ 3 - 1
apps/box-app-api/src/feature/auth/auth.module.ts

@@ -1,4 +1,4 @@
-import { Module } from '@nestjs/common';
+import { Module, forwardRef } from '@nestjs/common';
 import { JwtModule } from '@nestjs/jwt';
 import { PassportModule } from '@nestjs/passport';
 import { ConfigModule, ConfigService } from '@nestjs/config';
@@ -9,12 +9,14 @@ import { AuthService } from './auth.service';
 import { CoreModule } from '@box/core/core.module';
 import { JwtStrategy } from './strategies/jwt.strategy';
 import { JwtAuthGuard } from './guards/jwt-auth.guard';
+import { AdModule } from '../ads/ad.module';
 
 @Module({
   imports: [
     PrismaMongoModule,
     RabbitmqModule,
     CoreModule,
+    forwardRef(() => AdModule),
     PassportModule.register({ defaultStrategy: 'jwt' }),
     JwtModule.registerAsync({
       imports: [ConfigModule],

+ 17 - 9
apps/box-app-api/src/feature/auth/auth.service.ts

@@ -6,6 +6,7 @@ import { RabbitmqPublisherService } from '../../rabbitmq/rabbitmq-publisher.serv
 import { AdPoolService, AdPayload } from '@box/core/ad/ad-pool.service';
 import { AdType } from '@prisma/mongo/client';
 import { LoginResponseDto } from './dto/login-response.dto';
+import { AdService } from '../ads/ad.service';
 
 @Injectable()
 export class AuthService {
@@ -14,6 +15,7 @@ export class AuthService {
     private readonly jwtService: JwtService,
     private readonly rabbitmqPublisher: RabbitmqPublisherService,
     private readonly adPoolService: AdPoolService,
+    private readonly adService: AdService,
   ) {}
 
   async login(params: {
@@ -22,9 +24,11 @@ export class AuthService {
     userAgent?: string;
     appVersion?: string;
     os?: string;
+    channelId?: string;
+    machine?: string;
     // plus whatever you need like account, password, etc.
-  }): Promise<LoginResponseDto> {
-    const { uid, ip, userAgent, appVersion, os } = params;
+  }): Promise<any> {
+    const { uid, ip, userAgent, appVersion, os, channelId, machine } = params;
 
     // 1) Your existing auth logic (validate user, etc.)
     // const user = await this.validateUser(...);
@@ -50,6 +54,8 @@ export class AuthService {
       userAgent,
       appVersion,
       os,
+      channelId,
+      machine,
       tokenId,
       loginAt: now,
     };
@@ -73,12 +79,12 @@ export class AuthService {
       );
     }
 
-    let startupAd: AdPayload | null = null;
-    try {
-      startupAd = await this.adPoolService.getRandomAdByType(AdType.STARTUP);
-    } catch (err) {
-      this.logger.error('Failed to get startup ad for login', err);
-    }
+    // let startupAd: AdPayload | null = null;
+    // try {
+    //   startupAd = await this.adPoolService.getRandomAdByType(AdType.STARTUP);
+    // } catch (err) {
+    //   this.logger.error('Failed to get startup ad for login', err);
+    // }
     // let startupAdWithoutUrl: Omit<AdPayload, 'adsUrl'> | null = null;
 
     // if (startupAd) {
@@ -86,6 +92,8 @@ export class AuthService {
     //   startupAdWithoutUrl = rest;
     // }
 
-    return { accessToken, startupAd };
+    const allAds = await this.adService.listAdsByType(1, 100000);
+
+    return { accessToken, allAds };
   }
 }

+ 18 - 0
apps/box-app-api/src/feature/auth/login.dto.ts

@@ -11,6 +11,24 @@ export class LoginDto {
   uid: string;
 
   @ApiProperty({
+    description: '渠道ID',
+    example: 'channel-123',
+    required: false,
+  })
+  @IsOptional()
+  @IsString()
+  channelId?: string;
+
+  @ApiProperty({
+    description: '机器型号',
+    example: 'iPhone 12 Pro xxxx',
+    required: false,
+  })
+  @IsOptional()
+  @IsString()
+  machine?: string;
+
+  @ApiProperty({
     description: '应用版本号',
     example: '1.0.0',
     required: false,

+ 4 - 0
apps/box-app-api/src/rabbitmq/rabbitmq-publisher.service.ts

@@ -24,6 +24,8 @@ export interface StatsAdClickEventPayload {
   adType: string;
   clickedAt: bigint;
   ip: string;
+  channelId?: string;
+  machine?: string;
 }
 
 export interface StatsVideoClickEventPayload {
@@ -42,6 +44,8 @@ export interface StatsAdImpressionEventPayload {
   impressionAt: bigint;
   visibleDurationMs?: number;
   ip: string;
+  channelId?: string;
+  machine?: string;
 }
 
 // Circuit breaker states

+ 9 - 8
apps/box-stats-api/src/feature/stats-events/stats-events.consumer.ts

@@ -31,6 +31,7 @@ interface AdClickMessage extends BaseStatsMessage {
   adType: string;
   clickedAt?: string | number | bigint; // Alternative field name
   clickAt?: string | number | bigint; // Publisher sends this
+  machine?: string; // Device info: brand and system version
 }
 
 interface VideoClickMessage extends BaseStatsMessage {
@@ -50,6 +51,7 @@ interface AdImpressionMessage extends BaseStatsMessage {
   adType: string;
   impressionAt: string | number | bigint;
   visibleDurationMs?: number;
+  machine?: string; // Device info: brand and system version
 }
 
 @Injectable()
@@ -269,6 +271,8 @@ export class StatsEventsConsumer implements OnModuleInit, OnModuleDestroy {
           adType: payload.adType,
           clickedAt: this.toBigInt(clickTime),
           ip: payload.ip,
+          channelId: payload.channelId ?? null,
+          machine: payload.machine ?? null,
           createAt: this.toBigInt(payload.createAt || now),
           updateAt: this.toBigInt(payload.updateAt || now),
         },
@@ -369,17 +373,14 @@ export class StatsEventsConsumer implements OnModuleInit, OnModuleDestroy {
         data: {
           uid: payload.uid,
           adId: payload.adId,
-          adsModuleId: payload.adsModuleId,
-          channelId: payload.channelId,
-          scene: payload.scene,
-          slot: payload.slot,
           adType: payload.adType,
           impressionAt: this.toBigInt(payload.impressionAt),
-          visibleDurationMs: payload.visibleDurationMs ?? null,
+          visibleDurationMs: payload.visibleDurationMs
+            ? BigInt(payload.visibleDurationMs)
+            : null,
           ip: payload.ip,
-          userAgent: payload.userAgent,
-          appVersion: payload.appVersion ?? null,
-          os: payload.os ?? null,
+          channelId: payload.channelId ?? null,
+          machine: payload.machine ?? null,
           createAt: this.toBigInt(payload.createAt),
           updateAt: this.toBigInt(payload.updateAt),
         },

+ 6 - 0
apps/box-stats-api/src/feature/user-login/user-login.service.ts

@@ -22,10 +22,14 @@ export class UserLoginService {
               : BigInt(event.loginAt),
           ip: event.ip,
           os: event.os ?? null,
+          channelId: event.channelId ?? null,
+          machine: event.machine ?? null,
         },
         create: {
           uid: event.uid,
           os: event.os ?? null,
+          channelId: event.channelId ?? null,
+          machine: event.machine ?? null,
           createAt:
             typeof event.loginAt === 'bigint'
               ? event.loginAt
@@ -63,6 +67,8 @@ export class UserLoginService {
           userAgent: event.userAgent ?? null,
           appVersion: event.appVersion ?? null,
           os: event.os ?? null,
+          channelId: event.channelId ?? null,
+          machine: event.machine ?? null,
           createAt,
           tokenId: event.tokenId ?? null,
         },

+ 1 - 0
libs/common/src/events/ads-click-event.dto.ts

@@ -9,5 +9,6 @@ export interface AdsClickEventPayload {
   ip: string; // Client IP
   appVersion?: string; // App version
   os?: string; // iOS / Android / Web
+  machine?: string; // Device info: brand and system version
   clickAt: number | bigint; // epoch millis; will be stored as BigInt in Mongo
 }

+ 2 - 0
libs/common/src/events/user-login-event.dto.ts

@@ -6,6 +6,8 @@ export interface UserLoginEventPayload {
   userAgent?: string; // optional, but useful
   appVersion?: string; // optional
   os?: string; // iOS / Android / Browser / Web
+  channelId?: string; // optional channel ID
+  machine?: string; // optional machine model
 
   tokenId?: string; // JWT jti or session token ID (optional for now)
 

+ 2 - 1
prisma/mongo-stats/schema/ads-click-history.prisma

@@ -3,12 +3,13 @@ model AdsClickHistory {
   
   adsId        String   @db.ObjectId                    // 广告 ID
   adType       String                                   // 广告类型 (BANNER, STARTUP, etc.)
-  channelId    String   @db.ObjectId                    // 渠道 ID
   adsModuleId  String   @db.ObjectId                    // 广告模块 ID
   uid          String                                   // 用户设备码 (from JWT)
   ip           String                                   // 点击 IP
   appVersion   String?                                  // 客户端版本 (optional)
   os           String?                                  // iOS / Android / Web (optional)
+  channelId    String?                                  // 渠道 Id
+  machine      String?                                  // 客户端提供 : 设备的信息,品牌及系统版本什么的
 
   clickAt      BigInt                                   // 点击时间 (epoch millis)
 

+ 6 - 0
prisma/mongo-stats/schema/events.prisma

@@ -7,6 +7,8 @@ model AdClickEvents {
 
   clickedAt    BigInt                          // 点击时间 (epoch)
   ip           String                          // 点击 IP
+  channelId     String?                     // 渠道 Id
+  machine       String?                     // 客户端提供 : 设备的信息,品牌及系统版本什么的
 
   createAt     BigInt                          // 记录创建时间
   updateAt     BigInt                          // 记录更新时间
@@ -32,6 +34,8 @@ model VideoClickEvents {
 
   clickedAt   BigInt                          // 点击时间 (epoch)
   ip          String                          // 点击 IP
+  channelId   String?                         // 渠道 Id
+  machine     String?                         // 客户端提供 : 设备的信息,品牌及系统版本什么的
 
   createAt    BigInt                          // 记录创建时间
   updateAt    BigInt                          // 记录更新时间
@@ -57,6 +61,8 @@ model AdImpressionEvents {
   impressionAt      BigInt                          // 曝光时间 (epoch)
   visibleDurationMs BigInt?                         // 可见时长(毫秒,optional)
   ip                String                          // IP
+  channelId         String?                         // 渠道 Id
+  machine           String?                         // 客户端提供 : 设备的信息,品牌及系统版本什么的
 
   createAt          BigInt                          // 记录创建时间
   updateAt          BigInt                          // 记录更新时间

+ 2 - 0
prisma/mongo-stats/schema/user-login-history.prisma

@@ -6,6 +6,8 @@ model UserLoginHistory {
   userAgent   String?                         // UA (optional but useful)
   appVersion  String?                         // 客户端版本 (optional)
   os          String?                         // iOS / Android / Browser
+  channelId     String?                     // 渠道 Id
+  machine       String?                     // 客户端提供 : 设备的信息,品牌及系统版本什么的
 
   createAt    BigInt                          // 登录时间 (epoch)
 

+ 7 - 5
prisma/mongo-stats/schema/user.prisma

@@ -1,11 +1,13 @@
 model User {
-  id            String     @id @map("_id") @default(auto()) @db.ObjectId
-  uid           String     @unique          // 唯一设备码
+  id            String      @id @map("_id") @default(auto()) @db.ObjectId
+  uid           String      @unique         // 唯一设备码
   ip            String                      // 最近登录 IP
-  os          String?                         // iOS / Android / Browser
+  os            String?                     // iOS / Android / Browser
+  channelId     String?                     // 渠道 Id
+  machine       String?                     // 客户端提供 : 设备的信息,品牌及系统版本什么的
 
-  createAt      BigInt     @default(0)      // 注册/创建时间
-  lastLoginAt   BigInt     @default(0)      // 最后登录时间
+  createAt      BigInt      @default(0)     // 注册/创建时间
+  lastLoginAt   BigInt      @default(0)     // 最后登录时间
 
   // create index on uid field for search
   @@index([createAt])

+ 1 - 1
prisma/mongo/schema/ads.prisma

@@ -1,6 +1,6 @@
 model Ads {
   id           String     @id @map("_id") @default(auto()) @db.ObjectId
-  channelId    String     @db.ObjectId       // 渠道 ID
+  channelId    String     @db.ObjectId       // 渠道 Id
   adsModuleId  String     @db.ObjectId       // 广告模块 Id (banner/startup/轮播等)
   advertiser   String                        // 广告商 (业务上限制 max 20 字符)
   title        String                        // 标题 (业务上限制 max 20 字符)