Browse Source

Refactor ad and video caching mechanisms to remove adsModuleId, implement new adType schema, and enhance Redis key management

- Removed adsModuleId from AdClickMessage and AdImpressionMessage interfaces.
- Introduced new Ads Redis Key Inventory and Key Spec documentation to outline the updated caching strategy.
- Updated Video Redis Key Inventory and Key Spec documentation to reflect new semantics and payload structures.
- Modified ad-related interfaces and services to align with the new adType schema, ensuring backward compatibility.
- Enhanced VideoCacheHelper to handle legacy keys and improve data retrieval processes.
- Refactored AdPoolService and AdCacheWarmupService to utilize the new adType structure and improve data consistency.
- Implemented new methods in VideoCategoryCacheBuilder for rebuilding video caches and managing payloads.
- Updated Prisma schema for Ads to reflect changes in adType and image source defaults.
Dave 3 months ago
parent
commit
c9cf310ac3
33 changed files with 1159 additions and 529 deletions
  1. 108 0
      action-plans/20251222-ACT-01.md
  2. 158 0
      apps/box-app-api/src/feature/ads/ad-pool-cache-compat.util.ts
  3. 85 122
      apps/box-app-api/src/feature/ads/ad.service.ts
  4. 33 6
      apps/box-app-api/src/feature/homepage/homepage.service.ts
  5. 4 3
      apps/box-app-api/src/feature/recommendation/dto/ad-recommendation.dto.ts
  6. 7 6
      apps/box-app-api/src/feature/recommendation/recommend-public.controller.ts
  7. 8 7
      apps/box-app-api/src/feature/recommendation/recommendation.controller.ts
  8. 14 15
      apps/box-app-api/src/feature/recommendation/recommendation.service.ts
  9. 0 8
      apps/box-app-api/src/feature/stats/dto/ad-click.dto.ts
  10. 0 8
      apps/box-app-api/src/feature/stats/dto/ad-impression.dto.ts
  11. 0 2
      apps/box-app-api/src/feature/stats/stats-events.service.ts
  12. 24 63
      apps/box-app-api/src/feature/video/video.service.ts
  13. 40 21
      apps/box-mgnt-api/src/cache-sync/admin/video-cache-admin.controller.ts
  14. 10 9
      apps/box-mgnt-api/src/cache-sync/cache-checklist.service.ts
  15. 12 32
      apps/box-mgnt-api/src/cache-sync/cache-sync.service.ts
  16. 13 4
      apps/box-mgnt-api/src/dev/controllers/dev-video-cache.controller.ts
  17. 14 4
      apps/box-mgnt-api/src/dev/services/video-cache-debug.service.ts
  18. 6 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/MIGRATION_NOTES.md
  19. 10 2
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.controller.ts
  20. 73 28
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.dto.ts
  21. 141 59
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.service.ts
  22. 0 2
      apps/box-stats-api/src/feature/stats-events/stats-events.consumer.ts
  23. 14 2
      libs/common/src/ads/ad-types.ts
  24. 11 4
      libs/common/src/cache/cache-keys.ts
  25. 6 0
      libs/common/src/cache/ts-cache-key.provider.ts
  26. 89 29
      libs/common/src/cache/video-cache.helper.ts
  27. 0 1
      libs/common/src/events/ads-click-event.dto.ts
  28. 24 2
      libs/common/src/services/ad-pool.service.ts
  29. 41 10
      libs/core/src/ad/ad-cache-warmup.service.ts
  30. 40 26
      libs/core/src/ad/ad-pool.service.ts
  31. 164 44
      libs/core/src/cache/video/category/video-category-cache.builder.ts
  32. 1 1
      prisma/mongo-stats/schema/user-login-history.prisma
  33. 9 9
      prisma/mongo/schema/ads.prisma

+ 108 - 0
action-plans/20251222-ACT-01.md

@@ -0,0 +1,108 @@
+## Assumptions we’ll stick to
+
+- Provider video metadata is **used as-is** (including `updatedAt: DateTime`).
+- Ads cache keys are `box:app:adpool:<AdType>` and Ads payload is already slimmed (status=1, seq asc).
+- We won’t add DB fields unless we’re doing the User migration step.
+
+---
+
+## Tomorrow Action Plan
+
+### Phase 0 — Prep and guardrails
+
+1. **Confirm API contract snapshots**
+   - Current login response shape
+   - Current video endpoints response shape (even if incomplete)
+
+2. **Confirm Redis key spec and payload contracts**
+   - Ads cache keys + fields
+   - Video cache keys + fields (use provider fields as-is)
+
+Deliverable: quick “contract notes” doc/comment so we don’t drift while implementing multiple endpoints.
+
+---
+
+### Phase 1 — Login returns Ads (highest leverage)
+
+3. **Update login flow** to return Ads payload (likely home-related adTypes)
+   - Decide which `AdType`s are returned on login (e.g. STARTUP, BANNER, POPUP\_\* etc.)
+   - Read from Redis first (`box:app:adpool:<AdType>`), DB fallback if missing, then rebuild cache
+   - Ensure returned Ads are already filtered (status=1) and sorted by seq asc
+
+Deliverable: login response includes `ads` (grouped by adType or whatever your app contract expects).
+
+---
+
+### Phase 2 — Video listing/search endpoints (build foundation once, reuse everywhere)
+
+4. **`/api/v1/video/list`**
+   - Define baseline filters + pagination (current behavior, don’t invent new ones)
+   - Implement Redis list/payload strategy if we’re ready; otherwise DB-first with safe caching behind it
+
+5. **`/api/v1/video/category/`**
+   - Category list endpoint should reuse the same “list IDs → fetch payloads” pattern
+   - Stable sorting and pagination
+
+6. **`/api/v1/video/search-by-tag`**
+   - Define tag input: `tagId` / tag name / categoryId constraints
+   - Implement query + caching (tag list key) and reuse payload key
+
+Deliverable: list/search endpoints are consistent and share the same internal list-builder + payload fetcher.
+
+---
+
+### Phase 3 — Recommendation endpoints (depends on Phase 2)
+
+7. **`/api/v1/video/recommended`**
+   - Add **optional parameters** (only the ones you explicitly want)
+     - Example buckets: `categoryId`, `tagId`, `country`, `excludeIds`, `limit`
+
+   - Implement “better results” logic without breaking old callers (default behavior unchanged)
+
+8. **`/api/v1/recommendation/videos/{videoId}/similar`** _(fix the typo: you wrote `/api/v1/api/v1/...`)_
+   - Similarity rules: tag overlap / same category / same country / exclude same id
+   - Reuse existing list/payload pattern
+   - Ensure deterministic ordering
+
+Deliverable: recommended + similar endpoints work and don’t duplicate query logic.
+
+---
+
+### Phase 4 — Prisma migration: User from box-stats → box-admin
+
+9. **Model migration design**
+   - Identify current `User` schema in **box-stats** and all places it’s referenced
+   - Decide target schema in **box-admin** (migration-safe; avoid breaking fields)
+
+10. **Data migration strategy**
+
+- One-time migration script: copy users + indexes
+- Dual-read / cutover plan if needed (keep backward compatibility)
+- “Optimize may be required”: focus on indexes + query hotspots after migration
+
+Deliverable: User model lives in box-admin, references updated, and box-stats dependency reduced/removed safely.
+
+---
+
+## Implementation order (recommended)
+
+1. Login returns Ads
+2. Video list
+3. Video category
+4. Search-by-tag
+5. Recommended (optional params)
+6. Similar videos
+7. Prisma User migration
+
+---
+
+## Testing checklist (quick but effective)
+
+- Login response includes Ads and is stable even when Redis is empty
+- Video endpoints: pagination + ordering + empty results
+- Recommended/similar: excludes current videoId, respects optional params, no duplicates
+- Migration: User read/write still works after cutover; seed/admin login still ok
+
+---
+
+If you paste your **current login response DTO** (or the controller return object), I’ll turn Phase 1 into a tight Codex prompt that edits the right files without guessing.

+ 158 - 0
apps/box-app-api/src/feature/ads/ad-pool-cache-compat.util.ts

@@ -0,0 +1,158 @@
+import { Logger } from '@nestjs/common';
+import { CacheKeys } from '@box/common/cache/cache-keys';
+import type { AdPoolEntry, AdType } from '@box/common/ads/ad-types';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { RedisService } from '@box/db/redis/redis.service';
+
+const legacyRecoveryAttempted = new Set<string>();
+const moduleIdCache = new Map<string, string>();
+
+interface ReadAdPoolParams {
+  adType: string;
+  redis: RedisService;
+  mongoPrisma: MongoPrismaService;
+  logger: Logger;
+}
+
+/**
+ * Transitional helper: new ad pools are keyed by `adType`, but legacy caches
+ * were keyed by `AdsModule.id`. On a cache miss we try to read the old key once,
+ * rehydrate the new key, and stop falling back to the old key afterwards.
+ * Remove this helper once old keys are flushed in production.
+ */
+export async function readAdPoolEntriesWithLegacySupport(
+  params: ReadAdPoolParams,
+): Promise<AdPoolEntry[] | null> {
+  const { adType, redis, mongoPrisma, logger } = params;
+  const poolKey = CacheKeys.appAdPoolByType(adType);
+
+  const existing = await redis.getJson<AdPoolEntry[]>(poolKey);
+  if (Array.isArray(existing)) {
+    return existing;
+  }
+
+  if (legacyRecoveryAttempted.has(adType)) {
+    return null;
+  }
+
+  legacyRecoveryAttempted.add(adType);
+
+  const moduleId = await resolveModuleIdForType(adType, mongoPrisma);
+  if (!moduleId) {
+    logger.warn(
+      `[AdPoolCompat] Missing AdsModule for adType=${adType}; legacy pool recovery skipped.`,
+    );
+    return null;
+  }
+
+  const legacyKey = CacheKeys.legacyAppAdPoolByModuleId(moduleId);
+  const legacyValue = await redis.get(legacyKey);
+  if (!legacyValue) {
+    return null;
+  }
+
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(legacyValue);
+  } catch (err) {
+    logger.warn(
+      `[AdPoolCompat] Failed to parse legacy pool JSON for key=${legacyKey}; please rebuild caches.`,
+    );
+    return null;
+  }
+
+  if (!Array.isArray(parsed)) {
+    return null;
+  }
+
+  const normalized = parsed
+    .map((entry) => normalizeLegacyEntry(entry))
+    .filter((entry): entry is AdPoolEntry => entry !== null)
+    .sort((a, b) => a.seq - b.seq);
+
+  if (!normalized.length) {
+    return null;
+  }
+
+  await redis.atomicSwapJson([{ key: poolKey, value: normalized }]);
+
+  logger.warn(
+    `[AdPoolCompat] Rehydrated ad pool for adType=${adType} from legacy key=${legacyKey}. ` +
+      'Remove the legacy key after Redis caches have fully warmed.',
+  );
+
+  return normalized;
+}
+
+async function resolveModuleIdForType(
+  adType: string,
+  prisma: MongoPrismaService,
+): Promise<string | null> {
+  if (moduleIdCache.has(adType)) {
+    return moduleIdCache.get(adType)!;
+  }
+
+  const adsModule = await prisma.adsModule.findUnique({
+    where: { adType: adType as AdType },
+    select: { id: true },
+  });
+
+  if (!adsModule) {
+    return null;
+  }
+
+  moduleIdCache.set(adType, adsModule.id);
+  return adsModule.id;
+}
+
+function normalizeLegacyEntry(entry: unknown): AdPoolEntry | null {
+  if (!entry || typeof entry !== 'object') {
+    return null;
+  }
+
+  const record = entry as Record<string, unknown>;
+  const id = typeof record.id === 'string' ? record.id : '';
+  const adType = typeof record.adType === 'string' ? record.adType : '';
+  const advertiser =
+    typeof record.advertiser === 'string' ? record.advertiser : '';
+  const title = typeof record.title === 'string' ? record.title : '';
+
+  if (!id || !adType || !advertiser || !title) {
+    return null;
+  }
+
+  return {
+    id,
+    adType,
+    advertiser,
+    title,
+    adsContent:
+      record.adsContent === undefined
+        ? null
+        : typeof record.adsContent === 'string'
+        ? record.adsContent
+        : String(record.adsContent),
+    adsCoverImg:
+      typeof record.adsCoverImg === 'string' ? record.adsCoverImg : null,
+    adsUrl:
+      typeof record.adsUrl === 'string' ? record.adsUrl : null,
+    imgSource:
+      typeof record.imgSource === 'string'
+        ? (record.imgSource as AdPoolEntry['imgSource'])
+        : null,
+    startDt: parseEpochBigInt(record.startDt),
+    expiryDt: parseEpochBigInt(record.expiryDt),
+    seq: Number.isFinite(record.seq as number) ? Number(record.seq) : 0,
+  };
+}
+
+function parseEpochBigInt(value: unknown): bigint {
+  if (typeof value === 'bigint') return value;
+  if (typeof value === 'number' && Number.isFinite(value)) {
+    return BigInt(Math.trunc(value));
+  }
+  if (typeof value === 'string' && value.trim().length > 0) {
+    return BigInt(value);
+  }
+  return BigInt(0);
+}

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

@@ -12,6 +12,7 @@ import {
   AllAdsResponseDto,
   AdsByTypeDto,
 } from './dto';
+import type { AdPoolEntry } from '@box/common/ads/ad-types';
 import { AdType } from '@box/common/ads/ad-types';
 import { AdUrlResponseDto } from './dto/ad-url-response.dto';
 import { AdClickDto } from './dto/ad-click.dto';
@@ -25,23 +26,22 @@ import {
 import { AdsClickEventPayload } from '@box/common/events/ads-click-event.dto';
 import { randomUUID } from 'crypto';
 import { nowEpochMsBigInt } from '@box/common/time/time.util';
-
-interface AdPoolEntry {
-  id: string;
-  weight: number;
-}
+import { readAdPoolEntriesWithLegacySupport } from './ad-pool-cache-compat.util';
 
 // This should match what mgnt-side rebuildSingleAdCache stores.
 // We only care about a subset for now.
 interface CachedAd {
   id: string;
-  adsModuleId?: string;
+  adType: string;
   advertiser?: string;
   title?: string;
   adsContent?: string | null;
   adsCoverImg?: string | null;
   adsUrl?: string | null;
-  adType?: string | null;
+  imgSource?: string | null;
+  startDt?: bigint;
+  expiryDt?: bigint;
+  seq?: number;
 }
 
 export interface GetAdForPlacementParams {
@@ -143,35 +143,54 @@ export class AdService {
     poolKey: string,
     placement: Pick<GetAdForPlacementParams, 'scene' | 'slot' | 'adType'>,
   ): Promise<AdPoolEntry[] | null> {
-    const raw = await this.redis.get(poolKey);
+    try {
+      const pool = await readAdPoolEntriesWithLegacySupport({
+        adType: placement.adType,
+        redis: this.redis,
+        mongoPrisma: this.mongoPrisma,
+        logger: this.logger,
+      });
 
-    if (raw === null) {
-      this.logger.warn(
-        `Ad pool cache miss for scene=${placement.scene}, slot=${placement.slot}, adType=${placement.adType}, key=${poolKey}. Cache may be cold; ensure cache-sync rebuilt pools.`,
-      );
-      return null;
-    }
+      if (!pool) {
+        this.logger.warn(
+          `Ad pool cache miss for scene=${placement.scene}, slot=${placement.slot}, adType=${placement.adType}, key=${poolKey}. Cache may be cold; ensure cache-sync rebuilt pools.`,
+        );
+        return null;
+      }
 
-    let parsed: unknown;
-    try {
-      parsed = JSON.parse(raw);
+      if (!pool.length) {
+        this.logger.warn(
+          `Ad pool empty for scene=${placement.scene}, slot=${placement.slot}, adType=${placement.adType}, key=${poolKey}`,
+        );
+        return null;
+      }
+
+      return pool;
     } catch (err) {
-      const message =
-        err instanceof Error ? err.message : JSON.stringify(err ?? 'Unknown');
-      this.logger.error(
-        `Failed to parse ad pool JSON for key=${poolKey}: ${message}`,
-      );
-      return null;
-    }
+      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),
+          );
+        }
+        return null;
+      }
 
-    if (!Array.isArray(parsed) || parsed.length === 0) {
-      this.logger.warn(
-        `Ad pool empty or invalid shape for scene=${placement.scene}, slot=${placement.slot}, adType=${placement.adType}, key=${poolKey}`,
+      this.logger.error(
+        `Failed to read ad pool for adType=${placement.adType}, key=${poolKey}`,
+        err instanceof Error ? err.stack : String(err),
       );
       return null;
     }
-
-    return parsed as AdPoolEntry[];
   }
 
   /**
@@ -235,43 +254,13 @@ export class AdService {
     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),
-          );
-        }
-      }
+      const poolEntries = await this.readPoolWithDiagnostics(poolKey, {
+        scene: 'mgmt',
+        slot: 'list',
+        adType: adTypeInfo.adType,
+      });
 
-      if (!Array.isArray(poolEntries) || poolEntries.length === 0) {
-        // No ads for this type, add empty entry
+      if (!poolEntries || poolEntries.length === 0) {
         adsList.push({
           adType: adTypeInfo.adType,
           items: [],
@@ -374,47 +363,13 @@ export class AdService {
   ): Promise<AdListResponseDto> {
     const poolKey = CacheKeys.appAdPoolByType(adType);
 
-    // Step 1: Get the entire pool from Redis
-    // Note: The key should be a STRING (JSON), but might be a LIST from old implementation
-    let poolEntries: AdPoolEntry[] = [];
-    try {
-      // First, try to get as JSON (STRING type)
-      const jsonData = await this.redis.getJson<AdPoolEntry[]>(poolKey);
-      if (jsonData && Array.isArray(jsonData)) {
-        poolEntries = jsonData;
-      } else {
-        // If getJson failed or returned null, the key might not exist
-        this.logger.warn(
-          `Ad pool cache miss or invalid for adType=${adType}, key=${poolKey}`,
-        );
-      }
-    } catch (err) {
-      // If WRONGTYPE error, the key is stored as a different Redis type (likely from old code)
-      // Delete the incompatible key so it can be rebuilt properly
-      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=${adType}, key=${poolKey}`,
-          err instanceof Error ? err.stack : String(err),
-        );
-      }
-    }
+    const poolEntries = await this.readPoolWithDiagnostics(poolKey, {
+      scene: 'mgmt',
+      slot: 'specific',
+      adType,
+    });
 
-    if (!Array.isArray(poolEntries) || poolEntries.length === 0) {
+    if (!poolEntries || poolEntries.length === 0) {
       this.logger.debug(
         `Ad pool empty or invalid for adType=${adType}, key=${poolKey}`,
       );
@@ -520,7 +475,6 @@ export class AdService {
    */
   async getAdByIdValidated(adsId: string): Promise<{
     id: string;
-    adsModuleId: string;
     adType: string;
     adsUrl: string | null;
     advertiser: string;
@@ -536,8 +490,7 @@ export class AdService {
         // Cache hit - return cached data
         return {
           id: cachedAd.id,
-          adsModuleId: cachedAd.adsModuleId ?? '',
-          adType: cachedAd.adType ?? '',
+          adType: cachedAd.adType,
           adsUrl: cachedAd.adsUrl,
           advertiser: cachedAd.advertiser ?? '',
           title: cachedAd.title ?? '',
@@ -552,8 +505,19 @@ export class AdService {
       const now = BigInt(Date.now());
       const ad = await this.mongoPrisma.ads.findUnique({
         where: { id: adsId },
-        include: {
-          adsModule: { select: { id: true, adType: true } },
+        select: {
+          id: true,
+          adType: true,
+          advertiser: true,
+          title: true,
+          adsContent: true,
+          adsCoverImg: true,
+          adsUrl: true,
+          imgSource: true,
+          startDt: true,
+          expiryDt: true,
+          seq: true,
+          status: true,
         },
       });
 
@@ -583,13 +547,16 @@ export class AdService {
       // Cache the ad for future requests (fire-and-forget)
       const cacheData: CachedAd = {
         id: ad.id,
-        adsModuleId: ad.adsModuleId,
+        adType: ad.adType,
         advertiser: ad.advertiser,
         title: ad.title,
         adsContent: ad.adsContent,
         adsCoverImg: ad.adsCoverImg,
         adsUrl: ad.adsUrl,
-        adType: ad.adsModule.adType,
+        imgSource: ad.imgSource,
+        startDt: ad.startDt,
+        expiryDt: ad.expiryDt,
+        seq: ad.seq,
       };
 
       // Warm cache in Redis (fire-and-forget)
@@ -601,18 +568,15 @@ export class AdService {
 
       // Also notify mgnt-api to persist cache rebuild for durability (fire-and-forget)
       // This ensures the ad remains cached even if Redis is cleared
-      this.notifyCacheSyncForAdRefresh(ad.id, ad.adsModule.adType).catch(
-        (err) => {
-          this.logger.debug(
-            `Failed to notify mgnt-api for cache rebuild: ${err instanceof Error ? err.message : String(err)}`,
-          );
-        },
-      );
+      this.notifyCacheSyncForAdRefresh(ad.id, ad.adType).catch((err) => {
+        this.logger.debug(
+          `Failed to notify mgnt-api for cache rebuild: ${err instanceof Error ? err.message : String(err)}`,
+        );
+      });
 
       return {
         id: ad.id,
-        adsModuleId: ad.adsModuleId,
-        adType: ad.adsModule.adType,
+        adType: ad.adType,
         adsUrl: ad.adsUrl,
         advertiser: ad.advertiser ?? '',
         title: ad.title ?? '',
@@ -663,7 +627,6 @@ export class AdService {
     const clickEvent: AdsClickEventPayload = {
       adsId: ad.id,
       adType: ad.adType,
-      adsModuleId: ad.adsModuleId,
       uid,
       ip,
       appVersion,

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

@@ -3,6 +3,8 @@ import { Injectable, Logger } from '@nestjs/common';
 import { RedisService } from '@box/db/redis/redis.service';
 import { CacheKeys } from '@box/common/cache/cache-keys';
 import { AdType } from '@prisma/mongo/client';
+import type { AdPoolEntry } from '@box/common/ads/ad-types';
+import { readAdPoolEntriesWithLegacySupport } from '../ads/ad-pool-cache-compat.util';
 import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
 import { VideoService } from '../video/video.service';
 import {
@@ -25,11 +27,6 @@ import {
   AdSlot,
 } from './homepage.constants';
 
-interface AdPoolEntry {
-  id: string;
-  weight: number;
-}
-
 interface CachedAd {
   id: string;
   advertiser?: string;
@@ -144,7 +141,37 @@ export class HomepageService {
   ): Promise<HomeAdDto[]> {
     const poolKey = CacheKeys.appAdPoolByType(adType);
 
-    const pool = (await this.redis.getJson<AdPoolEntry[]>(poolKey)) ?? [];
+    let pool: AdPoolEntry[] | null = null;
+    try {
+      pool = await readAdPoolEntriesWithLegacySupport({
+        adType,
+        redis: this.redis,
+        mongoPrisma: this.prisma,
+        logger: this.logger,
+      });
+    } 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=${adType}, key=${poolKey}`,
+          err instanceof Error ? err.stack : String(err),
+        );
+      }
+    }
 
     if (!pool || pool.length === 0) {
       return [];

+ 4 - 3
apps/box-app-api/src/feature/recommendation/dto/ad-recommendation.dto.ts

@@ -1,4 +1,5 @@
 import { ApiProperty } from '@nestjs/swagger';
+import type { AdType as PrismaAdType } from '@prisma/mongo/client';
 
 export class AdRecommendationDto {
   @ApiProperty({
@@ -29,10 +30,10 @@ export class AdRecommendationContextDto {
   // channelId: string;
 
   @ApiProperty({
-    description: '广告模块ID(场景/槽位)',
-    example: '6756module456',
+    description: '广告类型/模块 (AdType enum)',
+    enum: PrismaAdType,
   })
-  adsModuleId: string;
+  adType: PrismaAdType;
 
   @ApiProperty({
     description: '返回推荐数量(默认5)',

+ 7 - 6
apps/box-app-api/src/feature/recommendation/recommend-public.controller.ts

@@ -11,6 +11,7 @@ import {
   YouMayAlsoLikeVideoResponseDto,
   YouMayAlsoLikeAdResponseDto,
 } from './dto/enriched-recommendation.dto';
+import type { AdType as PrismaAdType } from '@prisma/mongo/client';
 
 @ApiTags('推荐')
 @Controller('recommend')
@@ -82,10 +83,10 @@ export class RecommendPublicController {
   //   example: '6756channel123',
   // })
   @ApiQuery({
-    name: 'adsModuleId',
+    name: 'adType',
     required: true,
-    description: '广告模块ID(场景/槽位)',
-    example: '6756module456',
+    enum: PrismaAdType,
+    description: '广告类型/模块 (AdType enum)',
   })
   @ApiQuery({
     name: 'limit',
@@ -100,18 +101,18 @@ export class RecommendPublicController {
   })
   async getAdRecommendations(
     @Param('adId') adId: string,
-    @Query('adsModuleId') adsModuleId: string,
+    @Query('adType') adType: PrismaAdType,
     @Query('limit') limit?: string,
   ): Promise<YouMayAlsoLikeAdResponseDto> {
     const limitNum = limit ? parseInt(limit, 10) : 3;
 
     this.logger.debug(
-      `GET /api/v1/recommend/ad/${adId}?adsModuleId=${adsModuleId}&limit=${limitNum}`,
+      `GET /api/v1/recommend/ad/${adId}?adType=${adType}&limit=${limitNum}`,
     );
 
     const recommendations =
       await this.recommendationService.getEnrichedAdRecommendations(adId, {
-        adsModuleId,
+        adType,
         limit: limitNum,
       });
 

+ 8 - 7
apps/box-app-api/src/feature/recommendation/recommendation.controller.ts

@@ -15,6 +15,7 @@ import {
   AdRecommendationDto,
   GetSimilarAdsResponseDto,
 } from './dto/ad-recommendation.dto';
+import type { AdType as PrismaAdType } from '@prisma/mongo/client';
 
 @ApiTags('推荐系统')
 @Controller('api/v1/recommendation')
@@ -85,10 +86,10 @@ export class RecommendationController {
   //   example: '6756channel123',
   // })
   @ApiQuery({
-    name: 'adsModuleId',
+    name: 'adType',
     required: true,
-    description: '广告模块ID(场景/槽位)',
-    example: '6756module456',
+    enum: PrismaAdType,
+    description: '广告类型/模块 (AdType enum)',
   })
   @ApiQuery({
     name: 'limit',
@@ -103,19 +104,19 @@ export class RecommendationController {
   })
   async getSimilarAds(
     @Param('adId') adId: string,
-    @Query('adsModuleId') adsModuleId: string,
+    @Query('adType') adType: PrismaAdType,
     @Query('limit') limit?: string,
   ): Promise<GetSimilarAdsResponseDto> {
     const limitNum = limit ? parseInt(limit, 10) : 5;
 
     this.logger.debug(
-      `GET /api/v1/recommendation/ads/${adId}/similar?adsModuleId=${adsModuleId}&limit=${limitNum}`,
+      `GET /api/v1/recommendation/ads/${adId}/similar?adType=${adType}&limit=${limitNum}`,
     );
 
     const recommendations = await this.recommendationService.getSimilarAds(
       adId,
       {
-        adsModuleId,
+        adType,
         limit: limitNum,
       },
     );
@@ -125,7 +126,7 @@ export class RecommendationController {
       recommendations,
       count: recommendations.length,
       context: {
-        adsModuleId,
+        adType,
         limit: limitNum,
       },
     };

+ 14 - 15
apps/box-app-api/src/feature/recommendation/recommendation.service.ts

@@ -2,6 +2,7 @@
 import { Injectable, Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { RedisService } from '@box/db/redis/redis.service';
+import type { AdType as PrismaAdType } from '@prisma/mongo/client';
 import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
 import { VideoRecommendationDto } from './dto/video-recommendation.dto';
 import { AdRecommendationDto } from './dto/ad-recommendation.dto';
@@ -24,7 +25,7 @@ interface AdCandidate {
 
 export interface AdRecommendationContext {
   // channelId: string;
-  adsModuleId: string;
+  adType: PrismaAdType;
   limit?: number;
 }
 
@@ -293,7 +294,7 @@ export class RecommendationService {
   /**
    * Get similar ads with strict channel and module filtering.
    * Algorithm:
-   * 1. Fetch eligible ads from Mongo (same adsModuleId, active, valid dates)
+   * 1. Fetch eligible ads from Mongo (same adType, active, valid dates)
    * 2. Get scores from Redis ads:global:score for eligible ads
    * 3. Sort by score descending and return top N
    * 4. Exclude current adId
@@ -302,10 +303,10 @@ export class RecommendationService {
     currentAdId: string,
     context: AdRecommendationContext,
   ): Promise<AdRecommendationDto[]> {
-    const { adsModuleId, limit = 5 } = context;
+    const { adType, limit = 5 } = context;
 
     this.logger.debug(
-      `Getting similar ads for adId=${currentAdId}, adsModuleId=${adsModuleId}, limit=${limit}`,
+      `Getting similar ads for adId=${currentAdId}, adType=${adType}, limit=${limit}`,
     );
 
     try {
@@ -315,20 +316,18 @@ export class RecommendationService {
       // 2. Fetch eligible ads from Mongo with strict filters
       const eligibleAds = await this.prisma.ads.findMany({
         where: {
-          adsModuleId,
-          status: 1, // Active only
-          startDt: { lte: now }, // Started
-          expiryDt: { gte: now }, // Not expired
-          id: { not: currentAdId }, // Exclude current ad
+          adType,
+          status: 1,
+          startDt: { lte: now },
+          OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
+          id: { not: currentAdId },
         },
         select: { id: true },
         take: limit * 3, // Fetch more to ensure enough after scoring
       });
 
       if (eligibleAds.length === 0) {
-        this.logger.warn(
-          `No eligible ads found for adsModuleId=${adsModuleId}`,
-        );
+        this.logger.warn(`No eligible ads found for adType=${adType}`);
         return [];
       }
 
@@ -527,16 +526,16 @@ export class RecommendationService {
     currentAdId: string,
     context: AdRecommendationContext,
   ): Promise<EnrichedAdRecommendationDto[]> {
-    const { adsModuleId, limit = 3 } = context;
+    const { adType, limit = 3 } = context;
 
     this.logger.debug(
-      `Getting enriched ad recommendations for adId=${currentAdId}, adsModuleId=${adsModuleId}, limit=${limit}`,
+      `Getting enriched ad recommendations for adId=${currentAdId}, adType=${adType}, limit=${limit}`,
     );
 
     try {
       // 1. Get basic recommendations from existing logic
       const recommendations = await this.getSimilarAds(currentAdId, {
-        adsModuleId,
+        adType,
         limit,
       });
 

+ 0 - 8
apps/box-app-api/src/feature/stats/dto/ad-click.dto.ts

@@ -7,14 +7,6 @@ export class AdClickDto {
   @IsString()
   adId: string;
 
-  @ApiProperty({
-    description: '广告模块 ID',
-    example: '652e7bcf4f1a2b4f98ad9999',
-  })
-  @IsNotEmpty()
-  @IsString()
-  adsModuleId: string;
-
   @ApiProperty({ description: '渠道 ID', example: '652e7bcf4f1a2b4f98ad7777' })
   @IsNotEmpty()
   @IsString()

+ 0 - 8
apps/box-app-api/src/feature/stats/dto/ad-impression.dto.ts

@@ -8,14 +8,6 @@ export class AdImpressionDto {
   @IsString()
   adId: string;
 
-  @ApiProperty({
-    description: '广告模块 ID',
-    example: '652e7bcf4f1a2b4f98ad9999',
-  })
-  @IsNotEmpty()
-  @IsString()
-  adsModuleId: string;
-
   @ApiProperty({ description: '渠道 ID', example: '652e7bcf4f1a2b4f98ad7777' })
   @IsNotEmpty()
   @IsString()

+ 0 - 2
apps/box-app-api/src/feature/stats/stats-events.service.ts

@@ -10,7 +10,6 @@ import {
 export interface AdClickEvent {
   uid: string;
   adId: string;
-  adsModuleId: string;
   channelId: string;
   scene: string;
   slot: string;
@@ -42,7 +41,6 @@ export interface VideoClickEvent {
 export interface AdImpressionEvent {
   uid: string;
   adId: string;
-  adsModuleId: string;
   // channelId: string;
   scene: string;
   slot: string;

+ 24 - 63
apps/box-app-api/src/feature/video/video.service.ts

@@ -51,14 +51,11 @@ export class VideoService {
     categoryId: string,
   ): Promise<VideoDetailDto[]> {
     try {
-      // Compose Redis key for latest videos
-      const key = `box:app:video:list:category:${categoryId}:latest`;
-      // Get video IDs from Redis (LIST)
-      const videoIds: string[] = await this.redis.lrange(key, 0, -1);
+      const key = tsCacheKeys.video.categoryList(categoryId);
+      const videoIds = await this.cacheHelper.getVideoIdList(key);
       if (!videoIds || videoIds.length === 0) {
         return [];
       }
-      // Fetch video details from MongoDB, preserving order
       const videos = await this.mongoPrisma.videoMedia.findMany({
         where: { id: { in: videoIds } },
       });
@@ -760,25 +757,13 @@ export class VideoService {
         rawCategories.map(async (category) => {
           try {
             const tagKey = tsCacheKeys.tag.metadataByCategory(category.id);
-            // Tag metadata is stored as a LIST of JSON strings, not a single JSON string
-            const tagJsonStrings = await this.redis.lrange(tagKey, 0, -1);
-
-            const tags: Array<{ name: string; seq: number }> = [];
-            if (tagJsonStrings && tagJsonStrings.length > 0) {
-              for (const jsonStr of tagJsonStrings) {
-                try {
-                  const tag = JSON.parse(jsonStr);
-                  tags.push({
-                    name: tag.name,
-                    seq: tag.seq,
-                  });
-                } catch (parseErr) {
-                  this.logger.debug(
-                    `Failed to parse tag JSON for category ${category.id}`,
-                  );
-                }
-              }
-            }
+            const tagMetadata = await this.cacheHelper.getTagListForCategory(
+              tagKey,
+            );
+            const tags = (tagMetadata ?? []).map((tag) => ({
+              name: tag.name,
+              seq: tag.seq,
+            }));
 
             return {
               id: category.id,
@@ -855,10 +840,9 @@ export class VideoService {
       // Tag filter - need to find tag ID first
       try {
         const tagKey = tsCacheKeys.tag.metadataByCategory(categoryId);
-        // Tags are stored as a LIST where each element is a JSON string
-        const tagJsonStrings = await this.redis.lrange(tagKey, 0, -1);
+        const tags = await this.cacheHelper.getTagListForCategory(tagKey);
 
-        if (!tagJsonStrings || tagJsonStrings.length === 0) {
+        if (!tags || tags.length === 0) {
           this.logger.debug(
             `No tags found for categoryId=${categoryId}, tagName=${tagName}`,
           );
@@ -871,20 +855,7 @@ export class VideoService {
           };
         }
 
-        // Parse each JSON string to get tag objects
-        const tags: Array<{ id: string; name: string; seq: number }> = [];
-        for (const jsonStr of tagJsonStrings) {
-          try {
-            const tag = JSON.parse(jsonStr);
-            tags.push(tag);
-          } catch (parseErr) {
-            this.logger.debug(
-              `Failed to parse tag JSON for category ${categoryId}: ${jsonStr}`,
-            );
-          }
-        }
-
-        const tag = tags.find((t) => t.name === tagName);
+        const tag = tags.find((t) => t.name === tagName || t.id === tagName);
         if (!tag) {
           this.logger.debug(
             `Tag not found: categoryId=${categoryId}, tagName=${tagName}`,
@@ -1190,29 +1161,19 @@ export class VideoService {
     for (const category of categories) {
       try {
         const tagKey = tsCacheKeys.tag.metadataByCategory(category.id);
-        // Tag metadata is stored as a LIST of JSON strings
-        const tagJsonStrings = await this.redis.lrange(tagKey, 0, -1);
-
-        if (tagJsonStrings && tagJsonStrings.length > 0) {
-          const tags: Array<{ id: string; name: string; seq: number }> = [];
-          for (const jsonStr of tagJsonStrings) {
-            try {
-              const tag = JSON.parse(jsonStr);
-              tags.push(tag);
-            } catch (parseErr) {
-              this.logger.debug(
-                `Failed to parse tag JSON for category ${category.id}: ${jsonStr}`,
-              );
-            }
-          }
+        const tagsMetadata = await this.cacheHelper.getTagListForCategory(
+          tagKey,
+        );
 
-          const matchingTags = tags.filter((t) => t.name === tagName);
-          for (const tag of matchingTags) {
-            categoryTagPairs.push({
-              categoryId: category.id,
-              tagId: tag.id,
-            });
-          }
+        const matchingTags = (tagsMetadata ?? []).filter(
+          (t) => t.name === tagName,
+        );
+
+        for (const tag of matchingTags) {
+          categoryTagPairs.push({
+            categoryId: category.id,
+            tagId: tag.id,
+          });
         }
       } catch (err) {
         this.logger.debug(

+ 40 - 21
apps/box-mgnt-api/src/cache-sync/admin/video-cache-admin.controller.ts

@@ -159,37 +159,28 @@ export class VideoCacheAdminController {
 
     try {
       // Delete category video lists: box:app:video:category:list:*
-      const categoryListKeys = await this.redisService.keys(
+      const deletedCategoryLists = await this.deleteKeysSafeByPattern(
         tsCacheKeys.video.categoryList('*'),
+        'category video lists',
       );
-      if (categoryListKeys.length > 0) {
-        const deleted = await this.redisService.del(...categoryListKeys);
-        stats.categoryLists = deleted;
-        stats.total += deleted;
-        this.logger.log(`[AdminCache] Deleted ${deleted} category video lists`);
-      }
+      stats.categoryLists = deletedCategoryLists;
+      stats.total += deletedCategoryLists;
 
       // Delete tag video lists: box:app:video:tag:list:*:*
-      const tagListKeys = await this.redisService.keys(
+      const deletedTagLists = await this.deleteKeysSafeByPattern(
         tsCacheKeys.video.tagList('*', '*'),
+        'tag video lists',
       );
-      if (tagListKeys.length > 0) {
-        const deleted = await this.redisService.del(...tagListKeys);
-        stats.tagVideoLists = deleted;
-        stats.total += deleted;
-        this.logger.log(`[AdminCache] Deleted ${deleted} tag video lists`);
-      }
+      stats.tagVideoLists = deletedTagLists;
+      stats.total += deletedTagLists;
 
       // Delete tag metadata lists: box:app:tag:list:*
-      const tagMetadataKeys = await this.redisService.keys(
+      const deletedTagMetadata = await this.deleteKeysSafeByPattern(
         tsCacheKeys.tag.metadataByCategory('*'),
+        'tag metadata lists',
       );
-      if (tagMetadataKeys.length > 0) {
-        const deleted = await this.redisService.del(...tagMetadataKeys);
-        stats.tagMetadataLists = deleted;
-        stats.total += deleted;
-        this.logger.log(`[AdminCache] Deleted ${deleted} tag metadata lists`);
-      }
+      stats.tagMetadataLists = deletedTagMetadata;
+      stats.total += deletedTagMetadata;
 
       this.logger.log(`[AdminCache] Total keys deleted: ${stats.total}`);
       return stats;
@@ -201,6 +192,34 @@ export class VideoCacheAdminController {
       throw new BadRequestException('Failed to delete video cache keys');
     }
   }
+
+  private async deleteKeysSafeByPattern(
+    pattern: string,
+    label: string,
+  ): Promise<number> {
+    let deletedCount = 0;
+
+    const keys = await this.redisService.keys(pattern);
+    if (keys.length > 0) {
+      const deleted = await this.redisService.del(...keys);
+      deletedCount += deleted;
+      this.logger.log(
+        `[AdminCache] Deleted ${deleted} ${label} (pattern=${pattern})`,
+      );
+    }
+
+    const legacyPattern = `box:${pattern}`;
+    const legacyKeys = await this.redisService.keys(legacyPattern);
+    if (legacyKeys.length > 0) {
+      const deletedLegacy = await this.redisService.del(...legacyKeys);
+      deletedCount += deletedLegacy;
+      this.logger.log(
+        `[AdminCache] Deleted ${deletedLegacy} legacy ${label} (pattern=${legacyPattern})`,
+      );
+    }
+
+    return deletedCount;
+  }
 }
 
 /**

+ 10 - 9
apps/box-mgnt-api/src/cache-sync/cache-checklist.service.ts

@@ -75,10 +75,11 @@ export class CacheChecklistService implements OnApplicationBootstrap {
       if (exists) {
         try {
           // Check if this is an ad pool key (SET type) or regular key (JSON type)
-          if (key.startsWith('app:adpool:')) {
-            // Ad pools are stored as Redis SETs; get cardinality
-            const scard = await this.redis.scard(key);
-            items = scard ?? 0;
+          if (key.startsWith('box:app:adpool:')) {
+            const json = await this.redis.getJson<unknown[]>(key);
+            if (Array.isArray(json)) {
+              items = json.length;
+            }
           } else {
             // Other keys are JSON arrays
             const json = await this.redis.getJson<unknown>(key);
@@ -138,13 +139,13 @@ export class CacheChecklistService implements OnApplicationBootstrap {
       await this.cacheSync.rebuildTagAll();
       return;
     }
-    if (key.startsWith('app:adpool:')) {
-      // key format: app:adpool:<adType>
+    if (key.startsWith('box:app:adpool:')) {
+      // key format: box:app:adpool:<adType>
       const parts = key.split(':');
-      if (parts.length === 3) {
-        const [, , adType] = parts;
+      const adType = parts.length >= 4 ? parts[3] : undefined;
+      if (adType) {
         // Delegate to builder for the specific ad type
-        await this.cacheSync.rebuildAdPoolForType(adType as AdType);
+        await this.cacheSync.rebuildAdsCacheByType(adType as AdType);
         return;
       }
     }

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

@@ -521,9 +521,6 @@ export class CacheSyncService {
     // 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);
@@ -573,13 +570,16 @@ export class CacheSyncService {
     // For now, let's store the full ad + its module's adType.
     const cachedAd = {
       id: ad.id,
-      adsModuleId: ad.adsModuleId,
       advertiser: ad.advertiser,
       title: ad.title,
       adsContent: ad.adsContent ?? null,
       adsCoverImg: ad.adsCoverImg ?? null,
       adsUrl: ad.adsUrl ?? null,
-      adType: ad.adsModule?.adType ?? adType ?? null,
+      imgSource: ad.imgSource ?? null,
+      adType: ad.adType ?? null,
+      startDt: ad.startDt,
+      expiryDt: ad.expiryDt,
+      seq: ad.seq ?? 0,
     };
 
     try {
@@ -628,7 +628,7 @@ export class CacheSyncService {
           `handleAdPoolAction: rebuilding ad pool for adType=${adType}, action id=${action.id}`,
         );
         // Delegate to builder
-        await this.rebuildAdPoolForType(adType);
+        await this.rebuildAdsCacheByType(adType);
         break;
       }
     }
@@ -724,19 +724,7 @@ export class CacheSyncService {
       // Rebuild recommended videos
       await this.rebuildRecommendedVideos();
 
-      // Rebuild all ad pools by iterating AdType enum
-      const allAdTypes = Object.values(PrismaAdType) as AdType[];
-      for (const adType of allAdTypes) {
-        try {
-          await this.rebuildAdPoolForType(adType);
-        } catch (err) {
-          this.logger.error(
-            `Failed to rebuild ad pool for adType=${adType} during cache warming`,
-            err instanceof Error ? err.stack : String(err),
-          );
-          // Continue with other ad types even if one fails
-        }
-      }
+      await this.rebuildAllAdsCaches();
 
       this.logger.log(`Cache warming complete in ${Date.now() - start}ms`);
     } catch (err) {
@@ -779,30 +767,22 @@ export class CacheSyncService {
     await this.recommendedVideosCacheBuilder.buildAll();
   }
 
-  /**
-   * Public method to rebuild a single ad pool for an AdType.
-   * Delegates to AdPoolService.rebuildPoolForType().
-   */
-  async rebuildAdPoolForType(adType: AdType): Promise<void> {
+  async rebuildAdsCacheByType(adType: AdType): Promise<void> {
     await this.adPoolService.rebuildPoolForType(adType);
   }
 
-  /**
-   * Private helper to rebuild all ad pools.
-   * Iterates through all AdTypes and delegates to AdPoolService.
-   */
-  private async rebuildAllAdPools(): Promise<void> {
+  async rebuildAllAdsCaches(): Promise<void> {
     const allAdTypes = Object.values(PrismaAdType) as AdType[];
     for (const adType of allAdTypes) {
       try {
-        await this.rebuildAdPoolForType(adType);
+        await this.rebuildAdsCacheByType(adType);
       } catch (err) {
         this.logger.error(
-          `Failed to rebuild ad pool for adType=${adType}`,
+          `Failed to rebuild ads cache for adType=${adType}`,
           err instanceof Error ? err.stack : String(err),
         );
-        // Continue with other ad types even if one fails
       }
     }
   }
+
 }

+ 13 - 4
apps/box-mgnt-api/src/dev/controllers/dev-video-cache.controller.ts

@@ -49,11 +49,11 @@ export class DevVideoCacheController {
   ) {
     this.logger.log('🗑️ Starting video cache clear...');
 
-    // Delete cache keys by patterns
+    // Delete cache keys by canonical patterns (Redis client adds the `box:` prefix).
     const patterns = [
-      'box:app:video:category:list:*',
-      'box:app:video:tag:list:*',
-      'box:app:tag:list:*',
+      'app:video:category:list:*',
+      'app:video:tag:list:*',
+      'app:tag:list:*',
     ];
 
     const deletionStats: Record<string, number> = {};
@@ -62,6 +62,15 @@ export class DevVideoCacheController {
       const count = await this.redis.deleteByPattern(pattern);
       deletionStats[pattern] = count;
       this.logger.log(`  ✓ Deleted ${count} keys matching pattern: ${pattern}`);
+
+      // Legacy cleanup: some environments still have legacy `box:box:...` keys
+      const legacyPattern = `box:${pattern}`;
+      const legacyCount = await this.redis.deleteByPattern(legacyPattern);
+      if (legacyCount > 0) {
+        this.logger.log(
+          `    ↳ Legacy keys removed matching legacy pattern: ${legacyPattern}`,
+        );
+      }
     }
 
     const totalDeleted = Object.values(deletionStats).reduce(

+ 14 - 4
apps/box-mgnt-api/src/dev/services/video-cache-debug.service.ts

@@ -42,17 +42,17 @@ export class VideoCacheDebugService {
 
     // Scan each pattern and collect debug info
     const categoryVideoLists = await this.scanPattern(
-      'box:app:video:category:list:*',
+      'app:video:category:list:*',
       'video list',
       warnings,
     );
     const tagVideoLists = await this.scanPattern(
-      'box:app:video:tag:list:*',
+      'app:video:tag:list:*',
       'video list',
       warnings,
     );
     const tagMetadataLists = await this.scanPattern(
-      'box:app:tag:list:*',
+      'app:tag:list:*',
       'tag metadata',
       warnings,
     );
@@ -85,9 +85,19 @@ export class VideoCacheDebugService {
     type: 'video list' | 'tag metadata',
     warnings: string[],
   ): Promise<CacheKeyDebugInfo[]> {
-    const keys = await this.redis.keys(pattern);
+    const canonicalKeys = await this.redis.keys(pattern);
+    const legacyPattern = `box:${pattern}`;
+    const legacyKeys = await this.redis.keys(legacyPattern);
+
+    const keys = Array.from(new Set([...canonicalKeys, ...legacyKeys]));
     const result: CacheKeyDebugInfo[] = [];
 
+    if (legacyKeys.length > 0) {
+      const warning = `Legacy double-prefixed keys detected for pattern ${legacyPattern}`;
+      warnings.push(warning);
+      this.logger.warn(warning);
+    }
+
     for (const key of keys) {
       const redisType = await this.redis.type(key);
       const length = await this.redis.llen(key);

+ 6 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/ads/MIGRATION_NOTES.md

@@ -0,0 +1,6 @@
+# Ads migration notes
+
+- **Schema change** – `Ads` documents no longer store `adsModuleId`; the enum `adType` now sits directly on `Ads`.
+- **API compatibility** – mgnt endpoints still accept legacy `adsModuleId`, but the service resolves it to `adType` (logging a warning) and rejects when neither value is present. Once every caller switches to `adType`, you can remove the legacy branch.
+- **Data migration** – backfill every existing `Ads` record by copying `AdsModule.adType` into the new `adType` field, verify reads/writes no longer reference `adsModuleId`, and then retire the legacy key/payload mapping after caches flush.
+- **Cache migration** – `box:app:adpool:{adType}` and `app:ad:by-id:{adId}` now drive ad placement; legacy pools keyed by `adsModuleId` are only read once during fallback and should be pruned once the new adType-based caches are warm.

+ 10 - 2
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.controller.ts

@@ -55,14 +55,22 @@ export class AdsController {
   }
 
   @Post()
-  @ApiOperation({ summary: 'Create an ad' })
+  @ApiOperation({
+    summary:
+      'Create an ad (adType required; legacy adsModuleId is mapped inside service)',
+  })
+  @ApiBody({ type: CreateAdsDto })
   @ApiResponse({ status: 201, type: AdsDto })
   create(@Body() dto: CreateAdsDto) {
     return this.service.create(dto);
   }
 
   @Put(':id')
-  @ApiOperation({ summary: 'Update an ad' })
+  @ApiOperation({
+    summary:
+      'Update an ad (adType updates honored, legacy adsModuleId is resolved)',
+  })
+  @ApiBody({ type: UpdateAdsDto })
   @ApiResponse({ status: 200, type: AdsDto })
   update(@Param() { id }: MongoIdParamDto, @Body() dto: UpdateAdsDto) {
     if (dto.id && dto.id !== id) {

+ 73 - 28
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.dto.ts

@@ -1,4 +1,4 @@
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
 import {
   IsEnum,
   IsInt,
@@ -13,6 +13,14 @@ import {
 import { Transform, Type } from 'class-transformer';
 import { PageListDto } from '@box/common/dto/page-list.dto';
 import { CommonStatus } from '../common/status.enum';
+import type {
+  AdType as PrismaAdType,
+  ImageSource as PrismaImageSource,
+} from '@prisma/mongo/client';
+import {
+  AdType as PrismaAdTypeEnum,
+  ImageSource as PrismaImageSourceEnum,
+} from '@prisma/mongo/client';
 
 // ---- Base DTO for returning a full record ----
 export class AdsDto {
@@ -24,13 +32,12 @@ export class AdsDto {
   id: string;
 
   @ApiProperty({
-    description: '广告模块 (banner/startup/轮播等)',
-    example: 'banner',
+    enum: PrismaAdTypeEnum,
+    description: '广告类型 (系统内置模块/位置)',
+    example: PrismaAdTypeEnum.BANNER,
   })
-  @IsString()
-  @Length(1, 50)
-  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
-  adsModule: string;
+  @IsEnum(PrismaAdTypeEnum)
+  adType: PrismaAdType;
 
   @ApiProperty({ description: '广告商', maxLength: 20, example: 'Acme' })
   @IsString()
@@ -71,9 +78,23 @@ export class AdsDto {
   @IsUrl()
   adsUrl?: string | null;
 
+  @ApiPropertyOptional({
+    description: '广告图片公开 URL (由文件存储解析后的地址)',
+    example: 'https://cdn.example.com/ads/banner.png',
+  })
+  adsCoverImgUrl?: string | null;
+
+  @ApiProperty({
+    enum: PrismaImageSourceEnum,
+    description: '广告图片来源类型',
+    example: PrismaImageSourceEnum.LOCAL_ONLY,
+  })
+  @IsEnum(PrismaImageSourceEnum)
+  imgSource: PrismaImageSource;
+
   @ApiProperty({
-    description: '开始时间,epoch 毫秒',
-    example: 1719830400000,
+    description: '开始时间,epoch 秒 (BigInt)',
+    example: 1719830400,
   })
   @Type(() => Number)
   @IsInt()
@@ -81,8 +102,8 @@ export class AdsDto {
   startDt: number;
 
   @ApiProperty({
-    description: '到期时间,epoch 秒',
-    example: 1719916800000,
+    description: '到期时间,epoch 秒 (BigInt)',
+    example: 1719916800,
   })
   @Type(() => Number)
   @IsInt()
@@ -100,12 +121,18 @@ export class AdsDto {
   @IsEnum(CommonStatus)
   status: CommonStatus;
 
-  @ApiProperty({ description: '创建时间 epoch (ms)' })
+  @ApiProperty({
+    description: '创建时间 epoch 秒 (BigInt)',
+    example: 1719830400,
+  })
   @Type(() => Number)
   @IsInt()
   createAt: number;
 
-  @ApiProperty({ description: '更新时间 epoch (ms)' })
+  @ApiProperty({
+    description: '更新时间 epoch 秒 (BigInt)',
+    example: 1719830400,
+  })
   @Type(() => Number)
   @IsInt()
   updateAt: number;
@@ -113,11 +140,13 @@ export class AdsDto {
 
 // ---- Create ----
 export class CreateAdsDto {
-  @ApiProperty({ description: '广告模块 (banner/startup/轮播等)' })
-  @IsString()
-  @Length(1, 50)
-  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
-  adsModuleId: string;
+  @ApiProperty({
+    enum: PrismaAdTypeEnum,
+    description: '广告类型/模块 (AdType enum)',
+    example: PrismaAdTypeEnum.BANNER,
+  })
+  @IsEnum(PrismaAdTypeEnum)
+  adType: PrismaAdType;
 
   @ApiProperty({ description: '广告商', maxLength: 20 })
   @IsString()
@@ -148,13 +177,28 @@ export class CreateAdsDto {
   @IsUrl()
   adsUrl?: string;
 
-  @ApiProperty({ description: '开始时间 epoch (ms)' })
+  @ApiPropertyOptional({
+    enum: PrismaImageSourceEnum,
+    description: '广告图片来源类型(覆盖默认 LOCAL_ONLY)',
+    example: PrismaImageSourceEnum.LOCAL_ONLY,
+  })
+  @IsOptional()
+  @IsEnum(PrismaImageSourceEnum)
+  imgSource?: PrismaImageSource;
+
+  @ApiProperty({
+    description: '开始时间 epoch 秒 (BigInt)',
+    example: 1719830400,
+  })
   @Type(() => Number)
   @IsInt()
   @Min(0)
   startDt: number;
 
-  @ApiProperty({ description: '到期时间 epoch (ms)' })
+  @ApiProperty({
+    description: '到期时间 epoch 秒 (BigInt)',
+    example: 1719916800,
+  })
   @Type(() => Number)
   @IsInt()
   @Min(0)
@@ -179,7 +223,7 @@ export class CreateAdsDto {
 }
 
 // ---- Update ----
-export class UpdateAdsDto extends CreateAdsDto {
+export class UpdateAdsDto extends PartialType(CreateAdsDto) {
   @ApiProperty({ description: '广告ID (ObjectId)' })
   @IsMongoId()
   id: string;
@@ -201,12 +245,13 @@ export class ListAdsDto extends PageListDto {
   @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
   advertiser?: string;
 
-  @ApiPropertyOptional({ description: '广告模块ID', maxLength: 50 })
+  @ApiPropertyOptional({
+    enum: PrismaAdTypeEnum,
+    description: '按广告类型过滤',
+  })
   @IsOptional()
-  @IsString()
-  @MaxLength(50)
-  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
-  adsModuleId?: string;
+  @IsEnum(PrismaAdTypeEnum)
+  adType?: PrismaAdType;
 
   @ApiPropertyOptional({
     enum: CommonStatus,
@@ -220,13 +265,13 @@ export class ListAdsDto extends PageListDto {
 
 export interface AdsInterfaceDto {
   id: string;
-  adsModuleId: string;
+  adType: PrismaAdType;
   advertiser: string;
   title: string;
   adsContent?: string | null;
   adsUrl?: string | null;
 
-  imgSource: 'PROVIDER' | 'LOCAL_ONLY' | 'S3_ONLY' | 'S3_AND_LOCAL';
+  imgSource: PrismaImageSource;
   adsCoverImg?: string | null; // stored key (optional to expose)
   adsCoverImgUrl?: string | null; // final URL for frontend to use
 

+ 141 - 59
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.service.ts

@@ -3,6 +3,7 @@ import {
   Injectable,
   BadRequestException,
   NotFoundException,
+  Logger,
 } from '@nestjs/common';
 import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
@@ -19,9 +20,18 @@ import {
 } from './ads.dto';
 import { CommonStatus } from '../common/status.enum';
 import { ImageUrlBuilderService } from './image/image-url-builder.service';
-
+import type { AdType as PrismaAdType } from '@prisma/mongo/client';
+
+/**
+ * MIGRATION NOTES:
+ * - Ads previously referenced `adsModuleId` with a relation; the new schema stores `adType` as an enum and no longer joins `AdsModule`.
+ * - Create/update expect `adType` today and the service temporarily looks up legacy `adsModuleId` callers (logs a warning) so existing mgnt clients keep working until they switch.
+ * - To finish the migration, backfill every Ads document with its `adType` (use each AdsModule.adType) and remove all remaining `adsModuleId` usage in downstream code paths.
+ */
 @Injectable()
 export class AdsService {
+  private readonly logger = new Logger(AdsService.name);
+
   constructor(
     private readonly mongoPrismaService: MongoPrismaService,
     private readonly cacheSyncService: CacheSyncService,
@@ -29,15 +39,17 @@ export class AdsService {
     private readonly imageUrlBuilderService: ImageUrlBuilderService,
   ) {}
 
-  /**
-   * Current epoch time in milliseconds.
-   *
-   * NOTE:
-   *  - Kept as `number` for backward compatibility.
-   *  - If you migrate to BigInt timestamps, update this and the Prisma schema.
-   */
-  private now(): number {
-    return Date.now();
+  private nowSeconds(): bigint {
+    return BigInt(Math.floor(Date.now() / 1000));
+  }
+
+  private toBigIntSeconds(value?: number | bigint | null): bigint | undefined {
+    if (value === undefined || value === null) return undefined;
+    if (typeof value === 'bigint') return value;
+    if (typeof value === 'number' && Number.isFinite(value)) {
+      return BigInt(Math.floor(value));
+    }
+    return BigInt(value);
   }
 
   private trimOptional(value?: string | null) {
@@ -59,6 +71,37 @@ export class AdsService {
   }
 
   /**
+   * Backfill adType using adsModuleId when legacy callers still include it.
+   * Logs a warning and requires the adsModule to exist.
+   */
+  private async resolveLegacyAdType(
+    dto: { adType?: PrismaAdType },
+    context: 'create' | 'update' | 'list',
+  ): Promise<PrismaAdType | undefined> {
+    if (dto.adType) return dto.adType;
+
+    const legacy = (dto as Record<string, unknown>)['adsModuleId'];
+    if (!legacy || typeof legacy !== 'string') {
+      return undefined;
+    }
+
+    const adsModule = await this.mongoPrismaService.adsModule.findUnique({
+      where: { id: legacy },
+      select: { adType: true },
+    });
+
+    if (!adsModule) {
+      throw new NotFoundException('Ads module not found');
+    }
+
+    this.logger.warn(
+      `[AdsService] Legacy adsModuleId=${legacy} resolved to adType=${adsModule.adType} during ${context}`,
+    );
+
+    return adsModule.adType;
+  }
+
+  /**
    * Ensure the channel exists.
    */
   private async assertChannelExists(channelId: string): Promise<void> {
@@ -75,7 +118,7 @@ export class AdsService {
   private mapToDto(ad: any): AdsInterfaceDto {
     return {
       id: ad.id,
-      adsModuleId: ad.adsModuleId,
+      adType: ad.adType,
       advertiser: ad.advertiser,
       title: ad.title,
       adsContent: ad.adsContent,
@@ -96,54 +139,101 @@ export class AdsService {
   async create(dto: CreateAdsDto) {
     this.ensureTimeRange(dto.startDt, dto.expiryDt);
 
-    const now = this.now();
+    const adType = await this.resolveLegacyAdType(dto, 'create');
+    if (!adType) {
+      throw new BadRequestException('adType is required');
+    }
+
+    const startDt = this.toBigIntSeconds(dto.startDt);
+    const expiryDt = this.toBigIntSeconds(dto.expiryDt);
+    if (!startDt || !expiryDt) {
+      throw new BadRequestException(
+        'startDt and expiryDt must be valid epoch seconds',
+      );
+    }
+
+    const now = this.nowSeconds();
+
+    const adData: any = {
+      adType,
+      advertiser: this.trimOptional(dto.advertiser),
+      title: this.trimOptional(dto.title),
+      adsContent: this.trimOptional(dto.adsContent) ?? null,
+      adsCoverImg: this.trimOptional(dto.adsCoverImg) ?? null,
+      adsUrl: this.trimOptional(dto.adsUrl) ?? null,
+      startDt,
+      expiryDt,
+      seq: dto.seq ?? 0,
+      status: dto.status ?? CommonStatus.enabled,
+      createAt: now,
+      updateAt: now,
+    };
+
+    if (dto.imgSource !== undefined) {
+      adData.imgSource = dto.imgSource;
+    }
 
     const ad = await this.mongoPrismaService.ads.create({
-      data: {
-        adsModuleId: dto.adsModuleId,
-        advertiser: dto.advertiser,
-        title: dto.title,
-        adsContent: this.trimOptional(dto.adsContent) ?? null,
-        adsCoverImg: this.trimOptional(dto.adsCoverImg) ?? null,
-        adsUrl: this.trimOptional(dto.adsUrl) ?? null,
-        startDt: dto.startDt,
-        expiryDt: dto.expiryDt,
-        seq: dto.seq ?? 0,
-        status: dto.status ?? CommonStatus.enabled,
-        createAt: now,
-        updateAt: now,
-      },
-      include: { adsModule: true },
+      data: adData,
     });
 
     // Auto-schedule cache refresh (per-ad + pool)
-    await this.cacheSyncService.scheduleAdRefresh(ad.id, ad.adsModule.adType);
+    await this.cacheSyncService.scheduleAdRefresh(ad.id, ad.adType);
 
     // Return created ad mapped to AdsInterfaceDto
     return this.mapToDto(ad);
   }
 
   async update(dto: UpdateAdsDto) {
-    this.ensureTimeRange(dto.startDt, dto.expiryDt);
+    if (dto.startDt !== undefined && dto.expiryDt !== undefined) {
+      this.ensureTimeRange(dto.startDt, dto.expiryDt);
+    }
 
-    const now = this.now();
+    const now = this.nowSeconds();
 
-    // Build data object carefully to avoid unintended field changes
     const data: any = {
-      adsModuleId: dto.adsModuleId,
-      advertiser: dto.advertiser,
-      title: dto.title,
-      adsContent: this.trimOptional(dto.adsContent) ?? null,
-      adsCoverImg: this.trimOptional(dto.adsCoverImg) ?? null,
-      adsUrl: this.trimOptional(dto.adsUrl) ?? null,
-      startDt: dto.startDt,
-      expiryDt: dto.expiryDt,
-      seq: dto.seq ?? 0,
       updateAt: now,
     };
 
-    // Only update status if explicitly provided,
-    // to avoid silently re-enabling disabled ads.
+    const adTypeValue = await this.resolveLegacyAdType(dto, 'update');
+    if (adTypeValue) {
+      data.adType = adTypeValue;
+    }
+    if (dto.advertiser !== undefined) {
+      data.advertiser = this.trimOptional(dto.advertiser);
+    }
+    if (dto.title !== undefined) {
+      data.title = this.trimOptional(dto.title);
+    }
+    if (dto.adsContent !== undefined) {
+      data.adsContent = this.trimOptional(dto.adsContent) ?? null;
+    }
+    if (dto.adsCoverImg !== undefined) {
+      data.adsCoverImg = this.trimOptional(dto.adsCoverImg) ?? null;
+    }
+    if (dto.adsUrl !== undefined) {
+      data.adsUrl = this.trimOptional(dto.adsUrl) ?? null;
+    }
+    if (dto.imgSource !== undefined) {
+      data.imgSource = dto.imgSource;
+    }
+    if (dto.startDt !== undefined) {
+      const start = this.toBigIntSeconds(dto.startDt);
+      if (start === undefined) {
+        throw new BadRequestException('startDt must be a valid epoch second');
+      }
+      data.startDt = start;
+    }
+    if (dto.expiryDt !== undefined) {
+      const expiry = this.toBigIntSeconds(dto.expiryDt);
+      if (expiry === undefined) {
+        throw new BadRequestException('expiryDt must be a valid epoch second');
+      }
+      data.expiryDt = expiry;
+    }
+    if (dto.seq !== undefined) {
+      data.seq = dto.seq;
+    }
     if (dto.status !== undefined) {
       data.status = dto.status;
     }
@@ -152,11 +242,9 @@ export class AdsService {
       const ad = await this.mongoPrismaService.ads.update({
         where: { id: dto.id },
         data,
-        include: { adsModule: true },
       });
 
-      // Auto-schedule cache refresh (per-ad + pool)
-      await this.cacheSyncService.scheduleAdRefresh(ad.id, ad.adsModule.adType);
+      await this.cacheSyncService.scheduleAdRefresh(ad.id, ad.adType);
 
       return this.mapToDto(ad);
     } catch (e) {
@@ -170,7 +258,6 @@ export class AdsService {
   async findOne(id: string) {
     const row = await this.mongoPrismaService.ads.findUnique({
       where: { id },
-      include: { adsModule: true },
     });
 
     if (!row) {
@@ -189,8 +276,9 @@ export class AdsService {
     if (dto.advertiser) {
       where.advertiser = { contains: dto.advertiser, mode: 'insensitive' };
     }
-    if (dto.adsModuleId) {
-      where.adsModuleId = dto.adsModuleId;
+    const adTypeFilter = await this.resolveLegacyAdType(dto, 'list');
+    if (adTypeFilter) {
+      where.adType = adTypeFilter;
     }
     if (dto.status !== undefined) {
       where.status = dto.status;
@@ -204,12 +292,9 @@ export class AdsService {
       this.mongoPrismaService.ads.count({ where }),
       this.mongoPrismaService.ads.findMany({
         where,
-        orderBy: { updateAt: 'desc' },
+        orderBy: [{ seq: 'asc' }, { updateAt: 'desc' }, { createAt: 'desc' }],
         skip: (page - 1) * size,
         take: size,
-        include: {
-          adsModule: true,
-        },
       }),
     ]);
 
@@ -229,14 +314,13 @@ export class AdsService {
       // Fetch ad first to get adType for cache invalidation
       const ad = await this.mongoPrismaService.ads.findUnique({
         where: { id },
-        include: { adsModule: true },
       });
 
       await this.mongoPrismaService.ads.delete({ where: { id } });
 
       // Auto-schedule cache refresh if ad existed
-      if (ad?.adsModule?.adType) {
-        await this.cacheSyncService.scheduleAdRefresh(id, ad.adsModule.adType);
+      if (ad?.adType) {
+        await this.cacheSyncService.scheduleAdRefresh(id, ad.adType);
       }
 
       return { message: 'Deleted' };
@@ -264,7 +348,6 @@ export class AdsService {
     // Ensure ad exists
     const ad = await this.mongoPrismaService.ads.findUnique({
       where: { id },
-      include: { adsModule: true },
     });
     if (!ad) {
       throw new NotFoundException('Ads not found');
@@ -299,13 +382,12 @@ export class AdsService {
       data: {
         adsCoverImg: key,
         imgSource,
-        updateAt: this.now(),
+        updateAt: this.nowSeconds(),
       },
-      include: { adsModule: true },
     });
 
     // Schedule cache refresh
-    await this.cacheSyncService.scheduleAdRefresh(id, ad.adsModule.adType);
+    await this.cacheSyncService.scheduleAdRefresh(id, ad.adType);
 
     return updated;
   }

+ 0 - 2
apps/box-stats-api/src/feature/stats-events/stats-events.consumer.ts

@@ -24,7 +24,6 @@ interface BaseStatsMessage {
 interface AdClickMessage extends BaseStatsMessage {
   adsId: string; // Ad ID (from publisher)
   adId?: string; // Alternative field name (for backward compatibility)
-  adsModuleId: string;
   channelId: string;
   scene?: string; // Optional - from ad placement context
   slot?: string; // Optional - from ad placement context
@@ -45,7 +44,6 @@ interface VideoClickMessage extends BaseStatsMessage {
 
 interface AdImpressionMessage extends BaseStatsMessage {
   adId: string;
-  adsModuleId: string;
   // channelId: string;
   scene: string;
   slot: string;

+ 14 - 2
libs/common/src/ads/ad-types.ts

@@ -1,5 +1,8 @@
 // libs/common/src/ads/ad-types.ts
-import { AdType as PrismaAdType } from '@prisma/mongo/client';
+import {
+  AdType as PrismaAdType,
+  ImageSource as PrismaImageSource,
+} from '@prisma/mongo/client';
 
 /**
  * Canonical ad type.
@@ -42,5 +45,14 @@ export interface AdPoolPlacement {
  */
 export interface AdPoolEntry {
   id: string; // Ads.id (Mongo ObjectId string)
-  weight: number;
+  adType: AdType;
+  advertiser: string;
+  title: string;
+  adsContent?: string | null;
+  adsCoverImg?: string | null;
+  adsUrl?: string | null;
+  imgSource?: PrismaImageSource | null;
+  startDt: bigint;
+  expiryDt: bigint;
+  seq: number;
 }

+ 11 - 4
libs/common/src/cache/cache-keys.ts

@@ -42,7 +42,7 @@ export const CacheKeys = {
   appTagAll: 'app:tag:all',
 
   appTagByCategoryKey: (categoryId: string | number): string =>
-    `box:app:tag:list:${categoryId}`,
+    `app:tag:list:${categoryId}`,
 
   // ─────────────────────────────────────────────
   // ADS (existing)
@@ -53,7 +53,11 @@ export const CacheKeys = {
   // AD POOLS (AdType-based)
   // ─────────────────────────────────────────────
   /** Build the canonical ad pool key for a given AdType. */
-  appAdPoolByType: (adType: AdType | string): string => `app:adpool:${adType}`,
+  appAdPoolByType: (adType: AdType | string): string =>
+    `box:app:adpool:${adType}`,
+  /** Legacy ad pool key scoped by AdsModule ID. Remove once legacy caches expire. */
+  legacyAppAdPoolByModuleId: (moduleId: string): string =>
+    `app:adpool:${moduleId}`,
 
   // ─────────────────────────────────────────────
   // VIDEO LISTS (existing)
@@ -71,11 +75,14 @@ export const CacheKeys = {
   // ─────────────────────────────────────────────
   appVideoDetailKey: (videoId: string): string => `app:video:detail:${videoId}`,
 
+  appVideoPayloadKey: (videoId: string): string =>
+    `app:video:payload:${videoId}`,
+
   appVideoCategoryListKey: (categoryId: string): string =>
-    `box:app:video:category:list:${categoryId}`,
+    `app:video:category:list:${categoryId}`,
 
   appVideoTagListKey: (categoryId: string, tagId: string): string =>
-    `box:app:video:tag:list:${categoryId}:${tagId}`,
+    `app:video:tag:list:${categoryId}:${tagId}`,
 
   // ─────────────────────────────────────────────
   // VIDEO POOLS (sorted listings with scores)

+ 6 - 0
libs/common/src/cache/ts-cache-key.provider.ts

@@ -144,6 +144,11 @@ export interface TsCacheKeyBuilder {
      * Redis Type: STRING (JSON object)
      */
     detail(videoId: string): string;
+    /**
+     * Get video payload data (minimal metadata) per video.
+     * Redis Type: STRING (JSON object)
+     */
+    payload(videoId: string): string;
 
     /**
      * Get video category list by category.
@@ -250,6 +255,7 @@ export function createTsCacheKeyBuilder(): TsCacheKeyBuilder {
     },
     video: {
       detail: (videoId) => CacheKeys.appVideoDetailKey(videoId),
+      payload: (videoId) => CacheKeys.appVideoPayloadKey(videoId),
       categoryList: (categoryId) =>
         CacheKeys.appVideoCategoryListKey(categoryId),
       tagList: (categoryId, tagId) =>

+ 89 - 29
libs/common/src/cache/video-cache.helper.ts

@@ -129,8 +129,15 @@ export class VideoCacheHelper {
    */
   async getVideoIdList(key: string, start = 0, stop = -1): Promise<string[]> {
     try {
-      const videoIds = await this.redis.lrange(key, start, stop);
-      return videoIds || [];
+      const videoIds = await this.readListRangeWithLegacy(
+        key,
+        start,
+        stop,
+        async (legacyData) => {
+          await this.saveVideoIdList(key, legacyData);
+        },
+      );
+      return videoIds;
     } catch (err) {
       this.logger.error(
         `Failed to get video ID list from ${key}`,
@@ -154,7 +161,19 @@ export class VideoCacheHelper {
    */
   async getVideoIdListLength(key: string): Promise<number> {
     try {
-      return await this.redis.llen(key);
+      const length = await this.redis.llen(key);
+      if (length > 0) {
+        return length;
+      }
+      const legacyList = await this.readListRangeWithLegacy(
+        key,
+        0,
+        -1,
+        async (legacyData) => {
+          await this.saveVideoIdList(key, legacyData);
+        },
+      );
+      return legacyList.length;
     } catch (err) {
       this.logger.error(
         `Failed to get list length for ${key}`,
@@ -240,34 +259,19 @@ export class VideoCacheHelper {
    */
   async getTagListForCategory(key: string): Promise<TagMetadata[]> {
     try {
-      const items = await this.redis.lrange(key, 0, -1);
-
-      if (!items || items.length === 0) {
-        return [];
-      }
-
-      const tags: TagMetadata[] = [];
-      let malformedCount = 0;
-      for (const item of items) {
-        try {
-          const parsed = JSON.parse(item) as TagMetadata;
-          tags.push(parsed);
-        } catch (parseErr) {
-          malformedCount++;
-        }
-      }
-
-      if (malformedCount > 0) {
-        this.logger.warn(
-          `[GetTagList] key=${key}: parsed ${tags.length} tags, skipped ${malformedCount} malformed items`,
-        );
-      } else {
-        this.logger.debug(
-          `[GetTagList] key=${key}: parsed ${tags.length} tags`,
-        );
+      const items = await this.readListRangeWithLegacy(
+        key,
+        0,
+        -1,
+        async (legacyData) => {
+      const tags = this.parseTagMetadataStrings(legacyData, key);
+      if (tags.length > 0) {
+        await this.saveTagList(key, tags);
       }
+        },
+      );
 
-      return tags;
+      return this.parseTagMetadataStrings(items, key);
     } catch (err) {
       this.logger.error(
         `[GetTagList] Failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`,
@@ -323,4 +327,60 @@ export class VideoCacheHelper {
       return 0;
     }
   }
+
+  private legacyPrefixedKey(key: string): string {
+    return `box:${key}`;
+  }
+
+  private async readListRangeWithLegacy(
+    key: string,
+    start: number,
+    stop: number,
+    migrate: (legacyData: string[]) => Promise<void>,
+  ): Promise<string[]> {
+    const result = await this.redis.lrange(key, start, stop);
+    if (result.length > 0) {
+      return result;
+    }
+
+    const legacyKey = this.legacyPrefixedKey(key);
+    const legacyData = await this.redis.lrange(legacyKey, start, stop);
+
+    if (!legacyData.length) {
+      return [];
+    }
+
+    this.logger.warn(
+      `[VideoCacheHelper] Legacy key triggered: ${legacyKey}, rehydrating ${key}`,
+    );
+
+    await migrate(legacyData);
+    return legacyData;
+  }
+
+  private parseTagMetadataStrings(raw: string[], key: string): TagMetadata[] {
+    const tags: TagMetadata[] = [];
+    let malformedCount = 0;
+
+    for (const item of raw) {
+      try {
+        const parsed = JSON.parse(item) as TagMetadata;
+        tags.push(parsed);
+      } catch {
+        malformedCount++;
+      }
+    }
+
+    if (malformedCount > 0) {
+      this.logger.warn(
+        `[GetTagList] key=${key}: parsed ${tags.length} tags, skipped ${malformedCount} malformed items`,
+      );
+    } else {
+      this.logger.debug(
+        `[GetTagList] key=${key}: parsed ${tags.length} tags`,
+      );
+    }
+
+    return tags;
+  }
 }

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

@@ -3,7 +3,6 @@
 export interface AdsClickEventPayload {
   adsId: string; // Ads ObjectId
   adType: string; // BANNER, STARTUP, etc.
-  adsModuleId: string; // AdsModule ObjectId
   uid: string; // User device ID from JWT
   ip: string; // Client IP
   appVersion?: string; // App version

+ 24 - 2
libs/common/src/services/ad-pool.service.ts

@@ -39,17 +39,39 @@ export class AdPoolService {
 
     const ads = await this.mongoPrisma.ads.findMany({
       where: {
+        adType,
         status: 1,
         startDt: { lte: now },
         OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
-        adsModule: { is: { adType } },
       },
       orderBy: { seq: 'asc' },
+      select: {
+        id: true,
+        adType: true,
+        advertiser: true,
+        title: true,
+        adsContent: true,
+        adsCoverImg: true,
+        adsUrl: true,
+        imgSource: true,
+        startDt: true,
+        expiryDt: true,
+        seq: true,
+      },
     });
 
     const poolEntries: AdPoolEntry[] = ads.map((ad) => ({
       id: ad.id,
-      weight: 1,
+      adType: ad.adType,
+      advertiser: ad.advertiser,
+      title: ad.title,
+      adsContent: ad.adsContent ?? null,
+      adsCoverImg: ad.adsCoverImg ?? null,
+      adsUrl: ad.adsUrl ?? null,
+      imgSource: ad.imgSource ?? null,
+      startDt: ad.startDt,
+      expiryDt: ad.expiryDt,
+      seq: ad.seq,
     }));
 
     const key = CacheKeys.appAdPoolByType(adType);

+ 41 - 10
libs/core/src/ad/ad-cache-warmup.service.ts

@@ -6,13 +6,16 @@ import type { AdType } from '@box/common/ads/ad-types';
 
 interface CachedAd {
   id: string;
-  adsModuleId: string;
+  adType: string;
   advertiser: string;
   title: string;
   adsContent: string | null;
   adsCoverImg: string | null;
   adsUrl: string | null;
-  adType: string;
+  imgSource: string | null;
+  startDt: bigint;
+  expiryDt: bigint;
+  seq: number;
 }
 
 /**
@@ -61,8 +64,19 @@ export class AdCacheWarmupService implements OnModuleInit {
           startDt: { lte: now },
           OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
         },
-        include: {
-          adsModule: { select: { adType: true } },
+        orderBy: { seq: 'asc' },
+        select: {
+          id: true,
+          adType: true,
+          advertiser: true,
+          title: true,
+          adsContent: true,
+          adsCoverImg: true,
+          adsUrl: true,
+          imgSource: true,
+          startDt: true,
+          expiryDt: true,
+          seq: true,
         },
       });
 
@@ -76,13 +90,16 @@ export class AdCacheWarmupService implements OnModuleInit {
         try {
           await this.cacheAd(ad.id, {
             id: ad.id,
-            adsModuleId: ad.adsModuleId,
+            adType: ad.adType,
             advertiser: ad.advertiser,
             title: ad.title,
             adsContent: ad.adsContent ?? null,
             adsCoverImg: ad.adsCoverImg ?? null,
             adsUrl: ad.adsUrl ?? null,
-            adType: ad.adsModule.adType,
+            imgSource: ad.imgSource ?? null,
+            startDt: ad.startDt,
+            expiryDt: ad.expiryDt,
+            seq: ad.seq,
           });
           successCount++;
         } catch (err) {
@@ -122,8 +139,19 @@ export class AdCacheWarmupService implements OnModuleInit {
 
     const ad = await this.mongoPrisma.ads.findUnique({
       where: { id: adId },
-      include: {
-        adsModule: { select: { adType: true } },
+      select: {
+        id: true,
+        adType: true,
+        advertiser: true,
+        title: true,
+        adsContent: true,
+        adsCoverImg: true,
+        adsUrl: true,
+        imgSource: true,
+        startDt: true,
+        expiryDt: true,
+        seq: true,
+        status: true,
       },
     });
 
@@ -152,13 +180,16 @@ export class AdCacheWarmupService implements OnModuleInit {
     // Cache the ad
     await this.cacheAd(adId, {
       id: ad.id,
-      adsModuleId: ad.adsModuleId,
+      adType: ad.adType,
       advertiser: ad.advertiser,
       title: ad.title,
       adsContent: ad.adsContent ?? null,
       adsCoverImg: ad.adsCoverImg ?? null,
       adsUrl: ad.adsUrl ?? null,
-      adType: ad.adsModule.adType,
+      imgSource: ad.imgSource ?? null,
+      startDt: ad.startDt,
+      expiryDt: ad.expiryDt,
+      seq: ad.seq,
     });
 
     this.logger.debug(`Cached ad ${adId}`);

+ 40 - 26
libs/core/src/ad/ad-pool.service.ts

@@ -1,24 +1,14 @@
 import { Injectable, Logger } from '@nestjs/common';
 import { randomUUID } from 'crypto';
 import { CacheKeys } from '@box/common/cache/cache-keys';
-import type { AdType } from '@box/common/ads/ad-types';
+import type { AdPoolEntry, 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;
-  adsModuleId: string;
-  adType: AdType;
-  advertiser: string;
-  title: string;
-  adsContent?: string | null;
-  adsCoverImg?: string | null;
-  adsUrl?: string | null;
+export type AdPayload = AdPoolEntry & {
   trackingId?: string;
-}
-
-type AdPoolEntry = AdPayload;
+};
 
 @Injectable()
 export class AdPoolService {
@@ -62,33 +52,44 @@ export class AdPoolService {
 
     const ads = await this.mongoPrisma.ads.findMany({
       where: {
+        adType,
         status: 1,
         startDt: { lte: now },
         OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
-        adsModule: { is: { adType } },
       },
       orderBy: { seq: 'asc' },
-      include: {
-        adsModule: { select: { adType: true } },
+      select: {
+        id: true,
+        adType: true,
+        advertiser: true,
+        title: true,
+        adsContent: true,
+        adsCoverImg: true,
+        adsUrl: true,
+        imgSource: true,
+        startDt: true,
+        expiryDt: true,
+        seq: true,
       },
     });
 
     const payloads: AdPayload[] = ads.map((ad) => ({
       id: ad.id,
-      adsModuleId: ad.adsModuleId,
-      adType: ad.adsModule.adType as AdType,
+      adType: ad.adType as AdType,
       advertiser: ad.advertiser,
       title: ad.title,
       adsContent: ad.adsContent ?? null,
       adsCoverImg: ad.adsCoverImg ?? null,
       adsUrl: ad.adsUrl ?? null,
+      imgSource: ad.imgSource ?? null,
+      startDt: ad.startDt,
+      expiryDt: ad.expiryDt,
+      seq: ad.seq,
     }));
 
     const key = CacheKeys.appAdPoolByType(adType);
 
-    // Always overwrite with a JSON string so consumers never hit WRONGTYPE
-    await this.redis.del(key);
-    await this.redis.setJson<AdPoolEntry[]>(key, payloads);
+    await this.redis.atomicSwapJson([{ key, value: payloads }]);
 
     return payloads.length;
   }
@@ -125,13 +126,23 @@ export class AdPoolService {
       const now = BigInt(Date.now());
       const ads = await this.mongoPrisma.ads.findMany({
         where: {
+          adType,
           status: 1,
           startDt: { lte: now },
           OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
-          adsModule: { is: { adType } },
         },
-        include: {
-          adsModule: { select: { adType: true } },
+        select: {
+          id: true,
+          adType: true,
+          advertiser: true,
+          title: true,
+          adsContent: true,
+          adsCoverImg: true,
+          adsUrl: true,
+          imgSource: true,
+          startDt: true,
+          expiryDt: true,
+          seq: true,
         },
       });
 
@@ -142,13 +153,16 @@ export class AdPoolService {
 
       const payload: AdPayload = {
         id: ad.id,
-        adsModuleId: ad.adsModuleId,
-        adType: ad.adsModule.adType as AdType,
+        adType: ad.adType,
         advertiser: ad.advertiser,
         title: ad.title,
         adsContent: ad.adsContent ?? null,
         adsCoverImg: ad.adsCoverImg ?? null,
         adsUrl: ad.adsUrl ?? null,
+        imgSource: ad.imgSource ?? null,
+        startDt: ad.startDt,
+        expiryDt: ad.expiryDt,
+        seq: typeof ad.seq === 'number' ? ad.seq : 0,
         trackingId: this.generateTrackingId(),
       };
 

+ 164 - 44
libs/core/src/cache/video/category/video-category-cache.builder.ts

@@ -3,6 +3,7 @@ import { Injectable, Logger } from '@nestjs/common';
 import { BaseCacheBuilder } from '@box/common/cache/cache-builder';
 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 { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
 import {
   VideoCacheHelper,
@@ -15,6 +16,24 @@ import {
  */
 export interface TagMetadataPayload extends TagMetadata {}
 
+interface VideoPayloadRow {
+  id: string;
+  title: string;
+  coverImg: string;
+  coverImgNew: string;
+  videoTime: number;
+  country: string;
+  firstTag: string;
+  secondTags: string[];
+  preFileName: string;
+  desc: string;
+  size: bigint;
+  updatedAt: Date;
+  filename: string;
+  fieldNameFs: string;
+  ext: string;
+}
+
 /**
  * Cache builder for video category/tag lists following new semantics.
  *
@@ -204,26 +223,7 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
     try {
       this.logger.debug(`[CategoryList] Building for categoryId=${categoryId}`);
 
-      // ═══════════════════════════════════════════════════════════════
-      // PRISMA QUERY
-      // ═══════════════════════════════════════════════════════════════
-      // Filters applied:
-      // - categoryId: exact match (partition key for category videos)
-      // - listStatus: 1 = only "on shelf" videos (business rule: publishable)
-      //
-      // Ordering: by addedTime DESC (provider's upload timestamp), fallback createdAt DESC
-      // Reason: preserves provider's intended order for video feeds
-      //
-      // Selection: ID only (minimal data for Redis LIST storage)
-      const videos = await this.mongoPrisma.videoMedia.findMany({
-        where: {
-          categoryIds: { has: categoryId },
-          status: 'Completed',
-          // listStatus: 1, // ✅ Only "on shelf" videos (matches stats endpoint)
-        },
-        orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
-        select: { id: true }, // Only fetch IDs, not full documents
-      });
+      const videos = await this.fetchVideosForCategory(categoryId);
 
       const videoIds = videos.map((v) => v.id);
       const key = tsCacheKeys.video.categoryList(categoryId);
@@ -232,9 +232,10 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
         this.logger.debug(`[CategoryList] Empty category: ${categoryId}`);
       }
 
-      // Atomic write: DEL existing key, RPUSH video IDs, set TTL if configured
       await this.cacheHelper.saveVideoIdList(key, videoIds);
 
+      await this.writeVideoPayloads(videos);
+
       this.logger.debug(
         `[CategoryList] Complete: ${categoryId} → ${videoIds.length} videos`,
       );
@@ -287,28 +288,7 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
         `[TagList] Building for categoryId=${categoryId}, tagId=${tagId}`,
       );
 
-      // ═══════════════════════════════════════════════════════════════
-      // PRISMA QUERY
-      // ═══════════════════════════════════════════════════════════════
-      // Filters applied:
-      // - categoryId: exact match (partition key)
-      // - listStatus: 1 = only "on shelf" videos (business rule: publishable)
-      // - tagIds: { has: tagId } = JSON array contains this tag ID
-      //
-      // Ordering: by addedTime DESC (provider's upload timestamp), fallback createdAt DESC
-      // Reason: preserves provider's intended order, filtered by tag membership
-      //
-      // Selection: ID only (minimal data for Redis LIST storage)
-      const videos = await this.mongoPrisma.videoMedia.findMany({
-        where: {
-          categoryIds: { has: categoryId },
-          status: 'Completed',
-          // listStatus: 1, // ✅ Only "on shelf" videos (matches stats endpoint)
-          tagIds: { has: tagId }, // ✅ Has this specific tag (matches stats endpoint)
-        },
-        orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
-        select: { id: true }, // Only fetch IDs
-      });
+      const videos = await this.fetchVideosForTag(categoryId, tagId);
 
       const videoIds = videos.map((v) => v.id);
       const key = tsCacheKeys.video.tagList(categoryId, tagId);
@@ -319,9 +299,10 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
         );
       }
 
-      // Atomic write: DEL existing key, RPUSH video IDs, set TTL if configured
       await this.cacheHelper.saveVideoIdList(key, videoIds);
 
+      await this.writeVideoPayloads(videos);
+
       this.logger.debug(
         `[TagList] Complete: categoryId=${categoryId}, tagId=${tagId} → ${videoIds.length} videos`,
       );
@@ -434,4 +415,143 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
       throw err;
     }
   }
+
+  async rebuildVideoCategoryCache(categoryId: string): Promise<void> {
+    await this.buildCategoryVideoListForCategory(categoryId);
+  }
+
+  async rebuildVideoTagCache(
+    categoryId: string,
+    tagId: string,
+  ): Promise<void> {
+    await this.buildTagFilteredVideoListForTag(categoryId, tagId);
+  }
+
+  async rebuildVideoPayload(videoId: string): Promise<void> {
+    const video = await this.fetchVideoById(videoId);
+    if (!video) {
+      await this.redis.del(CacheKeys.appVideoPayloadKey(videoId));
+      return;
+    }
+
+    await this.writeVideoPayloads([video]);
+  }
+
+  async rebuildAllVideoCaches(): Promise<void> {
+    await this.buildAll();
+  }
+
+  private async fetchVideosForCategory(
+    categoryId: string,
+  ): Promise<VideoPayloadRow[]> {
+    return this.mongoPrisma.videoMedia.findMany({
+      where: {
+        categoryIds: { has: categoryId },
+        status: 'Completed',
+      },
+      orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
+      select: {
+        id: true,
+        title: true,
+        coverImg: true,
+        coverImgNew: true,
+        videoTime: true,
+        country: true,
+        firstTag: true,
+        secondTags: true,
+        preFileName: true,
+        desc: true,
+        size: true,
+        updatedAt: true,
+        filename: true,
+        fieldNameFs: true,
+        ext: true,
+      },
+    });
+  }
+
+  private async fetchVideosForTag(
+    categoryId: string,
+    tagId: string,
+  ): Promise<VideoPayloadRow[]> {
+    return this.mongoPrisma.videoMedia.findMany({
+      where: {
+        categoryIds: { has: categoryId },
+        status: 'Completed',
+        tagIds: { has: tagId },
+      },
+      orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
+      select: {
+        id: true,
+        title: true,
+        coverImg: true,
+        coverImgNew: true,
+        videoTime: true,
+        country: true,
+        firstTag: true,
+        secondTags: true,
+        preFileName: true,
+        desc: true,
+        size: true,
+        updatedAt: true,
+        filename: true,
+        fieldNameFs: true,
+        ext: true,
+      },
+    });
+  }
+
+  private async fetchVideoById(videoId: string): Promise<VideoPayloadRow | null> {
+    return this.mongoPrisma.videoMedia.findUnique({
+      where: { id: videoId },
+      select: {
+        id: true,
+        title: true,
+        coverImg: true,
+        coverImgNew: true,
+        videoTime: true,
+        country: true,
+        firstTag: true,
+        secondTags: true,
+        preFileName: true,
+        desc: true,
+        size: true,
+        updatedAt: true,
+        filename: true,
+        fieldNameFs: true,
+        ext: true,
+      },
+    });
+  }
+
+  private async writeVideoPayloads(videos: VideoPayloadRow[]): Promise<void> {
+    if (!videos.length) return;
+
+    const entries = videos.map((video) => ({
+      key: CacheKeys.appVideoPayloadKey(video.id),
+      value: this.mapToPayload(video),
+    }));
+
+    await this.redis.pipelineSetJson(entries);
+  }
+
+  private mapToPayload(video: VideoPayloadRow) {
+    return {
+      id: video.id,
+      title: video.title,
+      coverImg: video.coverImg,
+      coverImgNew: video.coverImgNew,
+      videoTime: video.videoTime,
+      country: video.country,
+      firstTag: video.firstTag,
+      secondTags: video.secondTags,
+      preFileName: video.preFileName,
+      desc: video.desc,
+      size: video.size,
+      updatedAt: video.updatedAt.toISOString(),
+      filename: video.filename,
+      fieldNameFs: video.fieldNameFs,
+      ext: video.ext,
+    };
+  }
 }

+ 1 - 1
prisma/mongo-stats/schema/user-login-history.prisma

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

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

@@ -1,17 +1,17 @@
 model Ads {
   id           String     @id @map("_id") @default(auto()) @db.ObjectId
-  adType       AdType                        // Redis key & module type
-  advertiser   String                        // 广告商 (业务上限制 max 20 字符)
-  title        String                        // 标题 (业务上限制 max 20 字符)
-  adsContent   String?                       // 广告文案 (业务上限制 max 500 字符)
-  adsCoverImg  String?                       // 广告图片
-  adsUrl       String?                       // 广告链接
+  adType       AdType     // Redis key & module type
+  advertiser   String     // 广告商 (业务上限制 max 20 字符)
+  title        String     // 标题 (业务上限制 max 20 字符)
+  adsContent   String?    // 广告文案 (业务上限制 max 500 字符)
+  adsCoverImg  String?    // 广告图片
+  adsUrl       String?    // 广告链接
 
-  imgSource    ImageSource @default(LOCAL_ONLY)
+  imgSource    ImageSource @default(PROVIDER)
 
   // 有效期,使用 BigInt epoch
-  startDt      BigInt                        // 开始时间
-  expiryDt     BigInt                        // 到期时间
+  startDt      BigInt     // 开始时间
+  expiryDt     BigInt     // 到期时间
 
   seq          Int        @default(0)        // 排序
   status       Int        @default(1)        // 状态 0: 禁用; 1: 启用