Browse Source

feat: Refactor ad pool management and enhance caching mechanisms

- Updated ad service to use ad pool keys based on ad type.
- Enhanced authentication controller to return random startup ads on login.
- Introduced new DTO for login response to include startup ad payload.
- Integrated ad pool service into auth service for fetching ads.
- Implemented ad pool warmup service for preloading ad data.
- Refactored cache keys to support ad pool by type.
- Created base cache builder and service for managing Redis and MongoDB interactions.
- Developed category, channel, and tag cache builders and services for structured caching.
- Added warmup services for categories, channels, and tags to initialize cache on module startup.
- Improved Redis service with additional methods for set operations.
Dave 4 months ago
parent
commit
c791f3072c
30 changed files with 771 additions and 45 deletions
  1. 1 1
      apps/box-app-api/src/feature/ads/ad.service.ts
  2. 14 15
      apps/box-app-api/src/feature/auth/auth.controller.ts
  3. 2 0
      apps/box-app-api/src/feature/auth/auth.module.ts
  4. 13 2
      apps/box-app-api/src/feature/auth/auth.service.ts
  5. 17 0
      apps/box-app-api/src/feature/auth/dto/login-response.dto.ts
  6. 1 6
      apps/box-app-api/src/feature/homepage/homepage.service.ts
  7. 4 0
      apps/box-mgnt-api/src/app.module.ts
  8. 12 13
      apps/box-mgnt-api/src/cache-sync/cache-checklist.service.ts
  9. 1 1
      apps/box-mgnt-api/src/cache-sync/cache-sync.service.ts
  10. 21 0
      apps/box-mgnt-api/src/cache/adpool-warmup.service.ts
  11. 9 4
      libs/common/src/ads/cache-keys.ts
  12. 30 0
      libs/common/src/cache/cache-builder.ts
  13. 6 3
      libs/common/src/cache/cache-keys.ts
  14. 18 0
      libs/common/src/cache/cache-service.ts
  15. 60 0
      libs/common/src/services/ad-pool.service.ts
  16. 18 0
      libs/core/src/ad/ad-pool-warmup.service.ts
  17. 11 0
      libs/core/src/ad/ad-pool.builder.ts
  18. 181 0
      libs/core/src/ad/ad-pool.service.ts
  19. 48 0
      libs/core/src/cache/cache-manager.module.ts
  20. 68 0
      libs/core/src/cache/category/category-cache.builder.ts
  21. 27 0
      libs/core/src/cache/category/category-cache.service.ts
  22. 18 0
      libs/core/src/cache/category/category-warmup.service.ts
  23. 52 0
      libs/core/src/cache/channel/channel-cache.builder.ts
  24. 26 0
      libs/core/src/cache/channel/channel-cache.service.ts
  25. 18 0
      libs/core/src/cache/channel/channel-warmup.service.ts
  26. 38 0
      libs/core/src/cache/tag/tag-cache.builder.ts
  27. 16 0
      libs/core/src/cache/tag/tag-cache.service.ts
  28. 18 0
      libs/core/src/cache/tag/tag-warmup.service.ts
  29. 12 0
      libs/core/src/core.module.ts
  30. 11 0
      libs/db/src/redis/redis.service.ts

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

@@ -58,7 +58,7 @@ export class AdService {
     const { scene, slot, adType } = params;
     const maxTries = params.maxTries ?? 3;
 
-    const poolKey = CacheKeys.appAdPool(scene, slot, adType);
+    const poolKey = CacheKeys.appAdPoolByType(adType);
     const pool = await this.readPoolWithDiagnostics(poolKey, {
       scene,
       slot,

+ 14 - 15
apps/box-app-api/src/feature/auth/auth.controller.ts

@@ -1,9 +1,15 @@
-// apps/box-app-api/src/auth/auth.controller.ts
+// apps/box-app-api/src/feature/auth/auth.controller.ts
 import { Body, Controller, Post, Req } from '@nestjs/common';
 import { AuthService } from './auth.service';
 import { Request } from 'express';
 import { LoginDto } from './login.dto';
-import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
+import {
+  ApiTags,
+  ApiOperation,
+  ApiResponse,
+  ApiOkResponse,
+} from '@nestjs/swagger';
+import { LoginResponseDto } from './dto/login-response.dto';
 
 @ApiTags('授权')
 @Controller('auth')
@@ -13,20 +19,13 @@ export class AuthController {
   @Post('login')
   @ApiOperation({
     summary: '用户登录',
-    description: '用户登录接口,返回访问令牌(Access Token)',
+    description:
+      '用户登录接口,返回访问令牌(Access Token),并在可用时附带一个来自广告池的随机 STARTUP 广告。',
   })
-  @ApiResponse({
-    status: 200,
-    description: '登录成功,返回访问token',
-    schema: {
-      type: 'object',
-      properties: {
-        accessToken: {
-          type: 'string',
-          example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
-        },
-      },
-    },
+  @ApiOkResponse({
+    description:
+      '登录成功,返回访问token,并在可用时返回随机 STARTUP 广告(startupAd)。',
+    type: LoginResponseDto,
   })
   @ApiResponse({ status: 400, description: '请求参数错误' })
   async login(@Body() body: LoginDto, @Req() req: Request) {

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

@@ -5,11 +5,13 @@ import { PrismaMongoModule } from '../../prisma/prisma-mongo.module';
 import { RabbitmqModule } from '../../rabbitmq/rabbitmq.module';
 import { AuthController } from './auth.controller';
 import { AuthService } from './auth.service';
+import { CoreModule } from '@box/core/core.module';
 
 @Module({
   imports: [
     PrismaMongoModule,
     RabbitmqModule,
+    CoreModule,
     JwtModule.registerAsync({
       imports: [ConfigModule],
       inject: [ConfigService],

+ 13 - 2
apps/box-app-api/src/feature/auth/auth.service.ts

@@ -3,6 +3,9 @@ import { Injectable, Logger } from '@nestjs/common';
 import { JwtService } from '@nestjs/jwt';
 import { UserLoginEventPayload } from '@box/common/events/user-login-event.dto';
 import { RabbitmqPublisherService } from '../../rabbitmq/rabbitmq-publisher.service';
+import { AdPoolService, AdPayload } from '@box/core/ad/ad-pool.service';
+import { AdType } from '@prisma/mongo/client';
+import { LoginResponseDto } from './dto/login-response.dto';
 
 @Injectable()
 export class AuthService {
@@ -10,6 +13,7 @@ export class AuthService {
   constructor(
     private readonly jwtService: JwtService,
     private readonly rabbitmqPublisher: RabbitmqPublisherService,
+    private readonly adPoolService: AdPoolService,
   ) {}
 
   async login(params: {
@@ -19,7 +23,7 @@ export class AuthService {
     appVersion?: string;
     os?: string;
     // plus whatever you need like account, password, etc.
-  }): Promise<{ accessToken: string }> {
+  }): Promise<LoginResponseDto> {
     const { uid, ip, userAgent, appVersion, os } = params;
 
     // 1) Your existing auth logic (validate user, etc.)
@@ -69,6 +73,13 @@ export class AuthService {
       );
     }
 
-    return { accessToken };
+    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);
+    }
+
+    return { accessToken, startupAd };
   }
 }

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

@@ -0,0 +1,17 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import type { AdPayload } from '@box/core/ad/ad-pool.service';
+
+export class LoginResponseDto {
+  @ApiProperty({
+    description: 'JWT access token',
+    example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
+  })
+  accessToken!: string;
+
+  @ApiPropertyOptional({
+    description: 'Randomly selected STARTUP ad payload; null when unavailable',
+    nullable: true,
+    type: 'object',
+  })
+  startupAd?: AdPayload | null;
+}

+ 1 - 6
apps/box-app-api/src/feature/homepage/homepage.service.ts

@@ -138,12 +138,7 @@ export class HomepageService {
     adType: AdType,
     order: AdOrder,
   ): Promise<HomeAdDto[]> {
-    // Get pool key - all homepage ads use 'home' scene
-    const poolKey = CacheKeys.appAdPool(
-      'home',
-      this.getSlotForType(adType),
-      adType,
-    );
+    const poolKey = CacheKeys.appAdPoolByType(adType);
 
     const pool = (await this.redis.getJson<AdPoolEntry[]>(poolKey)) ?? [];
 

+ 4 - 0
apps/box-mgnt-api/src/app.module.ts

@@ -15,6 +15,8 @@ import { MgntBackendModule } from './mgnt-backend/mgnt-backend.module';
 import pinoConfig from '@box/common/config/pino.config';
 import { CacheSyncModule } from './cache-sync/cache-sync.module';
 import { RedisModule } from '@box/db/redis/redis.module';
+import { AdPoolWarmupService } from './cache/adpool-warmup.service';
+import { CoreModule } from '@box/core/core.module';
 
 @Module({
   imports: [
@@ -41,6 +43,7 @@ import { RedisModule } from '@box/db/redis/redis.module';
     }),
 
     CacheSyncModule,
+    CoreModule,
     CommonModule,
     SharedModule,
     LoggerModule.forRoot(pinoConfig),
@@ -50,6 +53,7 @@ import { RedisModule } from '@box/db/redis/redis.module';
       http: process.env.NODE_ENV === 'development',
     }),
   ],
+  providers: [AdPoolWarmupService],
 })
 export class AppModule implements OnModuleInit {
   onModuleInit() {

+ 12 - 13
apps/box-mgnt-api/src/cache-sync/cache-checklist.service.ts

@@ -116,10 +116,7 @@ export class CacheChecklistService implements OnApplicationBootstrap {
 
     const adTypes = Object.keys(ADTYPE_POOLS) as AdType[];
     for (const adType of adTypes) {
-      const placements = ADTYPE_POOLS[adType] ?? [];
-      for (const { scene, slot } of placements) {
-        keys.push(CacheKeys.appAdPool(scene, slot, adType));
-      }
+      keys.push(CacheKeys.appAdPoolByType(adType));
     }
     return keys;
   }
@@ -138,16 +135,18 @@ export class CacheChecklistService implements OnApplicationBootstrap {
       return;
     }
     if (key.startsWith('app:adpool:')) {
-      // key format: app:adpool:<scene>:<slot>:<adType>
+      // key format: app:adpool:<adType>
       const parts = key.split(':');
-      // parts = ['app','adpool',scene,slot,adType]
-      if (parts.length === 5) {
-        const [, , scene, slot, adType] = parts;
-        await this.cacheSync.rebuildAdPoolForPlacement(
-          adType as AdType,
-          scene as any,
-          slot as any,
-        );
+      if (parts.length === 3) {
+        const [, , adType] = parts;
+        const placements = ADTYPE_POOLS[adType as AdType] ?? [];
+        for (const { scene, slot } of placements) {
+          await this.cacheSync.rebuildAdPoolForPlacement(
+            adType as AdType,
+            scene,
+            slot,
+          );
+        }
         return;
       }
     }

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

@@ -774,7 +774,7 @@ export class CacheSyncService {
         weight: 1,
       }));
 
-      const key = CacheKeys.appAdPool(scene, slot, adType);
+      const key = CacheKeys.appAdPoolByType(adType);
 
       // Atomic swap to avoid partial-read windows
       const start = Date.now();

+ 21 - 0
apps/box-mgnt-api/src/cache/adpool-warmup.service.ts

@@ -0,0 +1,21 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { AdPoolService } from '@box/core/ad/ad-pool.service';
+
+@Injectable()
+export class AdPoolWarmupService implements OnModuleInit {
+  private readonly logger = new Logger(AdPoolWarmupService.name);
+
+  constructor(private readonly adPoolService: AdPoolService) {}
+
+  async onModuleInit(): Promise<void> {
+    try {
+      await this.adPoolService.rebuildAllAdPools();
+      this.logger.log('Ad pool warmup completed');
+    } catch (err) {
+      this.logger.error(
+        'Ad pool warmup encountered an error but will not block startup',
+        err instanceof Error ? err.stack : String(err),
+      );
+    }
+  }
+}

+ 9 - 4
libs/common/src/ads/cache-keys.ts

@@ -1,10 +1,15 @@
 // libs/common/src/ads/cache-keys.ts
-import type { AdType, AdScene, AdSlot } from './ad-types';
+import type { AdType } from './ad-types';
+
+/** Build the canonical ad pool key for a given AdType. */
+function appAdPoolByType(adType: AdType): string;
+function appAdPoolByType(adType: string): string;
+function appAdPoolByType(adType: AdType | string): string {
+  return `app:adpool:${adType}`;
+}
 
 export const CacheKeys = {
-  appAdPool(scene: AdScene, slot: AdSlot, adType: AdType): string {
-    return `app:adpool:${scene}:${slot}:${adType}`;
-  },
+  appAdPoolByType,
 
   appAd(id: string): string {
     return `app:ad:${id}`;

+ 30 - 0
libs/common/src/cache/cache-builder.ts

@@ -0,0 +1,30 @@
+import { Logger } from '@nestjs/common';
+import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+
+/**
+ * Base class for cache builders that read from MongoDB and publish JSON into Redis.
+ */
+export abstract class BaseCacheBuilder {
+  protected readonly logger: Logger;
+
+  protected constructor(
+    protected readonly redis: RedisService,
+    protected readonly mongoPrisma: MongoPrismaService,
+    loggerContext?: string,
+  ) {
+    this.logger = new Logger(loggerContext ?? new.target.name);
+  }
+
+  /** Build every cache item managed by this builder. */
+  abstract buildAll(): Promise<void>;
+
+  /**
+   * Convert Prisma BigInt timestamps to numbers for JSON payloads.
+   * Falls back to the original number when already numeric.
+   */
+  protected toMillis(value?: bigint | number | null): number | null {
+    if (value === null || value === undefined) return null;
+    return typeof value === 'bigint' ? Number(value) : value;
+  }
+}

+ 6 - 3
libs/common/src/cache/cache-keys.ts

@@ -1,4 +1,5 @@
 // libs/common/src/cache/cache-keys.ts
+import type { AdType } from '../ads/ad-types';
 
 /**
  * Centralized Redis logical keys (without REDIS_KEY_PREFIX).
@@ -20,6 +21,8 @@ export const CacheKeys = {
   // ─────────────────────────────────────────────
   // CATEGORIES (existing)
   // ─────────────────────────────────────────────
+  appCategory: (categoryId: string | number): string =>
+    `app:category:${categoryId}`,
   appCategoryAll: 'app:category:all',
   appCategoryById: (categoryId: string | number): string =>
     `app:category:by-id:${categoryId}`,
@@ -46,10 +49,10 @@ export const CacheKeys = {
   appAdById: (adId: string | number): string => `app:ad:by-id:${adId}`,
 
   // ─────────────────────────────────────────────
-  // AD POOLS (existing)
+  // AD POOLS (AdType-based)
   // ─────────────────────────────────────────────
-  appAdPool: (scene: string, slot: string, type: string): string =>
-    `app:adpool:${scene}:${slot}:${type}`,
+  /** Build the canonical ad pool key for a given AdType. */
+  appAdPoolByType: (adType: AdType | string): string => `app:adpool:${adType}`,
 
   // ─────────────────────────────────────────────
   // VIDEO LISTS (existing)

+ 18 - 0
libs/common/src/cache/cache-service.ts

@@ -0,0 +1,18 @@
+import { Logger } from '@nestjs/common';
+import { RedisService } from '@box/db/redis/redis.service';
+
+/** Base utilities for cache readers backed by Redis JSON blobs. */
+export abstract class BaseCacheService {
+  protected readonly logger: Logger;
+
+  protected constructor(
+    protected readonly redis: RedisService,
+    loggerContext?: string,
+  ) {
+    this.logger = new Logger(loggerContext ?? new.target.name);
+  }
+
+  protected async getJson<T>(key: string): Promise<T | null> {
+    return this.redis.getJson<T>(key);
+  }
+}

+ 60 - 0
libs/common/src/services/ad-pool.service.ts

@@ -0,0 +1,60 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { CacheKeys } from '../cache/cache-keys';
+import type { AdPoolEntry, AdType } from '../ads/ad-types';
+import { AdType as PrismaAdType } from '@prisma/mongo/client';
+import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+
+@Injectable()
+export class AdPoolService {
+  private readonly logger = new Logger(AdPoolService.name);
+
+  constructor(
+    private readonly redis: RedisService,
+    private readonly mongoPrisma: MongoPrismaService,
+  ) {}
+
+  /** Rebuild all ad pools for every AdType. */
+  async rebuildAllAdPools(): Promise<void> {
+    const adTypes = Object.values(PrismaAdType) as AdType[];
+
+    for (const adType of adTypes) {
+      try {
+        const count = await this.rebuildAdPoolByType(adType);
+        this.logger.log(
+          `AdPool warmup succeeded for adType=${adType}, ads=${count}`,
+        );
+      } catch (err) {
+        this.logger.error(
+          `AdPool warmup failed for adType=${adType}`,
+          err instanceof Error ? err.stack : String(err),
+        );
+      }
+    }
+  }
+
+  /** Rebuild a single ad pool keyed by AdType. */
+  async rebuildAdPoolByType(adType: AdType): Promise<number> {
+    const now = BigInt(Date.now());
+
+    const ads = await this.mongoPrisma.ads.findMany({
+      where: {
+        status: 1,
+        startDt: { lte: now },
+        OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
+        adsModule: { is: { adType } },
+      },
+      orderBy: { seq: 'asc' },
+    });
+
+    const poolEntries: AdPoolEntry[] = ads.map((ad) => ({
+      id: ad.id,
+      weight: 1,
+    }));
+
+    const key = CacheKeys.appAdPoolByType(adType);
+    await this.redis.atomicSwapJson([{ key, value: poolEntries }]);
+
+    return poolEntries.length;
+  }
+}

+ 18 - 0
libs/core/src/ad/ad-pool-warmup.service.ts

@@ -0,0 +1,18 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { AdPoolBuilder } from './ad-pool.builder';
+
+@Injectable()
+export class AdPoolWarmupService implements OnModuleInit {
+  private readonly logger = new Logger(AdPoolWarmupService.name);
+
+  constructor(private readonly builder: AdPoolBuilder) {}
+
+  async onModuleInit(): Promise<void> {
+    try {
+      await this.builder.buildAll();
+      this.logger.log('Ad pool warmup completed');
+    } catch (err) {
+      this.logger.error('Ad pool warmup failed', err);
+    }
+  }
+}

+ 11 - 0
libs/core/src/ad/ad-pool.builder.ts

@@ -0,0 +1,11 @@
+import { Injectable } from '@nestjs/common';
+import { AdPoolService } from './ad-pool.service';
+
+@Injectable()
+export class AdPoolBuilder {
+  constructor(private readonly adPoolService: AdPoolService) {}
+
+  async buildAll(): Promise<void> {
+    await this.adPoolService.rebuildAllAdPools();
+  }
+}

+ 181 - 0
libs/core/src/ad/ad-pool.service.ts

@@ -0,0 +1,181 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { CacheKeys } from '@box/common/cache/cache-keys';
+import type { AdType } from '@box/common/ads/ad-types';
+import { AdType as PrismaAdType } from '@prisma/mongo/client';
+import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+
+export interface AdPayload {
+  id: string;
+  channelId: string;
+  channelName?: string;
+  adsModuleId: string;
+  adType: AdType;
+  advertiser: string;
+  title: string;
+  adsContent?: string | null;
+  adsCoverImg?: string | null;
+  adsUrl?: string | null;
+}
+
+@Injectable()
+export class AdPoolService {
+  private readonly logger = new Logger(AdPoolService.name);
+
+  constructor(
+    private readonly redis: RedisService,
+    private readonly mongoPrisma: MongoPrismaService,
+  ) {}
+
+  /** Rebuild all ad pools keyed by AdType. */
+  async rebuildAllAdPools(): Promise<void> {
+    const adTypes = Object.values(PrismaAdType) as AdType[];
+
+    for (const adType of adTypes) {
+      try {
+        const count = await this.rebuildPoolForType(adType);
+        this.logger.log(`AdPool rebuild: adType=${adType}, ads=${count}`);
+      } catch (err) {
+        this.logger.error(
+          `AdPool rebuild failed for adType=${adType}`,
+          err instanceof Error ? err.stack : String(err),
+        );
+      }
+    }
+  }
+
+  /** Rebuild a single ad pool for an AdType. Returns number of ads written. */
+  async rebuildPoolForType(adType: AdType): Promise<number> {
+    const now = BigInt(Date.now());
+
+    const ads = await this.mongoPrisma.ads.findMany({
+      where: {
+        status: 1,
+        startDt: { lte: now },
+        OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
+        adsModule: { is: { adType } },
+      },
+      orderBy: { seq: 'asc' },
+      include: {
+        channel: { select: { name: true } },
+        adsModule: { select: { adType: true } },
+      },
+    });
+
+    const payloads: AdPayload[] = ads.map((ad) => ({
+      id: ad.id,
+      channelId: ad.channelId,
+      channelName: ad.channel?.name,
+      adsModuleId: ad.adsModuleId,
+      adType: ad.adsModule.adType as AdType,
+      advertiser: ad.advertiser,
+      title: ad.title,
+      adsContent: ad.adsContent ?? null,
+      adsCoverImg: ad.adsCoverImg ?? null,
+      adsUrl: ad.adsUrl ?? null,
+    }));
+
+    const key = CacheKeys.appAdPoolByType(adType);
+    await this.redis.del(key);
+
+    if (!payloads.length) {
+      return 0;
+    }
+
+    const members = payloads.map((p) => JSON.stringify(p));
+    await this.redis.sadd(key, ...members);
+
+    return payloads.length;
+  }
+
+  /** Fetch one random ad payload from Redis SET. */
+  async getRandomFromRedisPool(adType: AdType): Promise<AdPayload | null> {
+    try {
+      const key = CacheKeys.appAdPoolByType(adType);
+      const raw = await this.redis.srandmember(key);
+      if (!raw) return null;
+      const parsed = JSON.parse(raw) as Partial<AdPayload>;
+      if (!parsed || typeof parsed !== 'object' || !parsed.id) return null;
+      return parsed as AdPayload;
+    } catch (err) {
+      this.logger.warn(
+        `getRandomFromRedisPool error for adType=${adType}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return null;
+    }
+  }
+
+  /** Fallback: pick a random ad directly from MongoDB. */
+  async getRandomFromDb(adType: AdType): Promise<AdPayload | null> {
+    try {
+      const now = BigInt(Date.now());
+      const ads = await this.mongoPrisma.ads.findMany({
+        where: {
+          status: 1,
+          startDt: { lte: now },
+          OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
+          adsModule: { is: { adType } },
+        },
+        include: {
+          channel: { select: { name: true } },
+          adsModule: { select: { adType: true } },
+        },
+      });
+
+      if (!ads.length) return null;
+
+      const pickIndex = Math.floor(Math.random() * ads.length);
+      const ad = ads[pickIndex];
+
+      const payload: AdPayload = {
+        id: ad.id,
+        channelId: ad.channelId,
+        channelName: ad.channel?.name,
+        adsModuleId: ad.adsModuleId,
+        adType: ad.adsModule.adType as AdType,
+        advertiser: ad.advertiser,
+        title: ad.title,
+        adsContent: ad.adsContent ?? null,
+        adsCoverImg: ad.adsCoverImg ?? null,
+        adsUrl: ad.adsUrl ?? null,
+      };
+
+      // Fire-and-forget rebuild for freshness.
+      void this.rebuildPoolForType(adType).catch(() => {
+        /* ignore */
+      });
+
+      return payload;
+    } catch (err) {
+      this.logger.warn(
+        `getRandomFromDb error for adType=${adType}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return null;
+    }
+  }
+
+  /** Prefer Redis; fall back to MongoDB. Never throws. */
+  async getRandomAdByType(adType: AdType): Promise<AdPayload | null> {
+    // Try Redis first
+    const fromRedis = await this.getRandomFromRedisPool(adType);
+    if (fromRedis) return fromRedis;
+
+    // Redis miss or error already logged; fall back to Mongo
+    const fromDb = await this.getRandomFromDb(adType);
+    if (!fromDb) {
+      this.logger.warn(
+        `No ads available for adType=${adType} from Redis or Mongo`,
+      );
+      return null;
+    }
+
+    // Fire-and-forget rebuild to refresh Redis cache
+    void this.rebuildPoolForType(adType).catch(() => {
+      /* ignore */
+    });
+
+    return fromDb;
+  }
+}

+ 48 - 0
libs/core/src/cache/cache-manager.module.ts

@@ -0,0 +1,48 @@
+import { Module } from '@nestjs/common';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { AdPoolService } from '../ad/ad-pool.service';
+import { AdPoolBuilder } from '../ad/ad-pool.builder';
+import { AdPoolWarmupService } from '../ad/ad-pool-warmup.service';
+import { CategoryCacheService } from './category/category-cache.service';
+import { CategoryCacheBuilder } from './category/category-cache.builder';
+import { CategoryWarmupService } from './category/category-warmup.service';
+import { TagCacheService } from './tag/tag-cache.service';
+import { TagCacheBuilder } from './tag/tag-cache.builder';
+import { TagWarmupService } from './tag/tag-warmup.service';
+import { ChannelCacheService } from './channel/channel-cache.service';
+import { ChannelCacheBuilder } from './channel/channel-cache.builder';
+import { ChannelWarmupService } from './channel/channel-warmup.service';
+
+@Module({
+  providers: [
+    // Shared data sources
+    MongoPrismaService,
+
+    // Ad pools
+    AdPoolService,
+    AdPoolBuilder,
+    AdPoolWarmupService,
+
+    // Categories
+    CategoryCacheService,
+    CategoryCacheBuilder,
+    CategoryWarmupService,
+
+    // Tags
+    TagCacheService,
+    TagCacheBuilder,
+    TagWarmupService,
+
+    // Channels
+    ChannelCacheService,
+    ChannelCacheBuilder,
+    ChannelWarmupService,
+  ],
+  exports: [
+    AdPoolService,
+    CategoryCacheService,
+    TagCacheService,
+    ChannelCacheService,
+  ],
+})
+export class CacheManagerModule {}

+ 68 - 0
libs/core/src/cache/category/category-cache.builder.ts

@@ -0,0 +1,68 @@
+import { Injectable } from '@nestjs/common';
+import { BaseCacheBuilder } from '@box/common/cache/cache-builder';
+import { CacheKeys } from '@box/common/cache/cache-keys';
+import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+
+export interface CategoryCachePayload {
+  id: string;
+  name: string;
+  subtitle?: string | null;
+  channelId: string;
+  seq: number;
+  tags: string[];
+}
+
+@Injectable()
+export class CategoryCacheBuilder extends BaseCacheBuilder {
+  constructor(redis: RedisService, mongoPrisma: MongoPrismaService) {
+    super(redis, mongoPrisma, CategoryCacheBuilder.name);
+  }
+
+  async buildAll(): Promise<void> {
+    const categories = await this.mongoPrisma.category.findMany({
+      where: { status: 1 },
+      orderBy: [{ seq: 'asc' }, { name: 'asc' }],
+    });
+
+    const categoryIds = categories.map((c) => c.id);
+    const tags = categoryIds.length
+      ? await this.mongoPrisma.tag.findMany({
+          where: { status: 1, categoryId: { in: categoryIds } },
+          orderBy: [{ seq: 'asc' }, { name: 'asc' }],
+        })
+      : [];
+
+    const tagsByCategory = new Map<string, string[]>();
+    for (const tag of tags) {
+      const list = tagsByCategory.get(tag.categoryId);
+      if (list) {
+        list.push(tag.name);
+      } else {
+        tagsByCategory.set(tag.categoryId, [tag.name]);
+      }
+    }
+
+    const payloads: CategoryCachePayload[] = categories.map((category) => ({
+      id: category.id,
+      name: category.name,
+      subtitle: category.subtitle ?? null,
+      channelId: category.channelId,
+      seq: category.seq,
+      tags: tagsByCategory.get(category.id) ?? [],
+    }));
+
+    const entries: Array<{ key: string; value: unknown }> = payloads.map(
+      (payload) => ({
+        key: CacheKeys.appCategory(payload.id),
+        value: payload,
+      }),
+    );
+
+    entries.push({ key: CacheKeys.appCategoryAll, value: payloads });
+
+    await this.redis.pipelineSetJson(entries);
+
+    this.logger.log(`Built ${payloads.length} categories`);
+  }
+}

+ 27 - 0
libs/core/src/cache/category/category-cache.service.ts

@@ -0,0 +1,27 @@
+import { Injectable } from '@nestjs/common';
+import { BaseCacheService } from '@box/common/cache/cache-service';
+import { CacheKeys } from '@box/common/cache/cache-keys';
+import { CategoryCachePayload } from './category-cache.builder';
+import { RedisService } from '@box/db/redis/redis.service';
+
+@Injectable()
+export class CategoryCacheService extends BaseCacheService {
+  constructor(redis: RedisService) {
+    super(redis, CategoryCacheService.name);
+  }
+
+  async getAllCategories(): Promise<CategoryCachePayload[]> {
+    return (
+      (await this.getJson<CategoryCachePayload[]>(CacheKeys.appCategoryAll)) ??
+      []
+    );
+  }
+
+  async getCategoryById(id: string): Promise<CategoryCachePayload | null> {
+    if (!id) return null;
+    return (
+      (await this.getJson<CategoryCachePayload>(CacheKeys.appCategory(id))) ??
+      null
+    );
+  }
+}

+ 18 - 0
libs/core/src/cache/category/category-warmup.service.ts

@@ -0,0 +1,18 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { CategoryCacheBuilder } from './category-cache.builder';
+
+@Injectable()
+export class CategoryWarmupService implements OnModuleInit {
+  private readonly logger = new Logger(CategoryWarmupService.name);
+
+  constructor(private readonly builder: CategoryCacheBuilder) {}
+
+  async onModuleInit(): Promise<void> {
+    try {
+      await this.builder.buildAll();
+      this.logger.log('Category cache warmup completed');
+    } catch (err) {
+      this.logger.error('Category cache warmup failed', err);
+    }
+  }
+}

+ 52 - 0
libs/core/src/cache/channel/channel-cache.builder.ts

@@ -0,0 +1,52 @@
+import { Injectable } from '@nestjs/common';
+import { BaseCacheBuilder } from '@box/common/cache/cache-builder';
+import { CacheKeys } from '@box/common/cache/cache-keys';
+import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+
+export interface ChannelCachePayload {
+  id: string;
+  name: string;
+  landingUrl: string;
+  videoCdn?: string;
+  coverCdn?: string;
+  clientName?: string;
+  clientNotice?: string;
+  remark?: string;
+}
+
+@Injectable()
+export class ChannelCacheBuilder extends BaseCacheBuilder {
+  constructor(redis: RedisService, mongoPrisma: MongoPrismaService) {
+    super(redis, mongoPrisma, ChannelCacheBuilder.name);
+  }
+
+  async buildAll(): Promise<void> {
+    const channels = await this.mongoPrisma.channel.findMany({
+      orderBy: [{ name: 'asc' }],
+    });
+
+    const payloads: ChannelCachePayload[] = channels.map((channel) => ({
+      id: channel.id,
+      name: channel.name,
+      landingUrl: channel.landingUrl,
+      videoCdn: channel.videoCdn ?? undefined,
+      coverCdn: channel.coverCdn ?? undefined,
+      clientName: channel.clientName ?? undefined,
+      clientNotice: channel.clientNotice ?? undefined,
+      remark: channel.remark ?? undefined,
+    }));
+
+    const entries: Array<{ key: string; value: unknown }> = payloads.map(
+      (payload) => ({
+        key: CacheKeys.appChannelById(payload.id),
+        value: payload,
+      }),
+    );
+
+    entries.push({ key: CacheKeys.appChannelAll, value: payloads });
+    await this.redis.pipelineSetJson(entries);
+
+    this.logger.log(`Built ${payloads.length} channels`);
+  }
+}

+ 26 - 0
libs/core/src/cache/channel/channel-cache.service.ts

@@ -0,0 +1,26 @@
+import { Injectable } from '@nestjs/common';
+import { BaseCacheService } from '@box/common/cache/cache-service';
+import { CacheKeys } from '@box/common/cache/cache-keys';
+import { ChannelCachePayload } from './channel-cache.builder';
+import { RedisService } from '@box/db/redis/redis.service';
+
+@Injectable()
+export class ChannelCacheService extends BaseCacheService {
+  constructor(redis: RedisService) {
+    super(redis, ChannelCacheService.name);
+  }
+
+  async getAllChannels(): Promise<ChannelCachePayload[]> {
+    return (
+      (await this.getJson<ChannelCachePayload[]>(CacheKeys.appChannelAll)) ?? []
+    );
+  }
+
+  async getChannelById(id: string): Promise<ChannelCachePayload | null> {
+    if (!id) return null;
+    return (
+      (await this.getJson<ChannelCachePayload>(CacheKeys.appChannelById(id))) ??
+      null
+    );
+  }
+}

+ 18 - 0
libs/core/src/cache/channel/channel-warmup.service.ts

@@ -0,0 +1,18 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { ChannelCacheBuilder } from './channel-cache.builder';
+
+@Injectable()
+export class ChannelWarmupService implements OnModuleInit {
+  private readonly logger = new Logger(ChannelWarmupService.name);
+
+  constructor(private readonly builder: ChannelCacheBuilder) {}
+
+  async onModuleInit(): Promise<void> {
+    try {
+      await this.builder.buildAll();
+      this.logger.log('Channel cache warmup completed');
+    } catch (err) {
+      this.logger.error('Channel cache warmup failed', err);
+    }
+  }
+}

+ 38 - 0
libs/core/src/cache/tag/tag-cache.builder.ts

@@ -0,0 +1,38 @@
+import { Injectable } from '@nestjs/common';
+import { BaseCacheBuilder } from '@box/common/cache/cache-builder';
+import { CacheKeys } from '@box/common/cache/cache-keys';
+import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+
+export interface TagCachePayload {
+  id: string;
+  name: string;
+  channelId: string;
+  categoryId: string;
+  seq: number;
+}
+
+@Injectable()
+export class TagCacheBuilder extends BaseCacheBuilder {
+  constructor(redis: RedisService, mongoPrisma: MongoPrismaService) {
+    super(redis, mongoPrisma, TagCacheBuilder.name);
+  }
+
+  async buildAll(): Promise<void> {
+    const tags = await this.mongoPrisma.tag.findMany({
+      where: { status: 1 },
+      orderBy: [{ seq: 'asc' }, { name: 'asc' }],
+    });
+
+    const payload: TagCachePayload[] = tags.map((tag) => ({
+      id: tag.id,
+      name: tag.name,
+      channelId: tag.channelId,
+      categoryId: tag.categoryId,
+      seq: tag.seq,
+    }));
+
+    await this.redis.setJson(CacheKeys.appTagAll, payload);
+    this.logger.log(`Built ${payload.length} tags`);
+  }
+}

+ 16 - 0
libs/core/src/cache/tag/tag-cache.service.ts

@@ -0,0 +1,16 @@
+import { Injectable } from '@nestjs/common';
+import { BaseCacheService } from '@box/common/cache/cache-service';
+import { CacheKeys } from '@box/common/cache/cache-keys';
+import { TagCachePayload } from './tag-cache.builder';
+import { RedisService } from '@box/db/redis/redis.service';
+
+@Injectable()
+export class TagCacheService extends BaseCacheService {
+  constructor(redis: RedisService) {
+    super(redis, TagCacheService.name);
+  }
+
+  async getAllTags(): Promise<TagCachePayload[]> {
+    return (await this.getJson<TagCachePayload[]>(CacheKeys.appTagAll)) ?? [];
+  }
+}

+ 18 - 0
libs/core/src/cache/tag/tag-warmup.service.ts

@@ -0,0 +1,18 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { TagCacheBuilder } from './tag-cache.builder';
+
+@Injectable()
+export class TagWarmupService implements OnModuleInit {
+  private readonly logger = new Logger(TagWarmupService.name);
+
+  constructor(private readonly builder: TagCacheBuilder) {}
+
+  async onModuleInit(): Promise<void> {
+    try {
+      await this.builder.buildAll();
+      this.logger.log('Tag cache warmup completed');
+    } catch (err) {
+      this.logger.error('Tag cache warmup failed', err);
+    }
+  }
+}

+ 12 - 0
libs/core/src/core.module.ts

@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { AdPoolService } from './ad/ad-pool.service';
+import { CategoryCacheService } from './cache/category/category-cache.service';
+import { CacheManagerModule } from './cache/cache-manager.module';
+
+@Module({
+  imports: [CacheManagerModule],
+  providers: [MongoPrismaService, AdPoolService, CategoryCacheService],
+  exports: [AdPoolService, CategoryCacheService, CacheManagerModule],
+})
+export class CoreModule {}

+ 11 - 0
libs/db/src/redis/redis.service.ts

@@ -49,6 +49,17 @@ export class RedisService {
     return result === 1;
   }
 
+  async sadd(key: string, ...members: string[]): Promise<number> {
+    const client = this.ensureClient();
+    if (!members.length) return 0;
+    return client.sadd(key, ...members);
+  }
+
+  async srandmember(key: string): Promise<string | null> {
+    const client = this.ensureClient();
+    return client.srandmember(key);
+  }
+
   async expire(key: string, ttlSeconds: number): Promise<boolean> {
     const client = this.ensureClient();
     const result = await client.expire(key, ttlSeconds);