Sfoglia il codice sorgente

feat(cache): refactor CacheSyncService and CacheChecklistService to use PrismaAdType and delegate cache rebuilding to builder classes

Dave 4 mesi fa
parent
commit
41d14ac527

+ 5 - 20
apps/box-mgnt-api/src/cache-sync/cache-checklist.service.ts

@@ -2,8 +2,8 @@
 import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
 import { RedisService } from '@box/db/redis/redis.service';
 import { CacheKeys } from '@box/common/cache/cache-keys';
-import { ADTYPE_POOLS } from '@box/common/ads/ad-pool-config';
 import type { AdType } from '@box/common/ads/ad-types';
+import { AdType as PrismaAdType } from '@prisma/mongo/client';
 import { CacheSyncService } from './cache-sync.service';
 
 export interface CacheKeyCheckResult {
@@ -76,16 +76,6 @@ export class CacheChecklistService implements OnApplicationBootstrap {
         const json = await this.redis.getJson<unknown>(key);
         if (Array.isArray(json)) {
           items = json.length;
-        } else if (
-          key === CacheKeys.appTagAll &&
-          typeof json === 'object' &&
-          json !== null
-        ) {
-          // For appTagAll, payload is { tags: [...], schemaVersion: 1, updatedAt: number }
-          const obj = json as Record<string, unknown>;
-          if (Array.isArray(obj.tags)) {
-            items = obj.tags.length;
-          }
         }
       }
 
@@ -114,7 +104,8 @@ export class CacheChecklistService implements OnApplicationBootstrap {
       CacheKeys.appTagAll,
     ];
 
-    const adTypes = Object.keys(ADTYPE_POOLS) as AdType[];
+    // Add one ad pool key per AdType (no scene/slot - simplified to one pool per type)
+    const adTypes = Object.values(PrismaAdType) as AdType[];
     for (const adType of adTypes) {
       keys.push(CacheKeys.appAdPoolByType(adType));
     }
@@ -139,14 +130,8 @@ export class CacheChecklistService implements OnApplicationBootstrap {
       const parts = key.split(':');
       if (parts.length === 3) {
         const [, , adType] = parts;
-        const placements = ADTYPE_POOLS[adType as AdType] ?? [];
-        for (const { scene, slot } of placements) {
-          await this.cacheSync.rebuildAdPoolForPlacement(
-            adType as AdType,
-            scene,
-            slot,
-          );
-        }
+        // Delegate to builder for the specific ad type
+        await this.cacheSync.rebuildAdPoolForType(adType as AdType);
         return;
       }
     }

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

@@ -6,13 +6,12 @@ import { MysqlPrismaService } from '@box/db/prisma/mysql-prisma.service';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import { RedisService } from '@box/db/redis/redis.service';
 import { CacheKeys } from '@box/common/cache/cache-keys';
-import { ADTYPE_POOLS } from '@box/common/ads/ad-pool-config';
-import type {
-  AdType,
-  AdPoolEntry,
-  AdScene,
-  AdSlot,
-} from '@box/common/ads/ad-types';
+import type { AdType } from '@box/common/ads/ad-types';
+import { AdType as PrismaAdType } from '@prisma/mongo/client';
+import { CategoryCacheBuilder } from '@box/core/cache/category/category-cache.builder';
+import { TagCacheBuilder } from '@box/core/cache/tag/tag-cache.builder';
+import { ChannelCacheBuilder } from '@box/core/cache/channel/channel-cache.builder';
+import { AdPoolService } from '@box/core/ad/ad-pool.service';
 
 import {
   CacheEntityType,
@@ -22,17 +21,14 @@ import {
 } from './cache-sync.types';
 
 // Cache TTL (seconds)
-const CHANNEL_CACHE_TTL = 900; // 15 min
-const CATEGORY_CACHE_TTL = 900; // 15 min
-const TAG_CACHE_TTL = 900; // 15 min
 const AD_CACHE_TTL = 300; // 5 min (more dynamic)
-const AD_POOL_TTL = 300; // 5 min
 
 /**
- * CacheSyncService
- *  - Writes durable CacheSyncAction records in MySQL.
- *  - Rebuilds Redis caches for channels/categories/ads/pools consumed by app-api.
+ * CacheSyncService - Refactored as orchestration wrapper
+ *  - Thin wrapper that delegates to builder classes.
+ *  - Writes durable CacheSyncAction records in MySQL for audit trail and retry logic.
  *  - Retries transient failures with backoff using attempts + nextAttemptAt.
+ *  - ALL Mongo-to-Redis logic is delegated to builders.
  */
 @Injectable()
 export class CacheSyncService {
@@ -59,6 +55,11 @@ export class CacheSyncService {
     private readonly mongoPrisma: MongoPrismaService,
     // Redis: cache store consumed by box-app-api
     private readonly redis: RedisService,
+    // Cache builders - delegated builders for actual Mongo-to-Redis logic
+    private readonly channelCacheBuilder: ChannelCacheBuilder,
+    private readonly categoryCacheBuilder: CategoryCacheBuilder,
+    private readonly tagCacheBuilder: TagCacheBuilder,
+    private readonly adPoolService: AdPoolService,
   ) {}
 
   // Utility to get "now" as BigInt epoch millis
@@ -382,7 +383,8 @@ export class CacheSyncService {
         const payload = action.payload as CachePayload | null;
         const channelId = (payload as any)?.channelId as string | undefined;
         if (channelId) {
-          await this.rebuildChannelWithCategories(channelId);
+          // Delegate to builder - rebuilds all channels
+          await this.rebuildChannelsAll();
         } else {
           this.logger.warn(
             `handleChannelAction REFRESH: missing channelId for action id=${action.id}`,
@@ -416,53 +418,6 @@ export class CacheSyncService {
     }
   }
 
-  // Made public so checklist service can invoke directly when a key is missing.
-  async rebuildChannelsAll(): Promise<void> {
-    try {
-      const channels = await this.mongoPrisma.channel.findMany({
-        where: {
-          // isDeleted: false,
-        },
-        orderBy: {
-          id: 'asc',
-        },
-      });
-
-      const sanitized = channels.map((c) => ({
-        id: c.id,
-        name: c.name,
-        landingUrl: c.landingUrl,
-        videoCdn: c.videoCdn ?? null,
-        coverCdn: c.coverCdn ?? null,
-        clientName: c.clientName ?? null,
-        clientNotice: c.clientNotice ?? null,
-        remark: c.remark ?? null,
-        createAt:
-          typeof c.createAt === 'bigint'
-            ? Number(c.createAt)
-            : (c as any).createAt,
-        updateAt:
-          typeof c.updateAt === 'bigint'
-            ? Number(c.updateAt)
-            : (c as any).updateAt,
-      }));
-
-      const start = Date.now();
-      await this.redis.setJson(
-        CacheKeys.appChannelAll,
-        sanitized,
-        CHANNEL_CACHE_TTL,
-      );
-
-      this.logger.log(
-        `Rebuilt ${CacheKeys.appChannelAll} with ${channels.length} item(s), ${Date.now() - start}ms`,
-      );
-    } catch (err) {
-      this.logger.error('Failed to rebuild channels:all cache', err);
-      throw err; // Re-throw to trigger retry mechanism
-    }
-  }
-
   // ─────────────────────────────────────────────
   // CATEGORIES
   // ─────────────────────────────────────────────
@@ -476,14 +431,8 @@ export class CacheSyncService {
         const payload = action.payload as CachePayload | null;
         const categoryId = (payload as any)?.categoryId as string | undefined;
         if (categoryId) {
-          await this.rebuildCategoryWithTags(categoryId);
-          // Load category to get channelId for channel-with-categories rebuild
-          const category = await this.mongoPrisma.category.findUnique({
-            where: { id: categoryId },
-          });
-          if (category) {
-            await this.rebuildChannelWithCategories(category.channelId);
-          }
+          // Delegate to builder - rebuilds all categories
+          await this.rebuildCategoriesAll();
         } else {
           this.logger.warn(
             `handleCategoryAction REFRESH: missing categoryId for action id=${action.id}`,
@@ -517,52 +466,6 @@ export class CacheSyncService {
     }
   }
 
-  // Made public so checklist service can invoke directly when a key is missing.
-  async rebuildCategoriesAll(): Promise<void> {
-    try {
-      const categories = await this.mongoPrisma.category.findMany({
-        where: {
-          // isDeleted: false,
-          status: 1, // only active categories
-        },
-        orderBy: {
-          seq: 'asc',
-        },
-      });
-
-      const sanitized = categories.map((c) => ({
-        id: c.id,
-        name: c.name,
-        subtitle: c.subtitle ?? null,
-        channelId: c.channelId,
-        seq: c.seq,
-        status: c.status,
-        createAt:
-          typeof c.createAt === 'bigint'
-            ? Number(c.createAt)
-            : (c as any).createAt,
-        updateAt:
-          typeof c.updateAt === 'bigint'
-            ? Number(c.updateAt)
-            : (c as any).updateAt,
-      }));
-
-      const start = Date.now();
-      await this.redis.setJson(
-        CacheKeys.appCategoryAll,
-        sanitized,
-        CATEGORY_CACHE_TTL,
-      );
-
-      this.logger.log(
-        `Rebuilt ${CacheKeys.appCategoryAll} with ${categories.length} item(s), ${Date.now() - start}ms`,
-      );
-    } catch (err) {
-      this.logger.error('Failed to rebuild categories:all cache', err);
-      throw err; // Re-throw to trigger retry mechanism
-    }
-  }
-
   // ─────────────────────────────────────────────
   // ADS (placeholders for now)
   // ─────────────────────────────────────────────
@@ -698,24 +601,14 @@ export class CacheSyncService {
       return;
     }
 
-    const placements = ADTYPE_POOLS[adType] ?? [];
-
-    if (!placements || placements.length === 0) {
-      this.logger.warn(
-        `handleAdPoolAction: no placements mapping found for adType=${adType}, action id=${action.id}`,
-      );
-      return;
-    }
-
     switch (action.operation as CacheOperation) {
       case CacheOperation.INVALIDATE: {
         try {
           // remove all pools for this adType
-          // Pattern: app:adpool:*:*:<adType>
-          const pattern = `app:adpool:*:*:${adType}`;
-          const deleted = await this.redis.deleteByPattern(pattern);
+          const key = CacheKeys.appAdPoolByType(adType);
+          await this.redis.del(key);
           this.logger.log(
-            `Invalidated ${deleted} pool key(s) for adType=${adType} using pattern=${pattern}`,
+            `Invalidated ad pool key=${key} for adType=${adType}`,
           );
         } catch (err) {
           this.logger.error(
@@ -729,71 +622,15 @@ export class CacheSyncService {
       case CacheOperation.REBUILD_POOL:
       default: {
         this.logger.log(
-          `handleAdPoolAction: rebuilding ${placements.length} pool(s) for adType=${adType}, action id=${action.id}`,
+          `handleAdPoolAction: rebuilding ad pool for adType=${adType}, action id=${action.id}`,
         );
-        for (const placement of placements) {
-          await this.rebuildAdPoolForPlacement(
-            adType,
-            placement.scene,
-            placement.slot,
-          );
-        }
+        // Delegate to builder
+        await this.rebuildAdPoolForType(adType);
         break;
       }
     }
   }
 
-  /**
-   * Rebuild a single ad pool for a placement (scene + slot).
-   * Reads active ads for the adType and atomically swaps the cache key to avoid
-   * partially-written pools being read by app-api.
-   */
-  // Made public so checklist service can invoke targeted pool rebuild.
-  async rebuildAdPoolForPlacement(
-    adType: AdType,
-    scene: AdScene,
-    slot: AdSlot,
-  ): Promise<void> {
-    try {
-      const now = this.nowBigInt();
-
-      const ads = await this.mongoPrisma.ads.findMany({
-        where: {
-          status: 1,
-          startDt: { lte: now },
-          OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
-          adsModule: {
-            is: { adType },
-          },
-        },
-        orderBy: { seq: 'asc' },
-      });
-
-      const poolEntries: AdPoolEntry[] = ads.map((ad) => ({
-        id: ad.id,
-        weight: 1,
-      }));
-
-      const key = CacheKeys.appAdPoolByType(adType);
-
-      // Atomic swap to avoid partial-read windows
-      const start = Date.now();
-      await this.redis.atomicSwapJson([
-        { key, value: poolEntries, ttlSeconds: AD_POOL_TTL },
-      ]);
-
-      this.logger.log(
-        `Rebuilt ad pool ${key} with ${poolEntries.length} ad(s) for adType=${adType}, scene=${scene}, slot=${slot}, ${Date.now() - start}ms`,
-      );
-    } catch (err) {
-      this.logger.error(
-        `Failed to rebuild ad pool for adType=${adType}, scene=${scene}, slot=${slot}`,
-        err,
-      );
-      throw err;
-    }
-  }
-
   // ─────────────────────────────────────────────
   // VIDEO LISTS (placeholder)
   // ─────────────────────────────────────────────
@@ -819,7 +656,8 @@ export class CacheSyncService {
         const payload = action.payload as CachePayload | null;
         const categoryId = action.entityId || (payload as any)?.categoryId;
         if (categoryId && categoryId !== 'null') {
-          await this.rebuildCategoryWithTags(categoryId);
+          // Delegate to builder - rebuilds all tags
+          await this.rebuildTagAll();
         } else {
           this.logger.warn(
             `handleTagAction REFRESH: missing categoryId for action id=${action.id}`,
@@ -857,240 +695,88 @@ export class CacheSyncService {
     }
   }
 
+  // ─────────────────────────────────────────────
+  // Cache warming
+  // ─────────────────────────────────────────────
+
   /**
-   * Rebuild channel with its categories tree for a specific channel.
+   * Pre-warm critical cache keys on startup or on-demand.
+   * Call this from app startup or via admin endpoint.
+   * Delegates to all cache builders and ad pool service.
    */
-  async rebuildChannelWithCategories(channelId: string): Promise<void> {
-    const cacheKey = CacheKeys.appChannelWithCategories(channelId);
+  async warmCache(): Promise<void> {
+    this.logger.log('Cache warming started');
+    const start = Date.now();
 
     try {
-      const channel = await this.mongoPrisma.channel.findUnique({
-        where: { id: channelId },
-      });
+      // Rebuild channels
+      await this.rebuildChannelsAll();
 
-      if (!channel) {
-        try {
-          await this.redis.del(cacheKey);
-          this.logger.warn(
-            `rebuildChannelWithCategories: channel not found, removed cache key=${cacheKey}`,
-          );
-        } catch (redisErr) {
-          this.logger.error(`Failed to delete Redis key ${cacheKey}`, redisErr);
-        }
-        return;
-      }
+      // Rebuild categories
+      await this.rebuildCategoriesAll();
 
-      const categories = await this.mongoPrisma.category.findMany({
-        where: { channelId: channel.id, status: 1 },
-        orderBy: [{ seq: 'asc' }, { name: 'asc' }],
-      });
+      // Rebuild tags
+      await this.rebuildTagAll();
 
-      const channelLite = {
-        id: channel.id,
-        name: channel.name,
-        landingUrl: channel.landingUrl,
-        videoCdn: channel.videoCdn ?? null,
-        coverCdn: channel.coverCdn ?? null,
-        clientName: channel.clientName ?? null,
-        clientNotice: channel.clientNotice ?? null,
-        createAt:
-          typeof channel.createAt === 'bigint'
-            ? Number(channel.createAt)
-            : (channel as any).createAt,
-        updateAt:
-          typeof channel.updateAt === 'bigint'
-            ? Number(channel.updateAt)
-            : (channel as any).updateAt,
-      };
-
-      const categoryLites = categories.map((c) => ({
-        id: c.id,
-        name: c.name,
-        subtitle: c.subtitle ?? null,
-        channelId: c.channelId,
-        seq: c.seq,
-        status: c.status,
-        createAt:
-          typeof c.createAt === 'bigint'
-            ? Number(c.createAt)
-            : (c as any).createAt,
-        updateAt:
-          typeof c.updateAt === 'bigint'
-            ? Number(c.updateAt)
-            : (c as any).updateAt,
-      }));
-
-      const payload = {
-        channel: channelLite,
-        categories: categoryLites,
-        schemaVersion: 1,
-        updatedAt: Date.now(),
-      };
-
-      const start = Date.now();
-      await this.redis.setJson(cacheKey, payload, CATEGORY_CACHE_TTL);
+      // Rebuild all ad pools
+      await this.rebuildAllAdPools();
 
-      this.logger.log(
-        `Rebuilt ${cacheKey} with ${categories.length} category(ies), ${Date.now() - start}ms`,
-      );
+      this.logger.log(`Cache warming complete in ${Date.now() - start}ms`);
     } catch (err) {
       this.logger.error(
-        `Failed to rebuild channel with categories for channelId=${channelId}`,
-        err,
+        `Cache warming failed: ${err instanceof Error ? err.message : String(err)}`,
       );
-      throw err; // Re-throw to trigger retry mechanism
+      throw err;
     }
   }
 
   /**
-   * Rebuild category with its tags tree for a specific category.
+   * Public method to rebuild channels cache.
+   * Delegates to ChannelCacheBuilder.buildAll().
    */
-  async rebuildCategoryWithTags(categoryId: string): Promise<void> {
-    // Validate categoryId to prevent 'null' string or invalid ObjectID
-    if (!categoryId || categoryId === 'null' || categoryId === 'undefined') {
-      this.logger.warn(
-        `rebuildCategoryWithTags: invalid categoryId="${categoryId}"`,
-      );
-      return;
-    }
-
-    const cacheKey = CacheKeys.appCategoryWithTags(categoryId);
-
-    try {
-      const category = await this.mongoPrisma.category.findUnique({
-        where: { id: categoryId },
-      });
-
-      if (!category) {
-        try {
-          await this.redis.del(cacheKey);
-          this.logger.warn(
-            `rebuildCategoryWithTags: category not found, removed cache key=${cacheKey}`,
-          );
-        } catch (redisErr) {
-          this.logger.error(`Failed to delete Redis key ${cacheKey}`, redisErr);
-        }
-        return;
-      }
-
-      const tags = await this.mongoPrisma.tag.findMany({
-        where: { categoryId, status: 1 },
-        orderBy: [{ seq: 'asc' }, { name: 'asc' }],
-      });
-
-      const payload = {
-        category: {
-          id: category.id,
-          name: category.name,
-          subtitle: category.subtitle ?? null,
-          channelId: category.channelId,
-          seq: category.seq,
-          status: category.status,
-          createAt:
-            typeof category.createAt === 'bigint'
-              ? Number(category.createAt)
-              : (category as any).createAt,
-          updateAt:
-            typeof category.updateAt === 'bigint'
-              ? Number(category.updateAt)
-              : (category as any).updateAt,
-        },
-        tags: tags.map((t) => ({
-          id: t.id,
-          name: t.name,
-          channelId: t.channelId,
-          categoryId: t.categoryId,
-          seq: t.seq,
-          status: t.status,
-          createAt:
-            typeof t.createAt === 'bigint'
-              ? Number(t.createAt)
-              : (t as any).createAt,
-          updateAt:
-            typeof t.updateAt === 'bigint'
-              ? Number(t.updateAt)
-              : (t as any).updateAt,
-        })),
-        schemaVersion: 1,
-        updatedAt: Date.now(),
-      };
-
-      const start = Date.now();
-      await this.redis.setJson(cacheKey, payload, TAG_CACHE_TTL);
+  async rebuildChannelsAll(): Promise<void> {
+    await this.channelCacheBuilder.buildAll();
+  }
 
-      this.logger.log(
-        `Rebuilt ${cacheKey} with category and ${tags.length} tag(s), ${Date.now() - start}ms`,
-      );
-    } catch (err) {
-      this.logger.error(
-        `Failed to rebuild category with tags for categoryId=${categoryId}`,
-        err,
-      );
-      throw err; // Re-throw to trigger retry mechanism
-    }
+  /**
+   * Public method to rebuild categories cache.
+   * Delegates to CategoryCacheBuilder.buildAll().
+   */
+  async rebuildCategoriesAll(): Promise<void> {
+    await this.categoryCacheBuilder.buildAll();
   }
 
   /**
-   * Rebuild global tag:all suggestion pool.
+   * Public method to rebuild tags cache.
+   * Delegates to TagCacheBuilder.buildAll().
    */
   async rebuildTagAll(): Promise<void> {
-    try {
-      const tags = await this.mongoPrisma.tag.findMany({
-        where: { status: 1 },
-        orderBy: [{ name: 'asc' }],
-      });
-
-      const payload = {
-        tags: tags.map((t) => ({
-          id: t.id,
-          name: t.name,
-          channelId: t.channelId,
-          categoryId: t.categoryId,
-        })),
-        schemaVersion: 1,
-        updatedAt: Date.now(),
-      };
-
-      const start = Date.now();
-      await this.redis.setJson(CacheKeys.appTagAll, payload, TAG_CACHE_TTL);
-
-      this.logger.log(
-        `Rebuilt ${CacheKeys.appTagAll} with ${tags.length} tag(s), ${Date.now() - start}ms`,
-      );
-    } catch (err) {
-      this.logger.error('Failed to rebuild tag:all cache', err);
-      throw err; // Re-throw to trigger retry mechanism
-    }
+    await this.tagCacheBuilder.buildAll();
   }
 
-  // ─────────────────────────────────────────────
-  // Cache warming
-  // ─────────────────────────────────────────────
-
   /**
-   * Pre-warm critical cache keys on startup or on-demand.
-   * Call this from app startup or via admin endpoint.
+   * Public method to rebuild a single ad pool for an AdType.
+   * Delegates to AdPoolService.rebuildPoolForType().
    */
-  async warmCache(): Promise<void> {
-    this.logger.log('Cache warming started');
-    const start = Date.now();
-
-    await Promise.all([
-      this.rebuildChannelsAll(),
-      this.rebuildCategoriesAll(),
-      this.rebuildTagAll(),
-      this.warmAdPools(),
-    ]);
-
-    this.logger.log(`Cache warming complete in ${Date.now() - start}ms`);
+  async rebuildAdPoolForType(adType: AdType): Promise<void> {
+    await this.adPoolService.rebuildPoolForType(adType);
   }
 
-  private async warmAdPools(): Promise<void> {
-    const allAdTypes = Object.keys(ADTYPE_POOLS) as AdType[];
+  /**
+   * Private helper to rebuild all ad pools.
+   * Iterates through all AdTypes and delegates to AdPoolService.
+   */
+  private async rebuildAllAdPools(): Promise<void> {
+    const allAdTypes = Object.values(PrismaAdType) as AdType[];
     for (const adType of allAdTypes) {
-      const placements = ADTYPE_POOLS[adType];
-      for (const { scene, slot } of placements) {
-        await this.rebuildAdPoolForPlacement(adType, scene, slot);
+      try {
+        await this.rebuildAdPoolForType(adType);
+      } catch (err) {
+        this.logger.error(
+          `Failed to rebuild ad pool for adType=${adType}`,
+          err instanceof Error ? err.stack : String(err),
+        );
+        // Continue with other ad types even if one fails
       }
     }
   }