|
|
@@ -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,
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|