瀏覽代碼

feat: enhance ad refresh functionality with new DTO and cache key management

Dave 4 月之前
父節點
當前提交
cdf42a3bc1

+ 15 - 4
apps/box-mgnt-api/src/cache-sync/cache-sync-debug.controller.ts

@@ -9,6 +9,16 @@ import {
   Query,
 } from '@nestjs/common';
 import { CacheSyncService } from './cache-sync.service';
+import { IsOptional, IsString } from 'class-validator';
+
+class ScheduleAdRefreshDto {
+  @IsString()
+  adId!: string;
+
+  @IsOptional()
+  @IsString()
+  adType?: string;
+}
 
 @Controller('mgnt-debug/cache-sync')
 export class CacheSyncDebugController {
@@ -44,14 +54,15 @@ export class CacheSyncDebugController {
    * Schedules AD REFRESH (+ optional AD_POOL REBUILD_POOL if adType provided).
    */
   @Post('ad/refresh')
-  async scheduleAdRefresh(
-    @Body('adId', ParseIntPipe) adId: number,
-    @Body('adType') adType?: string,
-  ) {
+  async scheduleAdRefresh(@Body() dto: ScheduleAdRefreshDto) {
+    const { adId, adType } = dto;
+
     await this.cacheSyncService.scheduleAdRefresh(adId, adType);
+
     this.logger.log(
       `Scheduled AD REFRESH for adId=${adId}, adType=${adType ?? 'N/A'}`,
     );
+
     return {
       ok: true,
       message: 'Scheduled AD REFRESH (and pool rebuild if adType provided)',

+ 234 - 26
apps/box-mgnt-api/src/cache-sync/cache-sync.service.ts

@@ -4,6 +4,7 @@ import { Prisma as MysqlPrisma, CacheSyncAction } from '@prisma/mysql/client';
 import { MysqlPrismaService } from '@box/db/prisma/mysql-prisma.service';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import { RedisService } from '@box/db/redis/redis.service';
+import { CacheKeys } from '@box/common/cache/cache-keys'; // 👈 new import
 
 import {
   CacheEntityType,
@@ -12,6 +13,55 @@ import {
   CachePayload,
 } from './cache-sync.types';
 
+interface AdPoolPlacement {
+  scene: string;
+  slot: string;
+}
+
+/**
+ * Mapping from adType (AdsModule.adType) → one or more pool placements.
+ * Each placement becomes a Redis key:
+ *   app:adpool:<scene>:<slot>:<adType>
+ *
+ * Adjust these mappings later if your design changes.
+ */
+const ADTYPE_POOLS: Record<string, AdPoolPlacement[]> = {
+  // 启动页广告
+  STARTUP: [{ scene: 'home', slot: 'startup' }],
+
+  // 首页轮播
+  CAROUSEL: [{ scene: 'home', slot: 'carousel' }],
+
+  // 弹窗类(详情页)
+  POPUP_ICON: [{ scene: 'detail', slot: 'popup' }],
+  POPUP_IMAGE: [{ scene: 'detail', slot: 'popup' }],
+  POPUP_OFFICIAL: [{ scene: 'detail', slot: 'popup' }],
+
+  // 瀑布流(首页)
+  WATERFALL_ICON: [{ scene: 'home', slot: 'waterfall' }],
+  WATERFALL_TEXT: [{ scene: 'home', slot: 'waterfall' }],
+  WATERFALL_VIDEO: [{ scene: 'home', slot: 'waterfall' }],
+
+  // 悬浮(全局)
+  FLOATING_BOTTOM: [{ scene: 'global', slot: 'floating_bottom' }],
+  FLOATING_EDGE: [{ scene: 'global', slot: 'floating_edge' }],
+
+  // 顶部 banner
+  BANNER: [{ scene: 'home', slot: 'top' }],
+
+  // 片头(前贴片)
+  PREROLL: [{ scene: 'player', slot: 'preroll' }],
+
+  // 暂停广告
+  PAUSE: [{ scene: 'player', slot: 'pause' }],
+};
+
+interface AdPoolEntry {
+  id: string;
+  // You can later change this to real weight if you add a weight field.
+  weight: number;
+}
+
 @Injectable()
 export class CacheSyncService {
   private readonly logger = new Logger(CacheSyncService.name);
@@ -82,21 +132,31 @@ export class CacheSyncService {
   }
 
   async scheduleAdRefresh(
-    adId: number | bigint,
+    adId: string | number | bigint,
     adType?: string,
   ): Promise<void> {
+    const adIdStr = String(adId);
+
+    // 1) Per-ad cache (AD)
     await this.scheduleAction({
       entityType: CacheEntityType.AD,
       operation: CacheOperation.REFRESH,
-      entityId: adId,
-      payload: adType ? { type: adType } : undefined,
+      entityId: null, // we don't rely on MySQL BigInt for Ads, so keep null
+      payload: {
+        type: adType,
+        adId: adIdStr,
+      },
     });
 
+    // 2) Pool rebuild (AD_POOL)
     if (adType) {
       await this.scheduleAction({
         entityType: CacheEntityType.AD_POOL,
         operation: CacheOperation.REBUILD_POOL,
-        payload: { type: adType },
+        entityId: null,
+        payload: {
+          type: adType,
+        },
       });
     }
   }
@@ -225,22 +285,18 @@ export class CacheSyncService {
   private async rebuildChannelsAll(): Promise<void> {
     const channels = await this.mongoPrisma.channel.findMany({
       where: {
-        // e.g. only active / not deleted; adjust if needed
         // isDeleted: false,
       },
       orderBy: {
-        // adjust to your schema
-        // sortOrder: 'asc',
-        // createdAt: 'asc',
         id: 'asc',
       },
     });
 
-    // NOTE:
-    // Actual Redis key will be "box:channels:all" if REDIS_KEY_PREFIX="box:".
-    await this.redis.setJson('channels:all', channels);
+    await this.redis.setJson(CacheKeys.appChannelAll, channels);
 
-    this.logger.log(`Rebuilt channels:all with ${channels.length} item(s).`);
+    this.logger.log(
+      `Rebuilt ${CacheKeys.appChannelAll} with ${channels.length} item(s).`,
+    );
   }
 
   // ─────────────────────────────────────────────
@@ -262,21 +318,17 @@ export class CacheSyncService {
   private async rebuildCategoriesAll(): Promise<void> {
     const categories = await this.mongoPrisma.category.findMany({
       where: {
-        // e.g. only active / not deleted; adjust if needed
         // isDeleted: false,
       },
       orderBy: {
-        // adjust to your schema
-        // sortOrder: 'asc',
-        // createdAt: 'asc',
-        id: 'asc',
+        seq: 'asc',
       },
     });
 
-    await this.redis.setJson('categories:all', categories);
+    await this.redis.setJson(CacheKeys.appCategoryAll, categories);
 
     this.logger.log(
-      `Rebuilt categories:all with ${categories.length} item(s).`,
+      `Rebuilt ${CacheKeys.appCategoryAll} with ${categories.length} item(s).`,
     );
   }
 
@@ -285,18 +337,174 @@ export class CacheSyncService {
   // ─────────────────────────────────────────────
 
   private async handleAdAction(action: CacheSyncAction): Promise<void> {
-    // TODO: implement real ad-by-id refresh using this.mongoPrisma.ad & Redis
+    const payload = action.payload as (CachePayload & { adId?: string }) | null;
+
+    if (!payload?.adId) {
+      this.logger.warn(
+        `handleAdAction: missing adId in payload for action id=${action.id}`,
+      );
+      return;
+    }
+
+    const adId = payload.adId;
+    const adType = payload.type;
+
+    await this.rebuildSingleAdCache(adId, adType);
+
     this.logger.debug(
-      `handleAdAction placeholder for id=${action.entityId}, operation=${action.operation}`,
+      `handleAdAction: rebuilt per-ad cache for adId=${adId}, adType=${adType ?? 'N/A'}, action id=${action.id}`,
+    );
+  }
+
+  private async rebuildSingleAdCache(
+    adId: string,
+    adType?: string,
+  ): Promise<void> {
+    const now = this.nowBigInt();
+
+    // Fetch the ad by Mongo ObjectId
+    const ad = await this.mongoPrisma.ads.findUnique({
+      where: { id: adId },
+      include: {
+        adsModule: true, // if you want adType / placement info
+      },
+    });
+
+    const cacheKey = CacheKeys.appAdById(adId);
+
+    if (!ad) {
+      // Ad no longer exists → ensure cache is cleared
+      await this.redis.del(cacheKey);
+      this.logger.log(
+        `rebuildSingleAdCache: ad not found, removed cache key=${cacheKey}`,
+      );
+      return;
+    }
+
+    // Validate business rules:
+    // - status = 1 (enabled)
+    // - startDt <= now
+    // - expiryDt == 0 (no expiry) OR expiryDt >= now
+    const isActive =
+      ad.status === 1 &&
+      ad.startDt <= now &&
+      (ad.expiryDt === BigInt(0) || ad.expiryDt >= now);
+
+    if (!isActive) {
+      await this.redis.del(cacheKey);
+      this.logger.log(
+        `rebuildSingleAdCache: adId=${adId} is not active (status/time window), removed cache key=${cacheKey}`,
+      );
+      return;
+    }
+
+    // Decide what to store in per-ad cache.
+    // You can store the full ad document or a trimmed DTO.
+    // For now, let’s store the full ad + its module's adType.
+    const cachedAd = {
+      id: ad.id,
+      channelId: ad.channelId,
+      adsModuleId: ad.adsModuleId,
+      advertiser: ad.advertiser,
+      title: ad.title,
+      adsContent: ad.adsContent,
+      adsCoverImg: ad.adsCoverImg,
+      adsUrl: ad.adsUrl,
+      startDt: ad.startDt,
+      expiryDt: ad.expiryDt,
+      seq: ad.seq,
+      status: ad.status,
+      createAt: ad.createAt,
+      updateAt: ad.updateAt,
+      // include adType from AdsModule so app-api can know its type
+      adType: ad.adsModule?.adType ?? adType ?? null,
+    };
+
+    await this.redis.setJson(cacheKey, cachedAd);
+
+    this.logger.log(
+      `rebuildSingleAdCache: updated per-ad cache for adId=${adId}, key=${cacheKey}`,
     );
   }
 
   private async handleAdPoolAction(action: CacheSyncAction): Promise<void> {
-    // const payload = action.payload as CachePayload | null;
-    // const adType = payload?.type;
-    // TODO: implement real pool rebuild logic
-    this.logger.debug(
-      `handleAdPoolAction placeholder, operation=${action.operation}`,
+    const payload = action.payload as CachePayload | null;
+    const adType = payload?.type;
+
+    if (!adType) {
+      this.logger.warn(
+        `handleAdPoolAction: missing adType in payload for action id=${action.id}`,
+      );
+      return;
+    }
+
+    const placements = ADTYPE_POOLS[adType];
+
+    if (!placements || placements.length === 0) {
+      this.logger.warn(
+        `handleAdPoolAction: no placements mapping found for adType=${adType}, action id=${action.id}`,
+      );
+      return;
+    }
+
+    this.logger.log(
+      `handleAdPoolAction: rebuilding ${placements.length} pool(s) for adType=${adType}, action id=${action.id}`,
+    );
+
+    for (const placement of placements) {
+      await this.rebuildAdPoolForPlacement(
+        adType,
+        placement.scene,
+        placement.slot,
+      );
+    }
+  }
+
+  private async rebuildAdPoolForPlacement(
+    adType: string,
+    scene: string,
+    slot: string,
+  ): Promise<void> {
+    const now = this.nowBigInt();
+
+    // NOTE:
+    // - status: 1 means enabled
+    // - startDt <= now
+    // - expiryDt == 0 (no expiry) OR expiryDt >= now
+    //
+    // Adjust the expiry logic if your business rule is different.
+    const ads = await this.mongoPrisma.ads.findMany({
+      where: {
+        status: 1,
+        startDt: {
+          lte: now,
+        },
+        OR: [
+          { expiryDt: BigInt(0) }, // "no expiry" if you use 0 as sentinel
+          { expiryDt: { gte: now } },
+        ],
+        adsModule: {
+          adType, // join AdsModule on adType
+        },
+      },
+      orderBy: {
+        seq: 'asc', // IMPORTANT: you said Ads list must be ordered by seq
+      },
+    });
+
+    const poolEntries: AdPoolEntry[] = ads.map((ad) => ({
+      id: ad.id,
+      // For now use a flat weight.
+      // Later you can map weight from seq or a dedicated weight field.
+      weight: 1,
+    }));
+
+    const key = CacheKeys.appAdPool(scene, slot, adType);
+
+    await this.redis.setJson(key, poolEntries);
+
+    this.logger.log(
+      `Rebuilt ad pool ${key} with ${poolEntries.length} ad(s) for adType=${adType}, scene=${scene}, slot=${slot}.`,
     );
   }
 

+ 3 - 0
apps/box-mgnt-api/src/cache-sync/cache-sync.types.ts

@@ -26,6 +26,9 @@ export interface CachePayload {
   // For ads pools, e.g. { type: 'BANNER' }
   type?: string;
 
+  // For per-ad cache, we’ll carry the Mongo Ad ID here
+  adId?: string;
+
   // For video lists, e.g. { scope: 'HOME', page: 1 }
   scope?: string;
   page?: number;

+ 47 - 0
libs/common/src/cache/cache-keys.ts

@@ -0,0 +1,47 @@
+// libs/common/src/cache/cache-keys.ts
+
+/**
+ * Centralized Redis logical keys (without REDIS_KEY_PREFIX).
+ * Actual keys in Redis will be: <REDIS_KEY_PREFIX><logicalKey>
+ * e.g. "box:" + "app:channel:all" => "box:app:channel:all"
+ */
+export const CacheKeys = {
+  // ─────────────────────────────────────────────
+  // CHANNELS
+  // ─────────────────────────────────────────────
+  appChannelAll: 'app:channel:all',
+  appChannelById: (channelId: string | number): string =>
+    `app:channel:by-id:${channelId}`,
+
+  // ─────────────────────────────────────────────
+  // CATEGORIES
+  // ─────────────────────────────────────────────
+  appCategoryAll: 'app:category:all',
+  appCategoryById: (categoryId: string | number): string =>
+    `app:category:by-id:${categoryId}`,
+
+  // ─────────────────────────────────────────────
+  // ADS
+  // ─────────────────────────────────────────────
+  appAdById: (adId: string | number): string => `app:ad:by-id:${adId}`,
+
+  // ─────────────────────────────────────────────
+  // AD POOLS
+  // scene: e.g. "home", "detail"
+  // slot: e.g. "top", "carousel", "popup"
+  // type: e.g. "BANNER", "CAROUSEL", "POPUP"
+  // ─────────────────────────────────────────────
+  appAdPool: (scene: string, slot: string, type: string): string =>
+    `app:adpool:${scene}:${slot}:${type}`,
+
+  // ─────────────────────────────────────────────
+  // VIDEO LISTS
+  // ─────────────────────────────────────────────
+  appHomeVideoPage: (page: number): string => `app:videolist:home:page:${page}`,
+
+  appChannelVideoPage: (channelId: string | number, page: number): string =>
+    `app:videolist:channel:${channelId}:page:${page}`,
+
+  appTrendingVideoPage: (countryCode: string, page: number): string =>
+    `app:videolist:trending:${countryCode}:page:${page}`,
+};

+ 13 - 13
mongo-db-seeds.md

@@ -3,19 +3,19 @@ adsModule
 ```bash
 db.adsModule.drop()
 db.adsModule.insertMany([
-{ adsModule: "启动页", moduleDesc: "启动页(10:21)", seq: 1 },
-{ adsModule: "轮播", moduleDesc: "轮播(2:1)", seq: 2 },
-{ adsModule: "弹窗-图标", moduleDesc: "弹窗-图标(1:1)", seq: 3 },
-{ adsModule: "弹窗-图片", moduleDesc: "弹窗-图片(2:3)", seq: 4 },
-{ adsModule: "弹窗-官方", moduleDesc: "弹窗-官方(2:3)", seq: 5 },
-{ adsModule: "瀑布流-图标", moduleDesc: "瀑布流-图标(1:1)", seq: 6 },
-{ adsModule: "瀑布流-文字", moduleDesc: "瀑布流-文字", seq: 7 },
-{ adsModule: "瀑布流-视频", moduleDesc: "瀑布流-视频(8:5)", seq: 8 },
-{ adsModule: "悬浮-底部", moduleDesc: "悬浮-底部(1:1)", seq: 9 },
-{ adsModule: "悬浮-边缘", moduleDesc: "悬浮-边缘(1:1)", seq:10 },
-{ adsModule: "banner", moduleDesc: "banner(4:1)", seq: 11 },
-{ adsModule: "片头", moduleDesc: "片头(8:5)", seq: 12 },
-{ adsModule: "暂停", moduleDesc: "暂停(2:1)", seq: 13 }
+  { adType: "STARTUP",         adsModule: "启动页",       moduleDesc: "启动页(10:21)",        seq: 1 },
+  { adType: "CAROUSEL",        adsModule: "轮播",         moduleDesc: "轮播(2:1)",            seq: 2 },
+  { adType: "POPUP_ICON",      adsModule: "弹窗-图标",     moduleDesc: "弹窗-图标(1:1)",        seq: 3 },
+  { adType: "POPUP_IMAGE",     adsModule: "弹窗-图片",     moduleDesc: "弹窗-图片(2:3)",        seq: 4 },
+  { adType: "POPUP_OFFICIAL",  adsModule: "弹窗-官方",     moduleDesc: "弹窗-官方(2:3)",        seq: 5 },
+  { adType: "WATERFALL_ICON",  adsModule: "瀑布流-图标",   moduleDesc: "瀑布流-图标(1:1)",      seq: 6 },
+  { adType: "WATERFALL_TEXT",  adsModule: "瀑布流-文字",   moduleDesc: "瀑布流-文字",           seq: 7 },
+  { adType: "WATERFALL_VIDEO", adsModule: "瀑布流-视频",   moduleDesc: "瀑布流-视频(8:5)",       seq: 8 },
+  { adType: "FLOATING_BOTTOM", adsModule: "悬浮-底部",     moduleDesc: "悬浮-底部(1:1)",        seq: 9 },
+  { adType: "FLOATING_EDGE",   adsModule: "悬浮-边缘",     moduleDesc: "悬浮-边缘(1:1)",        seq:10 },
+  { adType: "BANNER",          adsModule: "banner",        moduleDesc: "banner(4:1)",          seq: 11 },
+  { adType: "PREROLL",         adsModule: "片头",         moduleDesc: "片头(8:5)",             seq: 12 },
+  { adType: "PAUSE",           adsModule: "暂停",         moduleDesc: "暂停(2:1)",             seq: 13 }
 ])
 
 

+ 3 - 2
prisma/mongo/schema/ads-module.prisma

@@ -1,7 +1,8 @@
 model AdsModule {
   id          String    @id @default(auto()) @map("_id") @db.ObjectId
-  adsModule   String    /// 广告模块
-  moduleDesc  String?   /// 模块简介
+  adType      String    @unique
+  adsModule   String    @unique     /// 广告模块
+  moduleDesc  String?               /// 模块简介
   seq         Int       @default(0)
   ads         Ads[]