ソースを参照

feat: add ad module with controller and service for ad placement functionality

Dave 2 ヶ月 前
コミット
cf2733e6ee

+ 18 - 2
apps/box-app-api/src/app.module.ts

@@ -1,10 +1,12 @@
+// apps/box-app-api/src/app.module.ts
 import { Module } from '@nestjs/common';
-import { ConfigModule } from '@nestjs/config';
-
+import { ConfigModule, ConfigService } from '@nestjs/config';
 import { RedisCacheModule } from './redis/redis-cache.module';
 import { HealthModule } from './health/health.module';
 import { PrismaMongoModule } from './prisma/prisma-mongo.module';
 import { VideoModule } from './feature/video/video.module';
+import { AdModule } from './feature/ads/ad.module';
+import { RedisModule } from '@box/db/redis/redis.module';
 
 @Module({
   imports: [
@@ -15,6 +17,19 @@ import { VideoModule } from './feature/video/video.module';
       expandVariables: true,
     }),
 
+    // Global Redis module for RedisService
+    RedisModule.forRootAsync({
+      imports: [ConfigModule],
+      inject: [ConfigService],
+      useFactory: (configService: ConfigService) => ({
+        host: configService.get<string>('REDIS_HOST') ?? '127.0.0.1',
+        port: configService.get<number>('REDIS_PORT') ?? 6379,
+        password: configService.get<string>('REDIS_PASSWORD'),
+        db: configService.get<number>('REDIS_DB') ?? 0,
+        keyPrefix: configService.get<string>('REDIS_KEY_PREFIX'),
+      }),
+    }),
+
     // Global Redis cache
     RedisCacheModule,
 
@@ -24,6 +39,7 @@ import { VideoModule } from './feature/video/video.module';
     // Simple health endpoint
     HealthModule,
     VideoModule,
+    AdModule,
   ],
 })
 export class AppModule {}

+ 49 - 0
apps/box-app-api/src/feature/ads/ad.controller.ts

@@ -0,0 +1,49 @@
+// apps/box-app-api/src/feature/ads/ad.controller.ts
+import { Controller, Get, Logger, Query } from '@nestjs/common';
+import { AdService } from './ad.service';
+import { GetAdPlacementQueryDto } from './dto/get-ad-placement.dto';
+import { AdDto } from './dto/ad.dto';
+
+@Controller('ads')
+export class AdController {
+  private readonly logger = new Logger(AdController.name);
+
+  constructor(private readonly adService: AdService) {}
+
+  /**
+   * GET /ads/placement
+   *
+   * Example:
+   *   /ads/placement?scene=home&slot=top&adType=BANNER
+   *
+   * Returns a single AdDto or null (if no suitable ad is found).
+   * Your global response interceptor will wrap this into the standard envelope.
+   */
+  @Get('placement')
+  async getAdForPlacement(
+    @Query() query: GetAdPlacementQueryDto,
+  ): Promise<AdDto | null> {
+    const { scene, slot, adType, maxTries } = query;
+
+    const maxTriesNum =
+      maxTries != null ? Number.parseInt(maxTries, 10) || 3 : 3;
+
+    const ad = await this.adService.getAdForPlacement({
+      scene,
+      slot,
+      adType,
+      maxTries: maxTriesNum,
+    });
+
+    // Optional logging for debugging / analytics:
+    if (!ad) {
+      this.logger.debug(
+        `No ad returned for scene=${scene}, slot=${slot}, adType=${adType}`,
+      );
+    }
+
+    // Let your global response interceptor wrap this into:
+    // { status, code, data, error, timestamp, ... }
+    return ad;
+  }
+}

+ 15 - 0
apps/box-app-api/src/feature/ads/ad.module.ts

@@ -0,0 +1,15 @@
+// apps/box-app-api/src/feature/ads/ad.module.ts
+import { Module } from '@nestjs/common';
+import { RedisModule } from '@box/db/redis/redis.module';
+import { AdService } from './ad.service';
+import { AdController } from './ad.controller';
+
+@Module({
+  imports: [
+    RedisModule, // 👈 make RedisService available here
+  ],
+  providers: [AdService],
+  controllers: [AdController],
+  exports: [AdService],
+})
+export class AdModule {}

+ 145 - 0
apps/box-app-api/src/feature/ads/ad.service.ts

@@ -0,0 +1,145 @@
+// apps/box-app-api/src/feature/ads/ad.service.ts
+import { Injectable, Logger } from '@nestjs/common';
+import { RedisService } from '@box/db/redis/redis.service';
+import { CacheKeys } from '@box/common/cache/cache-keys';
+import { AdDto } from './dto/ad.dto';
+
+interface AdPoolEntry {
+  id: string;
+  weight: number;
+}
+
+// This should match what mgnt-side rebuildSingleAdCache stores.
+// We only care about a subset for now.
+interface CachedAd {
+  id: string;
+  channelId?: string;
+  adsModuleId?: string;
+  advertiser?: string;
+  title?: string;
+  adsContent?: string | null;
+  adsCoverImg?: string | null;
+  adsUrl?: string | null;
+  adType?: string | null;
+  // startDt?: bigint;
+  // expiryDt?: bigint;
+  // seq?: number;
+  // status?: number;
+  // createAt?: bigint;
+  // updateAt?: bigint;
+}
+
+export interface GetAdForPlacementParams {
+  scene: string; // e.g. 'home' | 'detail' | 'player' | 'global'
+  slot: string; // e.g. 'top' | 'carousel' | 'popup' | 'preroll' | ...
+  adType: string; // e.g. 'BANNER' | 'CAROUSEL' | 'POPUP_IMAGE' | ...
+  maxTries?: number; // optional, default 3
+}
+
+@Injectable()
+export class AdService {
+  private readonly logger = new Logger(AdService.name);
+
+  constructor(private readonly redis: RedisService) {}
+
+  /**
+   * Core method for app-api:
+   * Given a (scene, slot, adType), try to pick one ad from the prebuilt pool
+   * and return its details as AdDto. Returns null if no suitable ad is found.
+   */
+  async getAdForPlacement(
+    params: GetAdForPlacementParams,
+  ): Promise<AdDto | null> {
+    const { scene, slot, adType } = params;
+    const maxTries = params.maxTries ?? 3;
+
+    const poolKey = CacheKeys.appAdPool(scene, slot, adType);
+
+    const pool =
+      (await this.redis.getJson<AdPoolEntry[] | null>(poolKey)) ?? null;
+
+    if (!pool || pool.length === 0) {
+      this.logger.debug(
+        `getAdForPlacement: empty or missing pool for scene=${scene}, slot=${slot}, adType=${adType}, key=${poolKey}`,
+      );
+      return null;
+    }
+
+    // Limit attempts so we don't loop too much if some ad entries are stale.
+    const attempts = Math.min(maxTries, pool.length);
+
+    // We'll try up to `attempts` random entries from the pool.
+    const usedIndexes = new Set<number>();
+
+    for (let i = 0; i < attempts; i++) {
+      const idx = this.pickRandomIndex(pool.length, usedIndexes);
+      if (idx === -1) break;
+
+      usedIndexes.add(idx);
+
+      const entry = pool[idx];
+      const adKey = CacheKeys.appAdById(entry.id);
+
+      const cachedAd =
+        (await this.redis.getJson<CachedAd | null>(adKey)) ?? null;
+
+      if (!cachedAd) {
+        this.logger.debug(
+          `getAdForPlacement: missing per-ad cache for adId=${entry.id}, key=${adKey}, poolKey=${poolKey}`,
+        );
+        continue;
+      }
+
+      const dto = this.mapCachedAdToDto(cachedAd, adType);
+      return dto;
+    }
+
+    // All attempts failed to find a valid cached ad
+    this.logger.debug(
+      `getAdForPlacement: no usable ad found after ${attempts} attempt(s) for scene=${scene}, slot=${slot}, adType=${adType}, poolKey=${poolKey}`,
+    );
+
+    return null;
+  }
+
+  /**
+   * Pick a random index in [0, length-1] that is not in usedIndexes.
+   * Returns -1 if all indexes are already used.
+   */
+  private pickRandomIndex(length: number, usedIndexes: Set<number>): number {
+    if (usedIndexes.size >= length) return -1;
+
+    // Simple approach: try a few times to find an unused index.
+    // Since length is small in most pools, this is fine.
+    for (let attempts = 0; attempts < 5; attempts++) {
+      const idx = Math.floor(Math.random() * length);
+      if (!usedIndexes.has(idx)) {
+        return idx;
+      }
+    }
+
+    // Fallback: linear scan for the first unused index
+    for (let i = 0; i < length; i++) {
+      if (!usedIndexes.has(i)) return i;
+    }
+
+    return -1;
+  }
+
+  /**
+   * Map cached ad (as stored by mgnt-api) to the AdDto exposed to frontend.
+   */
+  private mapCachedAdToDto(cachedAd: CachedAd, fallbackAdType: string): AdDto {
+    return {
+      id: cachedAd.id,
+      adType: cachedAd.adType ?? fallbackAdType,
+
+      title: cachedAd.title ?? '',
+      advertiser: cachedAd.advertiser ?? '',
+
+      content: cachedAd.adsContent ?? undefined,
+      coverImg: cachedAd.adsCoverImg ?? undefined,
+      targetUrl: cachedAd.adsUrl ?? undefined,
+    };
+  }
+}

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

@@ -0,0 +1,13 @@
+// apps/box-app-api/src/feature/ads/dto/ad.dto.ts
+
+export interface AdDto {
+  id: string;
+  adType: string;
+
+  title: string;
+  advertiser: string;
+
+  content?: string;
+  coverImg?: string;
+  targetUrl?: string;
+}

+ 17 - 0
apps/box-app-api/src/feature/ads/dto/get-ad-placement.dto.ts

@@ -0,0 +1,17 @@
+// apps/box-app-api/src/feature/ads/dto/get-ad-placement.dto.ts
+import { IsOptional, IsString } from 'class-validator';
+
+export class GetAdPlacementQueryDto {
+  @IsString()
+  scene!: string; // e.g. 'home' | 'detail' | 'player' | 'global'
+
+  @IsString()
+  slot!: string; // e.g. 'top' | 'carousel' | 'popup' | 'preroll' | ...
+
+  @IsString()
+  adType!: string; // e.g. 'BANNER' | 'CAROUSEL' | 'POPUP_IMAGE' | ...
+
+  @IsOptional()
+  @IsString()
+  maxTries?: string; // keep as string in query, we’ll parse to number
+}