Просмотр исходного кода

feat(cache): enhance cache sync for categories and tags

- Added new CacheEntityType for TAG to support category-with-tags caching.
- Updated CachePayload to include optional categoryId and tagId.
- Implemented duplicate checks for category and tag creation to prevent conflicts.
- Enhanced CategoryService to schedule cache refresh for TAG upon category creation, update, and deletion.
- Updated ChannelService to schedule cache refresh for CHANNEL upon creation and update.
- Integrated CacheSyncService in TagService to manage cache for tag operations.
- Refactored cache keys in cache-keys.ts for better organization and future use.
Dave 4 месяцев назад
Родитель
Сommit
99701729bb

+ 23 - 2
apps/box-mgnt-api/src/cache-sync/cache-checklist.service.ts

@@ -1,3 +1,4 @@
+// apps/box-mgnt-api/src/cache-sync/cache-checklist.service.ts
 import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
 import { RedisService } from '@box/db/redis/redis.service';
 import { CacheKeys } from '@box/common/cache/cache-keys';
@@ -73,7 +74,19 @@ export class CacheChecklistService implements OnApplicationBootstrap {
 
       if (exists) {
         const json = await this.redis.getJson<unknown>(key);
-        if (Array.isArray(json)) items = json.length;
+        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;
+          }
+        }
       }
 
       results.push({ key, exists, rebuilt, items, error });
@@ -95,7 +108,11 @@ export class CacheChecklistService implements OnApplicationBootstrap {
   }
 
   private computeRequiredKeys(): string[] {
-    const keys: string[] = [CacheKeys.appChannelAll, CacheKeys.appCategoryAll];
+    const keys: string[] = [
+      CacheKeys.appChannelAll,
+      CacheKeys.appCategoryAll,
+      CacheKeys.appTagAll,
+    ];
 
     const adTypes = Object.keys(ADTYPE_POOLS) as AdType[];
     for (const adType of adTypes) {
@@ -116,6 +133,10 @@ export class CacheChecklistService implements OnApplicationBootstrap {
       await this.cacheSync.rebuildCategoriesAll();
       return;
     }
+    if (key === CacheKeys.appTagAll) {
+      await this.cacheSync.rebuildTagAll();
+      return;
+    }
     if (key.startsWith('app:adpool:')) {
       // key format: app:adpool:<scene>:<slot>:<adType>
       const parts = key.split(':');

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

@@ -24,6 +24,7 @@ import {
 // 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
 
@@ -38,6 +39,7 @@ export class CacheSyncService {
   private readonly logger = new Logger(CacheSyncService.name);
   private readonly maxAttempts = 5;
   private readonly baseBackoffMs = 5000; // initial retry delay
+  private readonly MAX_LAST_ERROR_LENGTH = 500; // Matches DB VARCHAR(500)
 
   private readonly actionHandlers: Partial<
     Record<CacheEntityType, (action: CacheSyncAction) => Promise<void>>
@@ -47,6 +49,7 @@ export class CacheSyncService {
     [CacheEntityType.AD]: this.handleAdAction.bind(this),
     [CacheEntityType.AD_POOL]: this.handleAdPoolAction.bind(this),
     [CacheEntityType.VIDEO_LIST]: this.handleVideoListAction.bind(this),
+    [CacheEntityType.TAG]: this.handleTagAction.bind(this),
   };
 
   constructor(
@@ -64,6 +67,39 @@ export class CacheSyncService {
   }
 
   /**
+   * Build a safe error string that fits within database column constraints.
+   * Truncates if necessary to prevent P2000 errors.
+   */
+  private buildLastErrorString(
+    err: unknown,
+    maxLength = this.MAX_LAST_ERROR_LENGTH,
+  ): string {
+    let base = '';
+
+    if (err instanceof Error) {
+      base = `${err.name}: ${err.message}`;
+      if (err.stack) {
+        base += `\n${err.stack}`;
+      }
+    } else if (typeof err === 'string') {
+      base = err;
+    } else {
+      // Fallback stringify
+      try {
+        base = JSON.stringify(err);
+      } catch {
+        base = String(err);
+      }
+    }
+
+    if (base.length > maxLength) {
+      return base.slice(0, maxLength - 20) + '... [truncated]';
+    }
+
+    return base;
+  }
+
+  /**
    * Enqueue a cache-sync action with optional initial delay.
    * Downstream processing relies on attempts/nextAttemptAt for retries.
    */
@@ -220,19 +256,63 @@ export class CacheSyncService {
         const updateTime = this.nowBigInt();
         const nextAttemptAt = updateTime + BigInt(backoffMs);
 
-        await this.mysqlPrisma.cacheSyncAction.update({
-          where: { id: action.id },
-          data: {
-            status:
-              attempts >= this.maxAttempts
-                ? CacheStatus.GAVE_UP
-                : CacheStatus.PENDING,
-            attempts,
-            lastError: message,
-            nextAttemptAt,
-            updatedAt: updateTime,
-          },
-        });
+        const lastError = this.buildLastErrorString(err);
+
+        try {
+          await this.mysqlPrisma.cacheSyncAction.update({
+            where: { id: action.id },
+            data: {
+              status:
+                attempts >= this.maxAttempts
+                  ? CacheStatus.GAVE_UP
+                  : CacheStatus.PENDING,
+              attempts,
+              lastError,
+              nextAttemptAt,
+              updatedAt: updateTime,
+            },
+          });
+        } catch (updateErr) {
+          // Handle "value too long" for lastError (P2000) gracefully
+          if (
+            updateErr instanceof MysqlPrisma.PrismaClientKnownRequestError &&
+            updateErr.code === 'P2000' &&
+            updateErr.meta?.column_name === 'lastError'
+          ) {
+            // Try again with a minimal error message
+            const minimalError = (
+              err instanceof Error ? err.message : String(err)
+            ).slice(0, 200);
+
+            try {
+              await this.mysqlPrisma.cacheSyncAction.update({
+                where: { id: action.id },
+                data: {
+                  status:
+                    attempts >= this.maxAttempts
+                      ? CacheStatus.GAVE_UP
+                      : CacheStatus.PENDING,
+                  attempts,
+                  lastError: minimalError,
+                  nextAttemptAt,
+                  updatedAt: updateTime,
+                },
+              });
+            } catch (secondErr) {
+              // At this point we only log – we don't want our scheduler to die
+              this.logger.error(
+                `Failed to persist minimal lastError for CacheSyncAction id=${action.id}`,
+                { originalError: err, updateErr, secondErr },
+              );
+            }
+          } else {
+            // Log but don't rethrow - keep processing other actions
+            this.logger.error(
+              `Failed to update CacheSyncAction id=${action.id} after error`,
+              { originalError: err, updateErr },
+            );
+          }
+        }
 
         if (attempts >= this.maxAttempts) {
           this.logger.warn(
@@ -298,17 +378,34 @@ export class CacheSyncService {
       case CacheOperation.REFRESH_ALL:
         await this.rebuildChannelsAll();
         break;
+      case CacheOperation.REFRESH: {
+        const payload = action.payload as CachePayload | null;
+        const channelId = (payload as any)?.channelId as string | undefined;
+        if (channelId) {
+          await this.rebuildChannelWithCategories(channelId);
+        } else {
+          this.logger.warn(
+            `handleChannelAction REFRESH: missing channelId for action id=${action.id}`,
+          );
+        }
+        break;
+      }
       case CacheOperation.INVALIDATE: {
         const payload = action.payload as CachePayload | null;
         const id = (payload as any)?.id as string | number | undefined;
-        if (id != null) {
-          await this.redis.del(CacheKeys.appChannelById(id));
-          this.logger.log(
-            `Invalidated channel by id key=${CacheKeys.appChannelById(id)}`,
-          );
-        } else {
-          await this.redis.del(CacheKeys.appChannelAll);
-          this.logger.log(`Invalidated ${CacheKeys.appChannelAll}`);
+        try {
+          if (id != null) {
+            await this.redis.del(CacheKeys.appChannelById(id));
+            this.logger.log(
+              `Invalidated channel by id key=${CacheKeys.appChannelById(id)}`,
+            );
+          } else {
+            await this.redis.del(CacheKeys.appChannelAll);
+            this.logger.log(`Invalidated ${CacheKeys.appChannelAll}`);
+          }
+        } catch (err) {
+          this.logger.error('Failed to invalidate channel cache', err);
+          throw err;
         }
         break;
       }
@@ -321,44 +418,49 @@ export class CacheSyncService {
 
   // Made public so checklist service can invoke directly when a key is missing.
   async rebuildChannelsAll(): Promise<void> {
-    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,
-    }));
+    try {
+      const channels = await this.mongoPrisma.channel.findMany({
+        where: {
+          // isDeleted: false,
+        },
+        orderBy: {
+          id: 'asc',
+        },
+      });
 
-    const start = Date.now();
-    await this.redis.setJson(
-      CacheKeys.appChannelAll,
-      sanitized,
-      CHANNEL_CACHE_TTL,
-    );
+      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`,
-    );
+      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
+    }
   }
 
   // ─────────────────────────────────────────────
@@ -370,17 +472,41 @@ export class CacheSyncService {
       case CacheOperation.REFRESH_ALL:
         await this.rebuildCategoriesAll();
         break;
+      case CacheOperation.REFRESH: {
+        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);
+          }
+        } else {
+          this.logger.warn(
+            `handleCategoryAction REFRESH: missing categoryId for action id=${action.id}`,
+          );
+        }
+        break;
+      }
       case CacheOperation.INVALIDATE: {
         const payload = action.payload as CachePayload | null;
         const id = (payload as any)?.id as string | number | undefined;
-        if (id != null) {
-          await this.redis.del(CacheKeys.appCategoryById(id));
-          this.logger.log(
-            `Invalidated category by id key=${CacheKeys.appCategoryById(id)}`,
-          );
-        } else {
-          await this.redis.del(CacheKeys.appCategoryAll);
-          this.logger.log(`Invalidated ${CacheKeys.appCategoryAll}`);
+        try {
+          if (id != null) {
+            await this.redis.del(CacheKeys.appCategoryById(id));
+            this.logger.log(
+              `Invalidated category by id key=${CacheKeys.appCategoryById(id)}`,
+            );
+          } else {
+            await this.redis.del(CacheKeys.appCategoryAll);
+            this.logger.log(`Invalidated ${CacheKeys.appCategoryAll}`);
+          }
+        } catch (err) {
+          this.logger.error('Failed to invalidate category cache', err);
+          throw err;
         }
         break;
       }
@@ -393,43 +519,48 @@ export class CacheSyncService {
 
   // Made public so checklist service can invoke directly when a key is missing.
   async rebuildCategoriesAll(): Promise<void> {
-    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,
-    }));
+    try {
+      const categories = await this.mongoPrisma.category.findMany({
+        where: {
+          // isDeleted: false,
+          status: 1, // only active categories
+        },
+        orderBy: {
+          seq: 'asc',
+        },
+      });
 
-    const start = Date.now();
-    await this.redis.setJson(
-      CacheKeys.appCategoryAll,
-      sanitized,
-      CATEGORY_CACHE_TTL,
-    );
+      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`,
-    );
+      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
+    }
   }
 
   // ─────────────────────────────────────────────
@@ -451,9 +582,17 @@ export class CacheSyncService {
 
     switch (action.operation as CacheOperation) {
       case CacheOperation.INVALIDATE: {
-        const key = CacheKeys.appAdById(adId);
-        await this.redis.del(key);
-        this.logger.log(`Invalidated per-ad cache key=${key}`);
+        try {
+          const key = CacheKeys.appAdById(adId);
+          await this.redis.del(key);
+          this.logger.log(`Invalidated per-ad cache key=${key}`);
+        } catch (err) {
+          this.logger.error(
+            `Failed to invalidate ad cache for adId=${adId}`,
+            err,
+          );
+          throw err;
+        }
         break;
       }
       case CacheOperation.REFRESH:
@@ -486,10 +625,17 @@ export class CacheSyncService {
 
     if (!ad) {
       // Ad no longer exists → ensure cache is cleared
-      await this.redis.del(cacheKey);
-      this.logger.log(
-        `rebuildSingleAdCache: ad not found, removed cache key=${cacheKey}`,
-      );
+      try {
+        await this.redis.del(cacheKey);
+        this.logger.log(
+          `rebuildSingleAdCache: ad not found, removed cache key=${cacheKey}`,
+        );
+      } catch (err) {
+        this.logger.error(
+          `Failed to delete Redis key ${cacheKey} for missing ad`,
+          err,
+        );
+      }
       return;
     }
 
@@ -503,10 +649,17 @@ export class CacheSyncService {
       (ad.expiryDt === BigInt(0) || ad.expiryDt >= now);
 
     if (!isActive) {
-      await this.redis.del(cacheKey);
-      this.logger.log(
-        `rebuildSingleAdCache: adId=${adId} is not active (status/time window), removed cache key=${cacheKey}`,
-      );
+      try {
+        await this.redis.del(cacheKey);
+        this.logger.log(
+          `rebuildSingleAdCache: adId=${adId} is not active (status/time window), removed cache key=${cacheKey}`,
+        );
+      } catch (err) {
+        this.logger.error(
+          `Failed to delete Redis key ${cacheKey} for inactive ad`,
+          err,
+        );
+      }
       return;
     }
 
@@ -523,11 +676,15 @@ export class CacheSyncService {
       adType: ad.adsModule?.adType ?? adType ?? null,
     };
 
-    await this.redis.setJson(cacheKey, cachedAd, AD_CACHE_TTL);
-
-    this.logger.log(
-      `rebuildSingleAdCache: updated per-ad cache for adId=${adId}, key=${cacheKey}`,
-    );
+    try {
+      await this.redis.setJson(cacheKey, cachedAd, AD_CACHE_TTL);
+      this.logger.log(
+        `rebuildSingleAdCache: updated per-ad cache for adId=${adId}, key=${cacheKey}`,
+      );
+    } catch (err) {
+      this.logger.error(`Failed to set Redis cache for ad adId=${adId}`, err);
+      throw err;
+    }
   }
 
   private async handleAdPoolAction(action: CacheSyncAction): Promise<void> {
@@ -552,12 +709,21 @@ export class CacheSyncService {
 
     switch (action.operation as CacheOperation) {
       case CacheOperation.INVALIDATE: {
-        // remove all pools for this adType
-        const pattern = `app:adpool:*:*:${adType}`;
-        const deleted = await this.redis.deleteByPattern(pattern);
-        this.logger.log(
-          `Invalidated ${deleted} pool key(s) for adType=${adType} using pattern=${pattern}`,
-        );
+        try {
+          // remove all pools for this adType
+          // Pattern: app:adpool:*:*:<adType>
+          const pattern = `app:adpool:*:*:${adType}`;
+          const deleted = await this.redis.deleteByPattern(pattern);
+          this.logger.log(
+            `Invalidated ${deleted} pool key(s) for adType=${adType} using pattern=${pattern}`,
+          );
+        } catch (err) {
+          this.logger.error(
+            `Failed to invalidate ad pools for adType=${adType}`,
+            err,
+          );
+          throw err;
+        }
         break;
       }
       case CacheOperation.REBUILD_POOL:
@@ -588,36 +754,44 @@ export class CacheSyncService {
     scene: AdScene,
     slot: AdSlot,
   ): Promise<void> {
-    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 },
+    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' },
-    });
+        orderBy: { seq: 'asc' },
+      });
 
-    const poolEntries: AdPoolEntry[] = ads.map((ad) => ({
-      id: ad.id,
-      weight: 1,
-    }));
+      const poolEntries: AdPoolEntry[] = ads.map((ad) => ({
+        id: ad.id,
+        weight: 1,
+      }));
 
-    const key = CacheKeys.appAdPool(scene, slot, adType);
+      const key = CacheKeys.appAdPool(scene, slot, adType);
 
-    // Atomic swap to avoid partial-read windows
-    const start = Date.now();
-    await this.redis.atomicSwapJson([
-      { key, value: poolEntries, ttlSeconds: AD_POOL_TTL },
-    ]);
+      // 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`,
-    );
+      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;
+    }
   }
 
   // ─────────────────────────────────────────────
@@ -633,6 +807,263 @@ export class CacheSyncService {
   }
 
   // ─────────────────────────────────────────────
+  // TAGS
+  // ─────────────────────────────────────────────
+
+  private async handleTagAction(action: CacheSyncAction): Promise<void> {
+    switch (action.operation as CacheOperation) {
+      case CacheOperation.REFRESH_ALL:
+        await this.rebuildTagAll();
+        break;
+      case CacheOperation.REFRESH: {
+        const payload = action.payload as CachePayload | null;
+        const categoryId = action.entityId || (payload as any)?.categoryId;
+        if (categoryId && categoryId !== 'null') {
+          await this.rebuildCategoryWithTags(categoryId);
+        } else {
+          this.logger.warn(
+            `handleTagAction REFRESH: missing categoryId for action id=${action.id}`,
+          );
+        }
+        break;
+      }
+      case CacheOperation.INVALIDATE: {
+        const payload = action.payload as CachePayload | null;
+        const categoryId = action.entityId || (payload as any)?.categoryId;
+        if (categoryId && categoryId !== 'null') {
+          try {
+            await this.redis.del(CacheKeys.appCategoryWithTags(categoryId));
+            this.logger.log(
+              `Invalidated category with tags key=${CacheKeys.appCategoryWithTags(categoryId)}`,
+            );
+          } catch (err) {
+            this.logger.error(
+              `Failed to invalidate category with tags cache for categoryId=${categoryId}`,
+              err,
+            );
+            throw err;
+          }
+        } else {
+          this.logger.warn(
+            `handleTagAction INVALIDATE: missing categoryId for action id=${action.id}`,
+          );
+        }
+        break;
+      }
+      default:
+        this.logger.warn(
+          `Unsupported TAG operation for action id=${action.id}: ${action.operation}`,
+        );
+    }
+  }
+
+  /**
+   * Rebuild channel with its categories tree for a specific channel.
+   */
+  async rebuildChannelWithCategories(channelId: string): Promise<void> {
+    const cacheKey = CacheKeys.appChannelWithCategories(channelId);
+
+    try {
+      const channel = await this.mongoPrisma.channel.findUnique({
+        where: { id: channelId },
+      });
+
+      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;
+      }
+
+      const categories = await this.mongoPrisma.category.findMany({
+        where: { channelId: channel.id, status: 1 },
+        orderBy: [{ seq: 'asc' }, { name: 'asc' }],
+      });
+
+      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);
+
+      this.logger.log(
+        `Rebuilt ${cacheKey} with ${categories.length} category(ies), ${Date.now() - start}ms`,
+      );
+    } catch (err) {
+      this.logger.error(
+        `Failed to rebuild channel with categories for channelId=${channelId}`,
+        err,
+      );
+      throw err; // Re-throw to trigger retry mechanism
+    }
+  }
+
+  /**
+   * Rebuild category with its tags tree for a specific category.
+   */
+  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);
+
+      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
+    }
+  }
+
+  /**
+   * Rebuild global tag:all suggestion pool.
+   */
+  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
+    }
+  }
+
+  // ─────────────────────────────────────────────
   // Cache warming
   // ─────────────────────────────────────────────
 
@@ -647,6 +1078,7 @@ export class CacheSyncService {
     await Promise.all([
       this.rebuildChannelsAll(),
       this.rebuildCategoriesAll(),
+      this.rebuildTagAll(),
       this.warmAdPools(),
     ]);
 

+ 13 - 5
apps/box-mgnt-api/src/cache-sync/cache-sync.types.ts

@@ -6,22 +6,26 @@ export enum CacheEntityType {
   CHANNEL = 'CHANNEL',
   CATEGORY = 'CATEGORY',
   VIDEO_LIST = 'VIDEO_LIST',
+
+  // NEW: for category-with-tags + tag:all caches
+  TAG = 'TAG',
 }
 
 export enum CacheOperation {
-  REFRESH = 'REFRESH', // refresh single item (e.g., ads:byId:<id>)
-  INVALIDATE = 'INVALIDATE', // delete cache only
-  REBUILD_POOL = 'REBUILD_POOL', // rebuild a list/pool (e.g., ads:pool:<type>:active)
-  REFRESH_ALL = 'REFRESH_ALL', // rebuild full set (e.g., channels:all)
+  REFRESH = 'REFRESH',
+  INVALIDATE = 'INVALIDATE',
+  REBUILD_POOL = 'REBUILD_POOL',
+  REFRESH_ALL = 'REFRESH_ALL',
 }
 
 export enum CacheStatus {
   PENDING = 'PENDING',
   SUCCESS = 'SUCCESS',
-  FAILED = 'FAILED',
   GAVE_UP = 'GAVE_UP',
 }
 
+// ...
+
 export interface CachePayload {
   // For ads pools, e.g. { type: 'BANNER' }
   type?: string;
@@ -33,6 +37,10 @@ export interface CachePayload {
   scope?: string;
   page?: number;
 
+  // NEW (optional hints for tag/category actions)
+  categoryId?: string;
+  tagId?: string;
+
   // Allow extra fields for future use
   [key: string]: unknown;
 }

+ 83 - 4
apps/box-mgnt-api/src/mgnt-backend/feature/category/category.service.ts

@@ -1,8 +1,16 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import {
+  Injectable,
+  BadRequestException,
+  NotFoundException,
+} from '@nestjs/common';
 import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import { CacheSyncService } from '../../../cache-sync/cache-sync.service';
 import {
+  CacheEntityType,
+  CacheOperation,
+} from '../../../cache-sync/cache-sync.types';
+import {
   CreateCategoryDto,
   ListCategoryDto,
   UpdateCategoryDto,
@@ -43,6 +51,21 @@ export class CategoryService {
 
   async create(dto: CreateCategoryDto) {
     await this.assertChannelExists(dto.channelId);
+
+    // Check for duplicate category name within the same channel
+    const existingCategory = await this.mongoPrismaService.category.findFirst({
+      where: {
+        channelId: dto.channelId,
+        name: dto.name,
+      },
+    });
+
+    if (existingCategory) {
+      throw new BadRequestException(
+        `Category with name "${dto.name}" already exists in this channel`,
+      );
+    }
+
     const now = this.now();
 
     const category = await this.mongoPrismaService.category.create({
@@ -57,7 +80,14 @@ export class CategoryService {
       },
     });
 
-    // Auto-schedule cache refresh
+    // Schedule cache sync actions
+    // Refresh category-with-tags for this category
+    await this.cacheSyncService.scheduleAction({
+      entityType: CacheEntityType.TAG,
+      operation: CacheOperation.REFRESH,
+      payload: { categoryId: category.id },
+    });
+    // Also refresh category:all for consistency
     await this.cacheSyncService.scheduleCategoryRefreshAll();
 
     return category;
@@ -65,6 +95,32 @@ export class CategoryService {
 
   async update(dto: UpdateCategoryDto) {
     await this.assertChannelExists(dto.channelId);
+
+    // Load existing category to capture old channelId
+    const existingCategory = await this.mongoPrismaService.category.findUnique({
+      where: { id: dto.id },
+    });
+
+    if (!existingCategory) {
+      throw new NotFoundException('Category not found');
+    }
+
+    // Check for duplicate category name within the same channel (excluding current category)
+    const duplicateCategory = await this.mongoPrismaService.category.findFirst({
+      where: {
+        channelId: dto.channelId,
+        name: dto.name,
+        id: { not: dto.id },
+      },
+    });
+
+    if (duplicateCategory) {
+      throw new BadRequestException(
+        `Category with name "${dto.name}" already exists in this channel`,
+      );
+    }
+
+    const oldChannelId = existingCategory.channelId;
     const now = this.now();
 
     // Build data object carefully to avoid unintended field changes
@@ -87,7 +143,14 @@ export class CategoryService {
         data,
       });
 
-      // Auto-schedule cache refresh
+      // Schedule cache sync actions
+      // Refresh category-with-tags for this category
+      await this.cacheSyncService.scheduleAction({
+        entityType: CacheEntityType.TAG,
+        operation: CacheOperation.REFRESH,
+        payload: { categoryId: category.id },
+      });
+      // Also refresh category:all for consistency
       await this.cacheSyncService.scheduleCategoryRefreshAll();
 
       return category;
@@ -148,10 +211,26 @@ export class CategoryService {
   }
 
   async remove(id: string) {
+    // Load category before deletion to get channelId
+    const category = await this.mongoPrismaService.category.findUnique({
+      where: { id },
+    });
+
+    if (!category) {
+      throw new NotFoundException('Category not found');
+    }
+
     try {
       await this.mongoPrismaService.category.delete({ where: { id } });
 
-      // Auto-schedule cache refresh
+      // Schedule cache sync actions
+      // Refresh category-with-tags for this category (builder will remove key if category missing)
+      await this.cacheSyncService.scheduleAction({
+        entityType: CacheEntityType.TAG,
+        operation: CacheOperation.REFRESH,
+        payload: { categoryId: category.id },
+      });
+      // Also refresh category:all for consistency
       await this.cacheSyncService.scheduleCategoryRefreshAll();
 
       return { message: 'Deleted' };

+ 46 - 1
apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.service.ts

@@ -1,8 +1,16 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import {
+  Injectable,
+  BadRequestException,
+  NotFoundException,
+} from '@nestjs/common';
 import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import { CacheSyncService } from '../../../cache-sync/cache-sync.service';
 import {
+  CacheEntityType,
+  CacheOperation,
+} from '../../../cache-sync/cache-sync.types';
+import {
   CreateChannelDto,
   ListChannelDto,
   UpdateChannelDto,
@@ -33,6 +41,17 @@ export class ChannelService {
   }
 
   async create(dto: CreateChannelDto) {
+    // Check for duplicate channel name
+    const existingChannel = await this.mongoPrismaService.channel.findFirst({
+      where: { name: dto.name },
+    });
+
+    if (existingChannel) {
+      throw new BadRequestException(
+        `Channel with name "${dto.name}" already exists`,
+      );
+    }
+
     const now = this.now();
 
     const channel = await this.mongoPrismaService.channel.create({
@@ -51,11 +70,31 @@ export class ChannelService {
 
     // Auto-schedule cache refresh
     await this.cacheSyncService.scheduleChannelRefreshAll();
+    // Schedule channel-with-categories rebuild for this channel
+    await this.cacheSyncService.scheduleAction({
+      entityType: CacheEntityType.CHANNEL,
+      operation: CacheOperation.REFRESH,
+      payload: { channelId: channel.id },
+    });
 
     return channel;
   }
 
   async update(dto: UpdateChannelDto) {
+    // Check for duplicate channel name (excluding current channel)
+    const duplicateChannel = await this.mongoPrismaService.channel.findFirst({
+      where: {
+        name: dto.name,
+        id: { not: dto.id },
+      },
+    });
+
+    if (duplicateChannel) {
+      throw new BadRequestException(
+        `Channel with name "${dto.name}" already exists`,
+      );
+    }
+
     const now = this.now();
 
     try {
@@ -75,6 +114,12 @@ export class ChannelService {
 
       // Auto-schedule cache refresh
       await this.cacheSyncService.scheduleChannelRefreshAll();
+      // Schedule channel-with-categories rebuild for this channel
+      await this.cacheSyncService.scheduleAction({
+        entityType: CacheEntityType.CHANNEL,
+        operation: CacheOperation.REFRESH,
+        payload: { channelId: channel.id },
+      });
 
       return channel;
     } catch (e) {

+ 2 - 1
apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.module.ts

@@ -1,10 +1,11 @@
 import { Module } from '@nestjs/common';
 import { PrismaModule } from '@box/db/prisma/prisma.module';
+import { CacheSyncModule } from '../../../cache-sync/cache-sync.module';
 import { TagService } from './tag.service';
 import { TagController } from './tag.controller';
 
 @Module({
-  imports: [PrismaModule],
+  imports: [PrismaModule, CacheSyncModule],
   providers: [TagService],
   controllers: [TagController],
   exports: [TagService],

+ 110 - 12
apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.service.ts

@@ -5,12 +5,20 @@ import {
 } from '@nestjs/common';
 import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { CacheSyncService } from '../../../cache-sync/cache-sync.service';
+import {
+  CacheEntityType,
+  CacheOperation,
+} from '../../../cache-sync/cache-sync.types';
 import { CreateTagDto, ListTagDto, UpdateTagDto } from './tag.dto';
 import { CommonStatus } from '../common/status.enum';
 
 @Injectable()
 export class TagService {
-  constructor(private readonly mongoPrismaService: MongoPrismaService) {}
+  constructor(
+    private readonly mongoPrismaService: MongoPrismaService,
+    private readonly cacheSyncService: CacheSyncService,
+  ) {}
 
   /**
    * Current epoch time in milliseconds.
@@ -63,9 +71,24 @@ export class TagService {
 
   async create(dto: CreateTagDto) {
     await this.assertRelations(dto.channelId, dto.categoryId);
+
+    // Check for duplicate tag name within the same category
+    const existingTag = await this.mongoPrismaService.tag.findFirst({
+      where: {
+        categoryId: dto.categoryId,
+        name: dto.name,
+      },
+    });
+
+    if (existingTag) {
+      throw new BadRequestException(
+        `Tag with name "${dto.name}" already exists in this category`,
+      );
+    }
+
     const now = this.now();
 
-    return this.mongoPrismaService.tag.create({
+    const tag = await this.mongoPrismaService.tag.create({
       data: {
         name: dto.name,
         channelId: dto.channelId,
@@ -76,10 +99,49 @@ export class TagService {
         updateAt: now,
       },
     });
+
+    // Schedule cache sync actions
+    await this.cacheSyncService.scheduleAction({
+      entityType: CacheEntityType.TAG,
+      operation: CacheOperation.REFRESH,
+      payload: { categoryId: tag.categoryId },
+    });
+    await this.cacheSyncService.scheduleAction({
+      entityType: CacheEntityType.TAG,
+      operation: CacheOperation.REFRESH_ALL,
+    });
+
+    return tag;
   }
 
   async update(dto: UpdateTagDto) {
     await this.assertRelations(dto.channelId, dto.categoryId);
+
+    // Load existing tag to capture old categoryId in case it changed
+    const existingTag = await this.mongoPrismaService.tag.findUnique({
+      where: { id: dto.id },
+    });
+
+    if (!existingTag) {
+      throw new NotFoundException('Tag not found');
+    }
+
+    // Check for duplicate tag name within the same category (excluding current tag)
+    const duplicateTag = await this.mongoPrismaService.tag.findFirst({
+      where: {
+        categoryId: dto.categoryId,
+        name: dto.name,
+        id: { not: dto.id },
+      },
+    });
+
+    if (duplicateTag) {
+      throw new BadRequestException(
+        `Tag with name "${dto.name}" already exists in this category`,
+      );
+    }
+
+    const oldCategoryId = existingTag.categoryId;
     const now = this.now();
 
     // Build update data carefully to avoid accidentally changing fields
@@ -97,18 +159,31 @@ export class TagService {
       data.status = dto.status;
     }
 
-    try {
-      return await this.mongoPrismaService.tag.update({
-        where: { id: dto.id },
-        data,
+    const updatedTag = await this.mongoPrismaService.tag.update({
+      where: { id: dto.id },
+      data,
+    });
+
+    // Schedule cache sync actions for both old and new category
+    if (oldCategoryId !== dto.categoryId) {
+      // Category changed: refresh both old and new
+      await this.cacheSyncService.scheduleAction({
+        entityType: CacheEntityType.TAG,
+        operation: CacheOperation.REFRESH,
+        payload: { categoryId: oldCategoryId },
       });
-    } catch (e) {
-      if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
-        // Tag with this ID does not exist
-        throw new NotFoundException('Tag not found');
-      }
-      throw e;
     }
+    await this.cacheSyncService.scheduleAction({
+      entityType: CacheEntityType.TAG,
+      operation: CacheOperation.REFRESH,
+      payload: { categoryId: dto.categoryId },
+    });
+    await this.cacheSyncService.scheduleAction({
+      entityType: CacheEntityType.TAG,
+      operation: CacheOperation.REFRESH_ALL,
+    });
+
+    return updatedTag;
   }
 
   async findOne(id: string) {
@@ -157,8 +232,31 @@ export class TagService {
   }
 
   async remove(id: string) {
+    // Load tag before deletion to get categoryId
+    const tag = await this.mongoPrismaService.tag.findUnique({
+      where: { id },
+    });
+
+    if (!tag) {
+      throw new NotFoundException('Tag not found');
+    }
+
+    const categoryId = tag.categoryId;
+
     try {
       await this.mongoPrismaService.tag.delete({ where: { id } });
+
+      // Schedule cache sync actions
+      await this.cacheSyncService.scheduleAction({
+        entityType: CacheEntityType.TAG,
+        operation: CacheOperation.REFRESH,
+        payload: { categoryId },
+      });
+      await this.cacheSyncService.scheduleAction({
+        entityType: CacheEntityType.TAG,
+        operation: CacheOperation.REFRESH_ALL,
+      });
+
       return { message: 'Deleted' };
     } catch (e) {
       if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {

+ 35 - 49
libs/common/src/cache/cache-keys.ts

@@ -5,74 +5,60 @@
  * Actual keys in Redis will be: <REDIS_KEY_PREFIX><logicalKey>
  * e.g. "box:" + "app:channel:all" => "box:app:channel:all"
  */
-
 export const CacheKeys = {
   // ─────────────────────────────────────────────
-  // CHANNELS
+  // CHANNELS (existing)
   // ─────────────────────────────────────────────
-  channel: {
-    // keep for backward compatibility (even if not used now)
-    all: 'app:channel:all',
-
-    byId: (channelId: string | number): string =>
-      `app:channel:by-id:${channelId}`,
+  appChannelAll: 'app:channel:all',
+  appChannelById: (channelId: string | number): string =>
+    `app:channel:by-id:${channelId}`,
 
-    // NEW: channel with categories tree
-    withCategories: (channelId: string | number): string =>
-      `app:channel:with-categories:${channelId}`,
-  },
+  // NEW: Channel → Categories tree (future use)
+  appChannelWithCategories: (channelId: string | number): string =>
+    `app:channel:with-categories:${channelId}`,
 
   // ─────────────────────────────────────────────
-  // CATEGORIES
+  // CATEGORIES (existing)
   // ─────────────────────────────────────────────
-  category: {
-    // keep for backward compatibility
-    all: 'app:category:all',
+  appCategoryAll: 'app:category:all',
+  appCategoryById: (categoryId: string | number): string =>
+    `app:category:by-id:${categoryId}`,
 
-    byId: (categoryId: string | number): string =>
-      `app:category:by-id:${categoryId}`,
-
-    // NEW: category with tags tree
-    withTags: (categoryId: string | number): string =>
-      `app:category:with-tags:${categoryId}`,
-  },
+  // NEW: Category → Tags tree (main for video listing)
+  appCategoryWithTags: (categoryId: string | number): string =>
+    `app:category:with-tags:${categoryId}`,
 
   // ─────────────────────────────────────────────
-  // TAGS
+  // TAGS (new)
   // ─────────────────────────────────────────────
-  tag: {
-    // NEW: tag:all for search suggestions
-    all: 'app:tag:all',
-  },
+  // Global tag suggestion pool
+  appTagAll: 'app:tag:all',
+
+  // (Optional future)
+  // appTagByCategory: (categoryId: string | number): string =>
+  //   `app:tag:by-category:${categoryId}`,
+  // appTagByChannel: (channelId: string | number): string =>
+  //   `app:tag:by-channel:${channelId}`,
 
   // ─────────────────────────────────────────────
-  // ADS
+  // ADS (existing)
   // ─────────────────────────────────────────────
-  ad: {
-    byId: (adId: string | number): string => `app:ad:by-id:${adId}`,
-  },
+  appAdById: (adId: string | number): string => `app:ad:by-id:${adId}`,
 
   // ─────────────────────────────────────────────
-  // AD POOLS
-  // scene: e.g. "home", "detail"
-  // slot: e.g. "top", "carousel", "popup"
-  // type: e.g. "BANNER", "CAROUSEL", "POPUP"
+  // AD POOLS (existing)
   // ─────────────────────────────────────────────
-  adPool: {
-    pool: (scene: string, slot: string, type: string): string =>
-      `app:adpool:${scene}:${slot}:${type}`,
-  },
+  appAdPool: (scene: string, slot: string, type: string): string =>
+    `app:adpool:${scene}:${slot}:${type}`,
 
   // ─────────────────────────────────────────────
-  // VIDEO LISTS
+  // VIDEO LISTS (existing)
   // ─────────────────────────────────────────────
-  videoList: {
-    homePage: (page: number): string => `app:videolist:home:page:${page}`,
+  appHomeVideoPage: (page: number): string => `app:videolist:home:page:${page}`,
 
-    byChannelPage: (channelId: string | number, page: number): string =>
-      `app:videolist:channel:${channelId}:page:${page}`,
+  appChannelVideoPage: (channelId: string | number, page: number): string =>
+    `app:videolist:channel:${channelId}:page:${page}`,
 
-    trendingPage: (countryCode: string, page: number): string =>
-      `app:videolist:trending:${countryCode}:page:${page}`,
-  },
-} as const;
+  appTrendingVideoPage: (countryCode: string, page: number): string =>
+    `app:videolist:trending:${countryCode}:page:${page}`,
+};