Sfoglia il codice sorgente

feat(video-cache): add VideoCacheDebugService for inspecting Redis keys and validating video cache structure

feat(video-stats): implement VideoStatsService to compute video statistics for categories and tags

refactor(sync-videomedia): enhance logging in SyncVideomediaController and SyncVideomediaService for better traceability

fix(mgnt-backend): include CacheSyncModule in mgnt-backend module imports

refactor(cache): update cache key definitions and semantics for better clarity and consistency

refactor(cache): improve logging in VideoCacheHelper for cache operations

refactor(video-category-cache): enhance logging and return counts for video and tag metadata builds
Dave 4 mesi fa
parent
commit
2c80920dca
21 ha cambiato i file con 1220 aggiunte e 706 eliminazioni
  1. 1 1
      apps/box-app-api/src/feature/video/video.service.ts
  2. 19 0
      apps/box-mgnt-api/src/app.module.ts
  3. 220 0
      apps/box-mgnt-api/src/cache-sync/admin/video-cache-admin.controller.ts
  4. 18 2
      apps/box-mgnt-api/src/cache-sync/cache-sync.module.ts
  5. 0 231
      apps/box-mgnt-api/src/cache/video-list-cache.builder.ts
  6. 0 189
      apps/box-mgnt-api/src/commands/cache-clear-video.command.ts
  7. 104 0
      apps/box-mgnt-api/src/dev/controllers/dev-video-cache.controller.ts
  8. 43 0
      apps/box-mgnt-api/src/dev/controllers/video-cache-coverage.controller.ts
  9. 41 0
      apps/box-mgnt-api/src/dev/controllers/video-cache-debug.controller.ts
  10. 40 0
      apps/box-mgnt-api/src/dev/controllers/video-stats.controller.ts
  11. 65 0
      apps/box-mgnt-api/src/dev/dev-video-cache.module.ts
  12. 175 0
      apps/box-mgnt-api/src/dev/services/video-cache-coverage.service.ts
  13. 177 0
      apps/box-mgnt-api/src/dev/services/video-cache-debug.service.ts
  14. 154 0
      apps/box-mgnt-api/src/dev/services/video-stats.service.ts
  15. 16 8
      apps/box-mgnt-api/src/mgnt-backend/feature/sync-videomedia/sync-videomedia.controller.ts
  16. 21 23
      apps/box-mgnt-api/src/mgnt-backend/feature/sync-videomedia/sync-videomedia.service.ts
  17. 2 0
      apps/box-mgnt-api/src/mgnt-backend/mgnt-backend.module.ts
  18. 3 199
      libs/common/src/cache/cache-keys.ts
  19. 16 0
      libs/common/src/cache/ts-cache-key.provider.ts
  20. 23 15
      libs/common/src/cache/video-cache.helper.ts
  21. 82 38
      libs/core/src/cache/video/category/video-category-cache.builder.ts

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

@@ -103,7 +103,7 @@ export class VideoService {
   async getTagListForCategory(categoryId: string): Promise<VideoTagDto[]> {
     try {
       // Use helper to read tag metadata from cache
-      const key = `box:app:tag:list:${categoryId}`;
+      const key = tsCacheKeys.tag.metadataByCategory(categoryId);
       const tags = await this.cacheHelper.getTagListForCategory(key);
 
       if (!tags || tags.length === 0) {

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

@@ -15,9 +15,15 @@ import { LoggerModule } from 'nestjs-pino';
 import { MgntBackendModule } from './mgnt-backend/mgnt-backend.module';
 import pinoConfig from '@box/common/config/pino.config';
 import { CacheSyncModule } from './cache-sync/cache-sync.module';
+import { DevVideoCacheModule } from './dev/dev-video-cache.module';
 import { RedisModule } from '@box/db/redis/redis.module';
 import { CoreModule } from '@box/core/core.module';
 
+/**
+ * Check if running in production
+ */
+const isProd = process.env.NODE_ENV === 'production';
+
 @Module({
   imports: [
     // Global config, load from .env.mgnt then .env with validation
@@ -49,6 +55,19 @@ import { CoreModule } from '@box/core/core.module';
     LoggerModule.forRoot(pinoConfig),
     MgntBackendModule,
 
+    // ═══════════════════════════════════════════════════════════════════════════
+    // DEVELOPMENT-ONLY MODULES
+    // ═══════════════════════════════════════════════════════════════════════════
+    // DevVideoCacheModule provides dev-only endpoints:
+    // - DELETE /api/v1/mgnt/dev/cache/video?rebuild=true (rebuild/clear cache)
+    // - GET    /api/v1/mgnt/dev/cache/video/debug        (inspect cache)
+    // - GET    /api/v1/mgnt/dev/cache/video/coverage     (verify coverage)
+    // - GET    /api/v1/mgnt/dev/video/stats              (count videos)
+    //
+    // Only loaded when NODE_ENV !== 'production'
+    // ═══════════════════════════════════════════════════════════════════════════
+    ...(isProd ? [] : [DevVideoCacheModule]),
+
     DevtoolsModule.register({
       http: process.env.NODE_ENV === 'development',
     }),

+ 220 - 0
apps/box-mgnt-api/src/cache-sync/admin/video-cache-admin.controller.ts

@@ -0,0 +1,220 @@
+import { Controller, Post, Logger, BadRequestException } from '@nestjs/common';
+import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { VideoCategoryCacheBuilder } from '@box/core/cache/video/category/video-category-cache.builder';
+import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
+
+/**
+ * VideoCacheAdminController
+ *
+ * PRODUCTION-SAFE ADMIN ENDPOINT
+ * ═══════════════════════════════════════════════════════════════════════════
+ *
+ * This controller provides AUTHORIZED ADMIN-ONLY endpoints for video cache management.
+ * Unlike dev endpoints (/dev/cache/...), this is designed for production use.
+ *
+ * Routes:
+ * - POST /api/v1/mgnt/admin/cache/video/rebuild
+ *
+ * Access Control:
+ * - Protected by RbacGuard + JwtAuthGuard
+ * - Requires proper API permissions (configured via role-based menu API permissions)
+ * - Suitable only for super-admin or trusted operators
+ *
+ * Response:
+ * - Minimal, no internal Redis details or raw key information
+ * - Success indicator + deletion counts + rebuild timestamp
+ * - Detailed logging via Logger for audit trail
+ *
+ * ═══════════════════════════════════════════════════════════════════════════
+ * IMPORTANT: This is production-safe and will NOT be disabled in production.
+ * Access is controlled via role-based permissions (RbacGuard).
+ * ═══════════════════════════════════════════════════════════════════════════
+ */
+@ApiTags('缓存管理 - Admin (生产)', 'Cache Management - Admin (Production)')
+@Controller('api/v1/mgnt/admin/cache/video')
+export class VideoCacheAdminController {
+  private readonly logger = new Logger(VideoCacheAdminController.name);
+
+  constructor(
+    private readonly redisService: RedisService,
+    private readonly mongoPrisma: MongoPrismaService,
+    private readonly videoCategoryCacheBuilder: VideoCategoryCacheBuilder,
+  ) {}
+
+  /**
+   * POST /api/v1/mgnt/admin/cache/video/rebuild
+   *
+   * Rebuild video cache: clear old keys and rebuild all categories/tags.
+   *
+   * This endpoint:
+   * 1. Scans for and deletes video cache keys matching patterns
+   * 2. Calls VideoCategoryCacheBuilder.buildAll() to repopulate cache
+   * 3. Returns deletion counts and rebuild timestamp
+   *
+   * Patterns deleted:
+   * - box:app:video:category:list:* (category video lists)
+   * - box:app:video:tag:list:*     (tag-filtered video lists)
+   * - box:app:tag:list:*           (tag metadata lists)
+   *
+   * @returns {Promise<VideoCacheRebuildResponse>} Success indicator, deletion stats, rebuild timestamp
+   * @throws {BadRequestException} If Redis scan or build operation fails
+   */
+  @Post('rebuild')
+  @ApiOperation({
+    summary: 'Rebuild video cache (admin only)',
+    description:
+      'Clear video cache keys and rebuild from database. Requires admin role permissions.',
+  })
+  @ApiResponse({
+    status: 200,
+    description: 'Cache rebuild completed successfully',
+    schema: {
+      type: 'object',
+      example: {
+        success: true,
+        deleted: {
+          categoryLists: 15,
+          tagVideoLists: 42,
+          tagMetadataLists: 8,
+          total: 65,
+        },
+        rebuiltAt: '2025-12-06T10:30:45.123Z',
+      },
+    },
+  })
+  @ApiResponse({
+    status: 400,
+    description: 'Cache operation failed (e.g., Redis error, build error)',
+  })
+  @ApiResponse({
+    status: 403,
+    description: 'Forbidden - User lacks admin permissions',
+  })
+  async rebuildVideoCache(): Promise<VideoCacheRebuildResponse> {
+    const startTime = Date.now();
+    this.logger.log('[AdminCache] Starting video cache rebuild...');
+
+    try {
+      // Step 1: Delete video cache keys by pattern
+      const deletionStats = await this.deleteVideoCache();
+
+      // Step 2: Rebuild video cache from database
+      this.logger.log('[AdminCache] Triggering cache builder rebuild...');
+      try {
+        await this.videoCategoryCacheBuilder.buildAll();
+        this.logger.log('[AdminCache] Cache rebuild completed successfully');
+      } catch (buildError) {
+        this.logger.error(
+          `[AdminCache] Cache rebuild failed: ${buildError instanceof Error ? buildError.message : 'Unknown error'}`,
+          buildError instanceof Error ? buildError.stack : undefined,
+        );
+        throw new BadRequestException('Cache rebuild operation failed');
+      }
+
+      // Step 3: Return success response with stats
+      const duration = Date.now() - startTime;
+      this.logger.log(
+        `[AdminCache] Video cache rebuild completed successfully (${duration}ms)`,
+      );
+
+      return {
+        success: true,
+        deleted: deletionStats,
+        rebuiltAt: new Date().toISOString(),
+      };
+    } catch (error) {
+      const duration = Date.now() - startTime;
+      this.logger.error(
+        `[AdminCache] Video cache rebuild failed after ${duration}ms: ${error instanceof Error ? error.message : 'Unknown error'}`,
+        error instanceof Error ? error.stack : undefined,
+      );
+
+      if (error instanceof BadRequestException) {
+        throw error;
+      }
+
+      throw new BadRequestException('Video cache rebuild operation failed');
+    }
+  }
+
+  /**
+   * Delete video cache keys matching specific patterns
+   *
+   * Patterns deleted:
+   * - box:app:video:category:list:*
+   * - box:app:video:tag:list:*
+   * - box:app:tag:list:*
+   */
+  private async deleteVideoCache(): Promise<VideoCacheDeletionStats> {
+    this.logger.log('[AdminCache] Deleting video cache keys...');
+
+    const stats: VideoCacheDeletionStats = {
+      categoryLists: 0,
+      tagVideoLists: 0,
+      tagMetadataLists: 0,
+      total: 0,
+    };
+
+    try {
+      // Delete category video lists: box:app:video:category:list:*
+      const categoryListKeys = await this.redisService.keys(
+        tsCacheKeys.video.categoryList('*'),
+      );
+      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`);
+      }
+
+      // Delete tag video lists: box:app:video:tag:list:*:*
+      const tagListKeys = await this.redisService.keys(
+        tsCacheKeys.video.tagList('*', '*'),
+      );
+      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`);
+      }
+
+      // Delete tag metadata lists: box:app:tag:list:*
+      const tagMetadataKeys = await this.redisService.keys(
+        tsCacheKeys.tag.metadataByCategory('*'),
+      );
+      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`);
+      }
+
+      this.logger.log(`[AdminCache] Total keys deleted: ${stats.total}`);
+      return stats;
+    } catch (error) {
+      this.logger.error(
+        `[AdminCache] Failed to delete cache keys: ${error instanceof Error ? error.message : 'Unknown error'}`,
+        error instanceof Error ? error.stack : undefined,
+      );
+      throw new BadRequestException('Failed to delete video cache keys');
+    }
+  }
+}
+
+/**
+ * Response shape for video cache rebuild endpoint
+ */
+export interface VideoCacheDeletionStats {
+  categoryLists: number;
+  tagVideoLists: number;
+  tagMetadataLists: number;
+  total: number;
+}
+
+export interface VideoCacheRebuildResponse {
+  success: boolean;
+  deleted: VideoCacheDeletionStats;
+  rebuiltAt: string;
+}

+ 18 - 2
apps/box-mgnt-api/src/cache-sync/cache-sync.module.ts

@@ -4,16 +4,32 @@ import { CacheSyncService } from './cache-sync.service';
 import { CacheSyncDebugController } from './cache-sync-debug.controller';
 import { CacheChecklistService } from './cache-checklist.service';
 import { CacheChecklistController } from './cache-checklist.controller';
-import { CacheClearVideoController } from '../commands/cache-clear-video.command';
+import { VideoCacheAdminController } from './admin/video-cache-admin.controller';
 
 import { CacheManagerModule } from '@box/core/cache/cache-manager.module';
 
+/**
+ * CacheSyncModule
+ *
+ * Production-safe cache synchronization module.
+ *
+ * Contains:
+ * - CacheSyncService: Background cache sync job
+ * - CacheSyncDebugController: Debug-only checklist endpoints
+ * - CacheChecklistService: Cache integrity checking
+ * - VideoCacheAdminController: PRODUCTION admin endpoint for cache rebuild
+ *
+ * Dev-only endpoints (debugging, statistics, raw key inspection) are isolated
+ * in DevVideoCacheModule and conditionally imported in AppModule based on NODE_ENV.
+ *
+ * See also: apps/box-mgnt-api/src/dev/dev-video-cache.module.ts
+ */
 @Module({
   imports: [CacheManagerModule],
   controllers: [
     CacheSyncDebugController,
     CacheChecklistController,
-    CacheClearVideoController,
+    VideoCacheAdminController,
   ],
   providers: [CacheSyncService, CacheChecklistService],
   exports: [CacheSyncService, CacheChecklistService],

+ 0 - 231
apps/box-mgnt-api/src/cache/video-list-cache.builder.ts

@@ -1,231 +0,0 @@
-// apps/box-mgnt-api/src/cache/video-category-cache.builder.ts
-import { Injectable, Logger } from '@nestjs/common';
-import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
-import { RedisService } from '@box/db/redis/redis.service';
-import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
-import type {
-  VideoSortKey,
-  VideoHomeSectionKey,
-} from '@box/common/cache/ts-cache-key.provider';
-
-interface VideoEntry {
-  id: string;
-  editedAt: bigint;
-  updatedAt: Date;
-}
-
-/**
- * Video pool cache builder.
- * Builds Redis ZSET pools for videos sorted by category/tag and LIST pools for home sections.
- * Videos are indexed by editedAt (or fallback to updatedAt) for consistent ordering.
- */
-@Injectable()
-export class VideoListCacheBuilder {
-  private readonly logger = new Logger(VideoListCacheBuilder.name);
-  private readonly HOME_SECTION_LIMIT = 50;
-
-  constructor(
-    private readonly redis: RedisService,
-    private readonly mongoPrisma: MongoPrismaService,
-  ) {}
-
-  /**
-   * Build all video pools for all channels.
-   * Iterates all channels and builds:
-   *  - Category pools (ZSET) for all sorts
-   *  - Tag pools (ZSET) for all sorts
-   *  - Home section lists (LIST)
-   */
-  async buildAll(): Promise<void> {
-    const channels = await this.mongoPrisma.channel.findMany();
-
-    for (const channel of channels) {
-      try {
-        await this.buildCategoryPoolsForChannel(channel.id);
-        await this.buildTagPoolsForChannel(channel.id);
-        await this.buildHomeSectionsForChannel(channel.id);
-      } catch (err) {
-        this.logger.error(
-          `Error building video pools for channel ${channel.id}`,
-          err instanceof Error ? err.stack : String(err),
-        );
-      }
-    }
-
-    this.logger.log(`Built video pools for ${channels.length} channels`);
-  }
-
-  /**
-   * Build category pools (ZSET) for a channel.
-   * For each category in the channel, create a sorted set of videoIds
-   * ordered by editedAt (or updatedAt as fallback).
-   */
-  async buildCategoryPoolsForChannel(channelId: string): Promise<void> {
-    // Fetch all videos for this channel with listStatus === 1
-    const videos = await this.mongoPrisma.videoMedia.findMany({
-      where: { listStatus: 1, categoryId: { not: null } },
-    });
-
-    // Group videos by categoryId
-    const videosByCategory = new Map<string, VideoEntry[]>();
-
-    for (const video of videos) {
-      if (!video.categoryId) continue;
-      if (!videosByCategory.has(video.categoryId)) {
-        videosByCategory.set(video.categoryId, []);
-      }
-      videosByCategory.get(video.categoryId)!.push({
-        id: video.id,
-        editedAt: video.editedAt,
-        updatedAt: video.updatedAt,
-      });
-    }
-
-    // Build ZSET for each category for each sort type
-    const sortTypes: VideoSortKey[] = ['latest', 'popular', 'manual'];
-
-    for (const [categoryId, categoryVideos] of videosByCategory) {
-      for (const sort of sortTypes) {
-        const sortedVideos = this.sortVideos(categoryVideos, sort);
-        const key = tsCacheKeys.video.categoryPool(channelId, categoryId, sort);
-        await this.buildZsetPool(key, sortedVideos);
-      }
-    }
-
-    this.logger.debug(
-      `Built category pools for ${videosByCategory.size} categories in channel ${channelId}`,
-    );
-  }
-
-  /**
-   * Build tag pools (ZSET) for a channel.
-   * For each tag in the channel's categories, create a sorted set of videoIds.
-   */
-  async buildTagPoolsForChannel(channelId: string): Promise<void> {
-    // Fetch all videos for this channel with listStatus === 1
-    const videos = await this.mongoPrisma.videoMedia.findMany({
-      where: { listStatus: 1 },
-    });
-
-    // Group videos by tagId (only include videos that have tags)
-    const videosByTag = new Map<string, VideoEntry[]>();
-
-    for (const video of videos) {
-      if (!video.tagIds || video.tagIds.length === 0) continue;
-      for (const tagId of video.tagIds) {
-        if (!videosByTag.has(tagId)) {
-          videosByTag.set(tagId, []);
-        }
-        videosByTag.get(tagId)!.push({
-          id: video.id,
-          editedAt: video.editedAt,
-          updatedAt: video.updatedAt,
-        });
-      }
-    }
-
-    // Build ZSET for each tag for each sort type
-    const sortTypes: VideoSortKey[] = ['latest', 'popular', 'manual'];
-
-    for (const [tagId, tagVideos] of videosByTag) {
-      for (const sort of sortTypes) {
-        const sortedVideos = this.sortVideos(tagVideos, sort);
-        const key = tsCacheKeys.video.tagPool(channelId, tagId, sort);
-        await this.buildZsetPool(key, sortedVideos);
-      }
-    }
-
-    this.logger.debug(
-      `Built tag pools for ${videosByTag.size} tags in channel ${channelId}`,
-    );
-  }
-
-  /**
-   * Build home section lists (LIST) for a channel.
-   * For each section (featured, latest, editorPick), store top N videoIds.
-   */
-  async buildHomeSectionsForChannel(channelId: string): Promise<void> {
-    // Fetch all videos for this channel with listStatus === 1, sorted by editedAt DESC
-    const videos = await this.mongoPrisma.videoMedia.findMany({
-      where: { listStatus: 1 },
-      orderBy: [{ editedAt: 'desc' }, { updatedAt: 'desc' }],
-      take: this.HOME_SECTION_LIMIT,
-    });
-
-    const videoIds = videos.map((v) => v.id);
-
-    const sections: VideoHomeSectionKey[] = [
-      'featured',
-      'latest',
-      'editorPick',
-    ];
-
-    for (const section of sections) {
-      const key = tsCacheKeys.video.homeSection(channelId, section);
-      await this.buildListPool(key, videoIds);
-    }
-
-    this.logger.debug(
-      `Built ${sections.length} home sections with ${videoIds.length} videos for channel ${channelId}`,
-    );
-  }
-
-  /**
-   * Sort video entries by the given sort key.
-   * 'latest': sorted by editedAt DESC
-   * 'popular': sorted by editedAt DESC (same as latest for now, can be extended)
-   * 'manual': no specific sorting (use insertion order)
-   */
-  private sortVideos(videos: VideoEntry[], sort: VideoSortKey): VideoEntry[] {
-    if (sort === 'latest') {
-      return [...videos].sort((a, b) => {
-        const scoreA =
-          a.editedAt !== BigInt(0) ? Number(a.editedAt) : a.updatedAt.getTime();
-        const scoreB =
-          b.editedAt !== BigInt(0) ? Number(b.editedAt) : b.updatedAt.getTime();
-        return scoreB - scoreA; // DESC
-      });
-    }
-
-    if (sort === 'popular') {
-      // For MVP, reuse latest sorting; can be extended with view counts later
-      return [...videos].sort((a, b) => {
-        const scoreA =
-          a.editedAt !== BigInt(0) ? Number(a.editedAt) : a.updatedAt.getTime();
-        const scoreB =
-          b.editedAt !== BigInt(0) ? Number(b.editedAt) : b.updatedAt.getTime();
-        return scoreB - scoreA; // DESC
-      });
-    }
-
-    // 'manual': preserve order as-is (can be extended with manual sort field)
-    return videos;
-  }
-
-  /**
-   * Build a Redis ZSET pool.
-   * Atomically deletes old key, then adds all members with scores.
-   * Score is based on editedAt (or updatedAt as fallback).
-   */
-  private async buildZsetPool(
-    key: string,
-    videos: VideoEntry[],
-  ): Promise<void> {
-    const members = videos.map((video) => ({
-      score:
-        video.editedAt !== BigInt(0)
-          ? Number(video.editedAt)
-          : video.updatedAt.getTime(),
-      member: video.id,
-    }));
-    await this.redis.zadd(key, members);
-  }
-
-  /**
-   * Build a Redis LIST pool.
-   * Atomically deletes old key, then pushes all videoIds.
-   */
-  private async buildListPool(key: string, videoIds: string[]): Promise<void> {
-    await this.redis.rpushList(key, videoIds);
-  }
-}

+ 0 - 189
apps/box-mgnt-api/src/commands/cache-clear-video.command.ts

@@ -1,189 +0,0 @@
-import {
-  Controller,
-  Delete,
-  Query,
-  Logger,
-  HttpException,
-  HttpStatus,
-} from '@nestjs/common';
-import { ApiOperation, ApiTags, ApiQuery, ApiResponse } from '@nestjs/swagger';
-import { RedisService } from '@box/db/redis/redis.service';
-import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
-import { VideoCategoryCacheBuilder } from '@box/core/cache/video';
-
-/**
- * Cache clear/rebuild controller for development and QA environments.
- *
- * ⚠️ DEV/QA ONLY - All endpoints should be protected and restricted to non-production!
- *
- * This controller provides REST endpoints to:
- * 1. Clear video cache keys matching patterns
- * 2. Optionally rebuild cache with latest data
- *
- * Cleared key patterns:
- * - box:app:video:category:list:*
- * - box:app:video:tag:list:*
- * - box:app:tag:list:*
- *
- * @example
- * # Clear cache (development only)
- * curl -X DELETE http://localhost:3001/api/v1/mgnt/dev/cache/video
- *
- * # Clear and rebuild
- * curl -X DELETE http://localhost:3001/api/v1/mgnt/dev/cache/video?rebuild=true
- *
- * @warning Production deployments should disable or remove this controller
- */
-@Controller('dev/cache')
-@ApiTags('🔧 Development - Cache Management (DEV/QA ONLY)')
-export class CacheClearVideoController {
-  private readonly logger = new Logger(CacheClearVideoController.name);
-
-  constructor(
-    private readonly redis: RedisService,
-    private readonly mongoPrisma: MongoPrismaService,
-    private readonly builder: VideoCategoryCacheBuilder,
-  ) {}
-
-  /**
-   * Clear all video cache keys and optionally rebuild.
-   *
-   * This endpoint:
-   * 1. Finds all keys matching cache key patterns
-   * 2. Deletes them from Redis
-   * 3. Optionally triggers rebuild to repopulate with current DB data
-   *
-   * ⚠️ Only available in development/QA environments
-   *
-   * @param rebuild - If true, rebuild cache after clearing (default: false)
-   * @returns Summary of deleted keys and rebuild status
-   *
-   * @example
-   * # Just clear (quick)
-   * DELETE /api/v1/mgnt/dev/cache/video
-   *
-   * # Clear and rebuild (slower but ensures clean state)
-   * DELETE /api/v1/mgnt/dev/cache/video?rebuild=true
-   */
-  @Delete('video')
-  @ApiOperation({
-    summary: 'Clear video cache keys (DEV/QA ONLY)',
-    description:
-      'Deletes all video cache keys. Optionally rebuilds cache from database.',
-  })
-  @ApiQuery({
-    name: 'rebuild',
-    required: false,
-    description: 'If true, rebuild cache after clearing (slower)',
-    example: false,
-  })
-  @ApiResponse({
-    status: 200,
-    description: 'Cache cleared successfully',
-    schema: {
-      example: {
-        success: true,
-        message: 'Video cache cleared and rebuilt successfully',
-        deletedKeys: {
-          'box:app:video:category:list:*': 15,
-          'box:app:video:tag:list:*': 45,
-          'box:app:tag:list:*': 20,
-        },
-        totalDeleted: 80,
-        rebuilt: true,
-        timestamp: '2024-12-06T01:02:39.000Z',
-      },
-    },
-  })
-  @ApiResponse({
-    status: 400,
-    description: 'Invalid request',
-  })
-  @ApiResponse({
-    status: 500,
-    description: 'Redis or database error',
-  })
-  async clearVideoCache(@Query('rebuild') rebuild?: string): Promise<{
-    success: boolean;
-    message: string;
-    deletedKeys: Record<string, number>;
-    totalDeleted: number;
-    rebuilt: boolean;
-    timestamp: string;
-  }> {
-    try {
-      // Check environment (optional but recommended)
-      const nodeEnv = process.env.NODE_ENV || 'development';
-      if (nodeEnv === 'production') {
-        throw new HttpException(
-          'This endpoint is not available in production',
-          HttpStatus.FORBIDDEN,
-        );
-      }
-
-      this.logger.warn(
-        '🗑️  Clearing video cache keys (DEV/QA ONLY) - ' +
-          new Date().toISOString(),
-      );
-
-      // Define patterns to clear
-      const patterns = [
-        'box:app:video:category:list:*',
-        'box:app:video:tag:list:*',
-        'box:app:tag:list:*',
-      ];
-
-      const deletedKeys: Record<string, number> = {};
-      let totalDeleted = 0;
-
-      // Clear each pattern
-      for (const pattern of patterns) {
-        const deleted = await this.redis.deleteByPattern(pattern);
-        deletedKeys[pattern] = deleted;
-        totalDeleted += deleted;
-        this.logger.log(`  ✅ Deleted ${deleted} keys matching: ${pattern}`);
-      }
-
-      this.logger.log(`📊 Total deleted: ${totalDeleted} keys`);
-
-      // Optionally rebuild
-      let rebuilt = false;
-      if (rebuild === 'true' || rebuild === '1') {
-        this.logger.log('🔨 Rebuilding video caches from database...');
-        await this.builder.buildAll();
-        rebuilt = true;
-        this.logger.log('✅ Cache rebuild complete!');
-      } else {
-        this.logger.log(
-          '💡 To rebuild cache, use: ?rebuild=true query parameter',
-        );
-      }
-
-      return {
-        success: true,
-        message: rebuilt
-          ? 'Video cache cleared and rebuilt successfully'
-          : 'Video cache cleared successfully (use ?rebuild=true to rebuild)',
-        deletedKeys,
-        totalDeleted,
-        rebuilt,
-        timestamp: new Date().toISOString(),
-      };
-    } catch (err) {
-      this.logger.error(
-        '❌ Error clearing cache:',
-        err instanceof Error ? err.stack : String(err),
-      );
-      throw new HttpException(
-        {
-          success: false,
-          error: err instanceof Error ? err.message : String(err),
-          timestamp: new Date().toISOString(),
-        },
-        err instanceof HttpException
-          ? err.getStatus()
-          : HttpStatus.INTERNAL_SERVER_ERROR,
-      );
-    }
-  }
-}

+ 104 - 0
apps/box-mgnt-api/src/dev/controllers/dev-video-cache.controller.ts

@@ -0,0 +1,104 @@
+import {
+  Controller,
+  Delete,
+  Logger,
+  Query,
+  DefaultValuePipe,
+  ParseBoolPipe,
+} from '@nestjs/common';
+import { RedisService } from '@box/db/redis/redis.service';
+import { VideoCategoryCacheBuilder } from '@box/core/cache/video/category/video-category-cache.builder';
+
+/**
+ * Dev-only endpoint for clearing and rebuilding video caches.
+ *
+ * Routes:
+ * - DELETE /api/v1/mgnt/dev/cache/video - Delete video cache keys matching patterns
+ * - DELETE /api/v1/mgnt/dev/cache/video?rebuild=true - Delete + rebuild all video caches
+ *
+ * Cache patterns cleared:
+ * - box:app:video:category:list:* (category video lists)
+ * - box:app:video:tag:list:* (tag-filtered video lists)
+ * - box:app:tag:list:* (tag metadata lists)
+ *
+ * ⚠️ DEVELOPMENT ONLY - Do not expose in production
+ */
+@Controller('dev/cache')
+export class DevVideoCacheController {
+  private readonly logger = new Logger(DevVideoCacheController.name);
+
+  constructor(
+    private readonly redis: RedisService,
+    private readonly videoCacheBuilder: VideoCategoryCacheBuilder,
+  ) {}
+
+  /**
+   * DELETE /api/v1/mgnt/dev/cache/video
+   * DELETE /api/v1/mgnt/dev/cache/video?rebuild=true
+   * DELETE /api/v1/mgnt/dev/cache/video?rebuild=1
+   *
+   * Clears video cache keys by pattern. If rebuild=true, also rebuilds all video caches.
+   *
+   * @param rebuild - If true, rebuild all video caches after clearing
+   * @returns Summary of deleted keys and rebuild status
+   */
+  @Delete('video')
+  async clearVideoCache(
+    @Query('rebuild', new DefaultValuePipe(false), ParseBoolPipe)
+    rebuild: boolean,
+  ) {
+    this.logger.log('🗑️ Starting video cache clear...');
+
+    // Delete cache keys by patterns
+    const patterns = [
+      'box:app:video:category:list:*',
+      'box:app:video:tag:list:*',
+      'box:app:tag:list:*',
+    ];
+
+    const deletionStats: Record<string, number> = {};
+
+    for (const pattern of patterns) {
+      const count = await this.redis.deleteByPattern(pattern);
+      deletionStats[pattern] = count;
+      this.logger.log(`  ✓ Deleted ${count} keys matching pattern: ${pattern}`);
+    }
+
+    const totalDeleted = Object.values(deletionStats).reduce(
+      (a, b) => a + b,
+      0,
+    );
+    this.logger.log(`✅ Total keys deleted: ${totalDeleted}`);
+
+    // If rebuild requested, rebuild all video caches
+    if (rebuild) {
+      this.logger.log(
+        '🔨 Rebuild requested. Starting VideoCategoryCacheBuilder.buildAll()...',
+      );
+      const startTime = Date.now();
+      try {
+        await this.videoCacheBuilder.buildAll();
+        const duration = Date.now() - startTime;
+        this.logger.log(`✅ Video cache rebuild completed in ${duration}ms`);
+      } catch (error) {
+        this.logger.error(
+          `❌ Video cache rebuild failed: ${error instanceof Error ? error.message : String(error)}`,
+          error instanceof Error ? error.stack : undefined,
+        );
+        throw error;
+      }
+    }
+
+    return {
+      ok: true,
+      message: rebuild
+        ? 'Video cache cleared and rebuilt successfully'
+        : 'Video cache cleared successfully',
+      deleted: {
+        ...deletionStats,
+        total: totalDeleted,
+      },
+      rebuilt: rebuild,
+    };
+  }
+}

+ 43 - 0
apps/box-mgnt-api/src/dev/controllers/video-cache-coverage.controller.ts

@@ -0,0 +1,43 @@
+import { Controller, Get } from '@nestjs/common';
+import {
+  VideoCacheCoverageService,
+  VideoCacheCoverageResponse,
+} from '../services/video-cache-coverage.service';
+
+/**
+ * Dev-only controller for checking video cache coverage.
+ *
+ * Routes:
+ * - GET /api/v1/mgnt/dev/cache/video/coverage
+ *
+ * Verifies that all expected video cache keys exist in Redis
+ * by comparing database structure (channels, categories, tags)
+ * with actual Redis keys.
+ *
+ * Smart detection: Only reports missing keys if videos actually exist in DB
+ * (empty categories/tags don't require Redis keys).
+ *
+ * ⚠️ DEVELOPMENT ONLY - Do not expose in production
+ */
+@Controller('dev/cache/video')
+export class VideoCacheCoverageController {
+  constructor(private readonly coverageService: VideoCacheCoverageService) {}
+
+  /**
+   * GET /api/v1/mgnt/dev/cache/video/coverage
+   *
+   * Checks if all database entities have corresponding Redis cache keys.
+   * Reports missing category video lists, tag video lists, and tag metadata lists.
+   *
+   * Only reports missing keys when:
+   * - categoryVideoList: videos exist in category AND Redis key missing
+   * - tagVideoList: videos exist for (category, tag) AND Redis key missing
+   * - tagMetadata: always checked (expected if tags exist in category)
+   *
+   * @returns VideoCacheCoverageResponse with coverage statistics and missing keys
+   */
+  @Get('coverage')
+  async coverage(): Promise<VideoCacheCoverageResponse> {
+    return this.coverageService.checkCoverage();
+  }
+}

+ 41 - 0
apps/box-mgnt-api/src/dev/controllers/video-cache-debug.controller.ts

@@ -0,0 +1,41 @@
+import { Controller, Get } from '@nestjs/common';
+import {
+  VideoCacheDebugService,
+  VideoCacheDebugResponse,
+} from '../services/video-cache-debug.service';
+
+/**
+ * Dev-only controller for debugging video cache state.
+ *
+ * Routes:
+ * - GET /api/v1/mgnt/dev/cache/video/debug
+ *
+ * Returns detailed inspection of:
+ * - box:app:video:category:list:* keys (category video IDs)
+ * - box:app:video:tag:list:* keys (tag-filtered video IDs)
+ * - box:app:tag:list:* keys (tag metadata JSON)
+ *
+ * Validates structure and reports anomalies (e.g., JSON in video lists, missing fields in tag metadata).
+ *
+ * ⚠️ DEVELOPMENT ONLY - Do not expose in production
+ */
+@Controller('dev/cache/video')
+export class VideoCacheDebugController {
+  constructor(private readonly debugService: VideoCacheDebugService) {}
+
+  /**
+   * GET /api/v1/mgnt/dev/cache/video/debug
+   *
+   * Scans video cache keys and returns a detailed summary with validation warnings.
+   * Checks:
+   * - Video list keys contain 24-char hex IDs (MongoDB ObjectIDs)
+   * - No JSON objects accidentally stored in video lists
+   * - Tag metadata keys contain valid JSON with required fields (id, name, categoryId, channelId)
+   *
+   * @returns VideoCacheDebugResponse with arrays of key info and validation warnings
+   */
+  @Get('debug')
+  async debug(): Promise<VideoCacheDebugResponse> {
+    return this.debugService.debugVideoCache();
+  }
+}

+ 40 - 0
apps/box-mgnt-api/src/dev/controllers/video-stats.controller.ts

@@ -0,0 +1,40 @@
+import { Controller, Get } from '@nestjs/common';
+import {
+  VideoStatsService,
+  VideoStatsResponse,
+} from '../services/video-stats.service';
+
+/**
+ * Dev-only controller for video statistics.
+ *
+ * Routes:
+ * - GET /api/v1/mgnt/dev/video/stats
+ *
+ * Returns video counts per category and per (category, tag) pair
+ * using hardcoded IDs for testing purposes.
+ *
+ * ⚠️ DEVELOPMENT ONLY - Do not expose in production
+ */
+@Controller('dev/video')
+export class VideoStatsController {
+  constructor(private readonly statsService: VideoStatsService) {}
+
+  /**
+   * GET /api/v1/mgnt/dev/video/stats
+   *
+   * Computes and returns video statistics for hardcoded categories and tags.
+   * Uses videoMedia.count() with appropriate filters:
+   * - Categories: listStatus: 1
+   * - Tags: categoryId, tagId, listStatus: 1
+   *
+   * Query filters match builder logic for consistency:
+   * - Only counts videos with listStatus: 1 (on shelf)
+   * - For tags, also filters by tag membership: tagIds: { has: tagId }
+   *
+   * @returns VideoStatsResponse with category and tag video counts
+   */
+  @Get('stats')
+  async stats(): Promise<VideoStatsResponse> {
+    return this.statsService.computeStats();
+  }
+}

+ 65 - 0
apps/box-mgnt-api/src/dev/dev-video-cache.module.ts

@@ -0,0 +1,65 @@
+/**
+ * DevVideoCacheModule
+ *
+ * DEVELOPMENT ONLY MODULE
+ * ═══════════════════════════════════════════════════════════════════════════
+ *
+ * This module contains all dev-only endpoints for:
+ * - Video cache debugging and monitoring
+ * - Video cache rebuild and clearing
+ * - Coverage checking
+ * - Video statistics
+ *
+ * Routes (all prefixed with /api/v1/mgnt/dev):
+ * ────────────────────────────────────────────
+ * DELETE /cache/video?rebuild=true      - Clear/rebuild video cache
+ * GET    /cache/video/debug             - Inspect Redis cache keys
+ * GET    /cache/video/coverage          - Verify DB vs Redis alignment
+ * GET    /video/stats                   - Count videos per category/tag
+ *
+ * ═══════════════════════════════════════════════════════════════════════════
+ * IMPORTANT: This module must ONLY be imported in non-production environments
+ * See: apps/box-mgnt-api/src/app.module.ts (conditional import)
+ * ═══════════════════════════════════════════════════════════════════════════
+ */
+
+import { Module } from '@nestjs/common';
+import { CacheManagerModule } from '@box/core/cache/cache-manager.module';
+
+// Dev Controllers (all prefixed with @Controller('dev/...'))
+import { DevVideoCacheController } from './controllers/dev-video-cache.controller';
+import { VideoCacheDebugController } from './controllers/video-cache-debug.controller';
+import { VideoCacheCoverageController } from './controllers/video-cache-coverage.controller';
+import { VideoStatsController } from './controllers/video-stats.controller';
+
+// Dev Services
+import { VideoCacheDebugService } from './services/video-cache-debug.service';
+import { VideoCacheCoverageService } from './services/video-cache-coverage.service';
+import { VideoStatsService } from './services/video-stats.service';
+
+/**
+ * DevVideoCacheModule - Bundles all development tools for video cache management
+ *
+ * Only imported when NODE_ENV !== 'production'
+ * Provides endpoints for cache debugging, monitoring, and statistics
+ */
+@Module({
+  imports: [CacheManagerModule],
+  controllers: [
+    DevVideoCacheController,
+    VideoCacheDebugController,
+    VideoCacheCoverageController,
+    VideoStatsController,
+  ],
+  providers: [
+    VideoCacheDebugService,
+    VideoCacheCoverageService,
+    VideoStatsService,
+  ],
+  exports: [
+    VideoCacheDebugService,
+    VideoCacheCoverageService,
+    VideoStatsService,
+  ],
+})
+export class DevVideoCacheModule {}

+ 175 - 0
apps/box-mgnt-api/src/dev/services/video-cache-coverage.service.ts

@@ -0,0 +1,175 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
+
+/**
+ * Response structure for video cache coverage check
+ */
+export interface VideoCacheCoverageResponse {
+  totalChannels: number;
+  totalCategories: number;
+  totalTags: number;
+  missingCategoryVideoLists: string[];
+  missingTagVideoLists: string[];
+  missingTagMetadataLists: string[];
+}
+
+/**
+ * Service for checking video cache coverage.
+ * Dev-only: verifies that all expected cache keys exist in Redis.
+ *
+ * Compares database structure (channels, categories, tags) with actual Redis keys
+ * to identify missing cache entries.
+ *
+ * Smart detection:
+ * - Only reports missing category video list if videos exist and Redis key is missing
+ * - Only reports missing tag video list if videos exist and Redis key is missing
+ * - Tag metadata lists always checked (expected if category has tags)
+ */
+@Injectable()
+export class VideoCacheCoverageService {
+  private readonly logger = new Logger(VideoCacheCoverageService.name);
+
+  constructor(
+    private readonly redis: RedisService,
+    private readonly mongoPrisma: MongoPrismaService,
+  ) {}
+
+  /**
+   * Check video cache coverage across all channels, categories, and tags.
+   * Identifies missing category lists, tag lists, and tag metadata lists.
+   *
+   * SMART MISSING-KEY DETECTION:
+   * - Only reports category video list as missing if: videoCount > 0 AND Redis key missing
+   * - Only reports tag video list as missing if: videoCount > 0 AND Redis key missing
+   * - Tag metadata lists are checked regardless (always expected if tags exist)
+   *
+   * This avoids false positives for empty categories/tags with no videos.
+   */
+  async checkCoverage(): Promise<VideoCacheCoverageResponse> {
+    this.logger.log('[Coverage] Starting video cache coverage check');
+
+    const missingCategoryVideoLists: string[] = [];
+    const missingTagVideoLists: string[] = [];
+    const missingTagMetadataLists: string[] = [];
+
+    let totalChannels = 0;
+    let totalCategories = 0;
+    let totalTags = 0;
+
+    // Get all channels
+    const channels = await this.mongoPrisma.channel.findMany();
+
+    totalChannels = channels.length;
+    this.logger.debug(`[Coverage] Found ${totalChannels} channels`);
+
+    for (const channel of channels) {
+      // Get all enabled categories for this channel
+      const categories = await this.mongoPrisma.category.findMany({
+        where: { status: 1, channelId: channel.id },
+      });
+
+      for (const category of categories) {
+        totalCategories++;
+
+        // Count videos in this category (using same filter as builder)
+        const categoryVideoCount = await this.mongoPrisma.videoMedia.count({
+          where: {
+            categoryId: category.id,
+            listStatus: 1, // Only "on shelf" videos
+          },
+        });
+
+        // Check category video list cache key ONLY if there are videos
+        const categoryListKey = tsCacheKeys.video.categoryList(category.id);
+        const categoryListExists = await this.redis.exists(categoryListKey);
+
+        if (categoryVideoCount > 0 && !categoryListExists) {
+          missingCategoryVideoLists.push(categoryListKey);
+          this.logger.warn(
+            `[Coverage] Missing category list: ${categoryListKey} (videos: ${categoryVideoCount})`,
+          );
+        } else if (categoryVideoCount === 0) {
+          this.logger.debug(
+            `[Coverage] Empty category OK (no videos, no cache required): ${categoryListKey}`,
+          );
+        }
+
+        // Check tag metadata list cache key for this category
+        const tagMetadataKey = tsCacheKeys.tag.metadataByCategory(category.id);
+        const tagMetadataExists = await this.redis.exists(tagMetadataKey);
+
+        if (!tagMetadataExists) {
+          missingTagMetadataLists.push(tagMetadataKey);
+          this.logger.warn(
+            `[Coverage] Missing tag metadata list: ${tagMetadataKey}`,
+          );
+        }
+
+        // Get all enabled tags for this category
+        const tags = await this.mongoPrisma.tag.findMany({
+          where: {
+            status: 1,
+            channelId: channel.id,
+            categoryId: category.id,
+          },
+        });
+
+        for (const tag of tags) {
+          totalTags++;
+
+          // Count videos with this tag in this category (same filter as builder)
+          const tagVideoCount = await this.mongoPrisma.videoMedia.count({
+            where: {
+              categoryId: category.id,
+              listStatus: 1, // Only "on shelf" videos
+              tagIds: { has: tag.id }, // Has this specific tag
+            },
+          });
+
+          // Check tag-filtered video list cache key ONLY if there are videos
+          const tagListKey = tsCacheKeys.video.tagList(category.id, tag.id);
+          const tagListExists = await this.redis.exists(tagListKey);
+
+          if (tagVideoCount > 0 && !tagListExists) {
+            missingTagVideoLists.push(tagListKey);
+            this.logger.warn(
+              `[Coverage] Missing tag video list: ${tagListKey} (videos: ${tagVideoCount})`,
+            );
+          } else if (tagVideoCount === 0) {
+            this.logger.debug(
+              `[Coverage] Empty tag OK (no videos, no cache required): ${tagListKey}`,
+            );
+          }
+        }
+      }
+    }
+
+    const totalMissing =
+      missingCategoryVideoLists.length +
+      missingTagVideoLists.length +
+      missingTagMetadataLists.length;
+
+    this.logger.log(
+      `[Coverage] Complete: channels=${totalChannels} categories=${totalCategories} tags=${totalTags} missing=${totalMissing}`,
+    );
+
+    if (totalMissing > 0) {
+      this.logger.warn(
+        `[Coverage] Found ${totalMissing} missing cache keys (categoryLists: ${missingCategoryVideoLists.length}, tagLists: ${missingTagVideoLists.length}, tagMetadata: ${missingTagMetadataLists.length})`,
+      );
+    } else {
+      this.logger.log('[Coverage] All expected cache keys exist');
+    }
+
+    return {
+      totalChannels,
+      totalCategories,
+      totalTags,
+      missingCategoryVideoLists,
+      missingTagVideoLists,
+      missingTagMetadataLists,
+    };
+  }
+}

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

@@ -0,0 +1,177 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { RedisService } from '@box/db/redis/redis.service';
+
+/**
+ * Helper interface for cache key debug info
+ */
+interface CacheKeyDebugInfo {
+  key: string;
+  type: string;
+  length: number;
+  sample: string[];
+}
+
+/**
+ * Response structure for video cache debug endpoint
+ */
+export interface VideoCacheDebugResponse {
+  categoryVideoLists: CacheKeyDebugInfo[];
+  tagVideoLists: CacheKeyDebugInfo[];
+  tagMetadataLists: CacheKeyDebugInfo[];
+  warnings: string[];
+}
+
+/**
+ * Service for debugging video cache state.
+ * Dev-only: inspects Redis keys, validates structure, and reports anomalies.
+ */
+@Injectable()
+export class VideoCacheDebugService {
+  private readonly logger = new Logger(VideoCacheDebugService.name);
+
+  constructor(private readonly redis: RedisService) {}
+
+  /**
+   * Scan and debug video cache keys.
+   * Checks structure, sampling, and logs warnings for anomalies.
+   */
+  async debugVideoCache(): Promise<VideoCacheDebugResponse> {
+    const warnings: string[] = [];
+
+    this.logger.log('📊 Starting video cache debug scan...');
+
+    // Scan each pattern and collect debug info
+    const categoryVideoLists = await this.scanPattern(
+      'box:app:video:category:list:*',
+      'video list',
+      warnings,
+    );
+    const tagVideoLists = await this.scanPattern(
+      'box:app:video:tag:list:*',
+      'video list',
+      warnings,
+    );
+    const tagMetadataLists = await this.scanPattern(
+      'box:app:tag:list:*',
+      'tag metadata',
+      warnings,
+    );
+
+    const message =
+      `✅ Debug scan complete: ${categoryVideoLists.length} category lists, ` +
+      `${tagVideoLists.length} tag-filtered lists, ${tagMetadataLists.length} tag metadata lists`;
+    this.logger.log(message);
+
+    if (warnings.length > 0) {
+      this.logger.warn(`⚠️ Found ${warnings.length} anomalies`);
+    }
+
+    return {
+      categoryVideoLists,
+      tagVideoLists,
+      tagMetadataLists,
+      warnings,
+    };
+  }
+
+  /**
+   * Scan for keys matching a pattern and collect debug info.
+   * @param pattern - Redis key pattern (e.g., 'box:app:video:category:list:*')
+   * @param type - 'video list' or 'tag metadata' for validation context
+   * @param warnings - Array to accumulate warning messages
+   */
+  private async scanPattern(
+    pattern: string,
+    type: 'video list' | 'tag metadata',
+    warnings: string[],
+  ): Promise<CacheKeyDebugInfo[]> {
+    const keys = await this.redis.keys(pattern);
+    const result: CacheKeyDebugInfo[] = [];
+
+    for (const key of keys) {
+      const redisType = await this.redis.type(key);
+      const length = await this.redis.llen(key);
+      const sample = await this.redis.lrange(key, 0, 2);
+
+      result.push({
+        key,
+        type: redisType,
+        length,
+        sample: sample as string[],
+      });
+
+      // Validate sample based on key type
+      if (type === 'video list') {
+        this.validateVideoListSample(key, sample, warnings);
+      } else if (type === 'tag metadata') {
+        this.validateTagMetadataSample(key, sample, warnings);
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Validate a video list sample (should contain 24-char hex video IDs, never JSON).
+   */
+  private validateVideoListSample(
+    key: string,
+    sample: string[],
+    warnings: string[],
+  ): void {
+    for (const element of sample) {
+      // Check if element looks like JSON (starts with {)
+      if (element.startsWith('{')) {
+        const msg = `⚠️ Tag JSON detected in video list key ${key}: "${element.substring(0, 50)}..."`;
+        warnings.push(msg);
+        this.logger.warn(msg);
+      }
+
+      // Check if element looks like a valid video ID (24-char hex)
+      if (!this.isValidVideoId(element)) {
+        const msg = `⚠️ Invalid video ID format in ${key}: "${element}"`;
+        warnings.push(msg);
+        this.logger.warn(msg);
+      }
+    }
+  }
+
+  /**
+   * Validate a tag metadata sample (should be valid JSON with required fields).
+   */
+  private validateTagMetadataSample(
+    key: string,
+    sample: string[],
+    warnings: string[],
+  ): void {
+    for (const element of sample) {
+      try {
+        const parsed = JSON.parse(element);
+
+        // Check for required fields
+        const requiredFields = ['id', 'name', 'categoryId', 'channelId'];
+        const missingFields = requiredFields.filter(
+          (field) => !(field in parsed),
+        );
+
+        if (missingFields.length > 0) {
+          const msg = `⚠️ Tag metadata in ${key} missing fields: [${missingFields.join(', ')}]. Object: ${JSON.stringify(parsed)}`;
+          warnings.push(msg);
+          this.logger.warn(msg);
+        }
+      } catch (error) {
+        const msg = `⚠️ Failed to parse tag metadata in ${key}: "${element}"`;
+        warnings.push(msg);
+        this.logger.warn(msg);
+      }
+    }
+  }
+
+  /**
+   * Check if a string looks like a valid 24-character hex MongoDB ObjectID.
+   */
+  private isValidVideoId(str: string): boolean {
+    // MongoDB ObjectID is typically 24 hex characters
+    return /^[a-fA-F0-9]{24}$/.test(str);
+  }
+}

+ 154 - 0
apps/box-mgnt-api/src/dev/services/video-stats.service.ts

@@ -0,0 +1,154 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+
+/**
+ * Category video statistics
+ */
+export interface CategoryVideoStats {
+  categoryId: string;
+  videoCount: number;
+}
+
+/**
+ * Tag video statistics
+ */
+export interface TagVideoStats {
+  categoryId: string;
+  tagId: string;
+  videoCount: number;
+}
+
+/**
+ * Response structure for video stats endpoint
+ */
+export interface VideoStatsResponse {
+  categories: CategoryVideoStats[];
+  tags: TagVideoStats[];
+}
+
+/**
+ * Service for computing video statistics.
+ * Dev-only: dynamically counts videos per category and per (category, tag) pair.
+ *
+ * ⚠️ DEVELOPMENT ONLY
+ * This service loads ALL enabled channels/categories/tags from the database
+ * and computes video counts. It is NOT imported in production environments.
+ *
+ * See: apps/box-mgnt-api/src/dev/dev-video-cache.module.ts (dev-only module)
+ */
+@Injectable()
+export class VideoStatsService {
+  private readonly logger = new Logger(VideoStatsService.name);
+
+  constructor(private readonly mongoPrisma: MongoPrismaService) {}
+
+  /**
+   * Compute video statistics for ALL enabled channels, categories, and tags.
+   * Dynamically loads database structure and returns counts of videos per category
+   * and per (category, tag) pair.
+   *
+   * Uses same filters as cache builder:
+   * - Categories: status: 1 (enabled), count videos with listStatus: 1 (only "on shelf")
+   * - Tags: status: 1 (enabled), count videos with listStatus: 1 AND tagIds contains tag
+   *
+   * ALGORITHM:
+   * 1. Load all enabled channels
+   * 2. For each channel:
+   *    - Load all enabled categories
+   *    - For each category:
+   *      - Count videos with listStatus: 1 (on shelf)
+   *      - Load all enabled tags
+   *      - For each tag:
+   *        - Count videos with listStatus: 1 AND tagIds has this tag
+   *
+   * @returns {Promise<VideoStatsResponse>} Categories and tags with video counts
+   */
+  async computeStats(): Promise<VideoStatsResponse> {
+    this.logger.log('[VideoStats] Starting video statistics computation');
+
+    const categories: CategoryVideoStats[] = [];
+    const tags: TagVideoStats[] = [];
+
+    // Load all enabled channels
+    const channels = await this.mongoPrisma.channel.findMany();
+    this.logger.debug(`[VideoStats] Found ${channels.length} channels`);
+
+    for (const channel of channels) {
+      // Load all enabled categories for this channel
+      const channelCategories = await this.mongoPrisma.category.findMany({
+        where: {
+          status: 1,
+          channelId: channel.id,
+        },
+      });
+
+      this.logger.debug(
+        `[VideoStats] Channel ${channel.id}: ${channelCategories.length} categories`,
+      );
+
+      for (const category of channelCategories) {
+        // Count videos in this category (same filter as cache builder)
+        const videoCount = await this.mongoPrisma.videoMedia.count({
+          where: {
+            categoryId: category.id,
+            listStatus: 1, // Only "on shelf" videos
+          },
+        });
+
+        categories.push({
+          categoryId: category.id,
+          videoCount,
+        });
+
+        this.logger.debug(
+          `[VideoStats] Category ${category.id}: ${videoCount} videos`,
+        );
+
+        // Load all enabled tags for this category
+        const categoryTags = await this.mongoPrisma.tag.findMany({
+          where: {
+            status: 1,
+            channelId: channel.id,
+            categoryId: category.id,
+          },
+        });
+
+        for (const tag of categoryTags) {
+          // Count videos with this tag in this category (same filter as builder)
+          const tagVideoCount = await this.mongoPrisma.videoMedia.count({
+            where: {
+              categoryId: category.id,
+              listStatus: 1, // Only "on shelf" videos (matches builder)
+              tagIds: { has: tag.id }, // Videos with this tag (matches builder)
+            },
+          });
+
+          tags.push({
+            categoryId: category.id,
+            tagId: tag.id,
+            videoCount: tagVideoCount,
+          });
+
+          this.logger.debug(
+            `[VideoStats] Tag ${tag.id} in category ${category.id}: ${tagVideoCount} videos`,
+          );
+        }
+      }
+    }
+
+    const totalCategoryVideos = categories.reduce(
+      (sum, c) => sum + c.videoCount,
+      0,
+    );
+    const totalTagVideos = tags.reduce((sum, t) => sum + t.videoCount, 0);
+
+    this.logger.log(
+      `[VideoStats] Complete: categories=${categories.length} categoryVideos=${totalCategoryVideos} tags=${tags.length} tagVideos=${totalTagVideos}`,
+    );
+
+    return {
+      categories,
+      tags,
+    };
+  }
+}

+ 16 - 8
apps/box-mgnt-api/src/mgnt-backend/feature/sync-videomedia/sync-videomedia.controller.ts

@@ -1,5 +1,11 @@
 // sync-videomedia.controller.ts
-import { BadRequestException, Controller, Post, Req } from '@nestjs/common';
+import {
+  BadRequestException,
+  Controller,
+  Logger,
+  Post,
+  Req,
+} from '@nestjs/common';
 import {
   ApiTags,
   ApiOperation,
@@ -13,6 +19,8 @@ import { SyncVideomediaService } from './sync-videomedia.service';
 @ApiTags('Sync Video Media')
 @Controller('sync-videomedia')
 export class SyncVideomediaController {
+  private readonly logger = new Logger(SyncVideomediaController.name);
+
   constructor(private readonly syncVideomediaService: SyncVideomediaService) {}
 
   @Post('upload-json')
@@ -52,17 +60,17 @@ export class SyncVideomediaController {
     description: 'Bad request - invalid file format or JSON structure',
   })
   async uploadVideoData(@Req() req: any) {
-    // console.log('[sync-videomedia] content-type:', req.headers['content-type']);
-    // console.log('[sync-videomedia] isMultipart:', typeof req.isMultipart);
-    // console.log('[sync-videomedia] raw:', req.raw);
+    // this.logger.debug('[uploadVideoData] content-type:', req.headers['content-type']);
+    // this.logger.debug('[uploadVideoData] isMultipart:', typeof req.isMultipart);
+    // this.logger.debug('[uploadVideoData] raw:', req.raw);
 
     if (typeof req.isMultipart !== 'function') {
-      console.log('[sync-videomedia] isMultipart is not a function');
+      this.logger.debug('[uploadVideoData] isMultipart is not a function');
       throw new BadRequestException('Multipart not available on request');
     }
 
     const isMultipartResult = req.isMultipart();
-    console.log('[sync-videomedia] isMultipart():', isMultipartResult);
+    this.logger.debug('[uploadVideoData] isMultipart():', isMultipartResult);
 
     if (!isMultipartResult) {
       throw new BadRequestException('Request must be multipart/form-data');
@@ -71,8 +79,8 @@ export class SyncVideomediaController {
     const parts = req.parts();
 
     for await (const part of parts) {
-      // console.log(
-      //   '[sync-videomedia] part:',
+      // this.logger.debug(
+      //   '[uploadVideoData] part:',
       //   part.type,
       //   part.fieldname,
       //   (part as MultipartFile).filename,

+ 21 - 23
apps/box-mgnt-api/src/mgnt-backend/feature/sync-videomedia/sync-videomedia.service.ts

@@ -1,5 +1,5 @@
 // sync-videomedia.service.ts
-import { Injectable, BadRequestException } from '@nestjs/common';
+import { Injectable, BadRequestException, Logger } from '@nestjs/common';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 
 interface RawVideoMedia {
@@ -56,6 +56,8 @@ interface RawVideoMedia {
 
 @Injectable()
 export class SyncVideomediaService {
+  private readonly logger = new Logger(SyncVideomediaService.name);
+
   constructor(private readonly mongo: MongoPrismaService) {}
 
   async list() {
@@ -70,10 +72,10 @@ export class SyncVideomediaService {
    * - [ ... ]
    */
   async syncFromJson(jsonData: unknown) {
-    console.log('[sync-videomedia] syncFromJson called');
+    this.logger.log('[syncFromJson] syncFromJson called');
     const list = this.extractList(jsonData);
 
-    console.log(`[sync-videomedia] Extracted ${list.length} items from JSON`);
+    this.logger.log(`[syncFromJson] Extracted ${list.length} items from JSON`);
 
     if (!list.length) {
       // No data to import; treat as client error for now
@@ -82,10 +84,10 @@ export class SyncVideomediaService {
 
     const normalized = list.map((item) => this.normalizeItem(item));
 
-    console.log(
-      `[sync-videomedia] Ready to import ${normalized.length} records`,
+    this.logger.log(
+      `[syncFromJson] Ready to import ${normalized.length} records`,
     );
-    console.log('[sync-videomedia] First record sample:', normalized[0]);
+    this.logger.debug('[syncFromJson] First record sample:', normalized[0]);
 
     // Batch processing - try to create each record individually and catch duplicate errors
     const BATCH_SIZE = 100;
@@ -96,8 +98,8 @@ export class SyncVideomediaService {
 
     for (let i = 0; i < normalized.length; i += BATCH_SIZE) {
       const batch = normalized.slice(i, i + BATCH_SIZE);
-      console.log(
-        `[sync-videomedia] Processing batch ${i / BATCH_SIZE + 1}, size: ${batch.length}`,
+      this.logger.debug(
+        `[syncFromJson] Processing batch ${i / BATCH_SIZE + 1}, size: ${batch.length}`,
       );
 
       await Promise.all(
@@ -106,12 +108,10 @@ export class SyncVideomediaService {
             // Try to create the record
             await this.mongo.videoMedia.create({ data: record });
             created++;
-            console.log(`[sync-videomedia] Created record: ${record.id}`);
+            this.logger.debug(`[syncFromJson] Created record: ${record.id}`);
           } catch (error: any) {
-            console.log(
-              `[sync-videomedia] Create failed for ${record.id}:`,
-              error.code,
-              error.message?.substring(0, 100),
+            this.logger.debug(
+              `[syncFromJson] Create failed for ${record.id}: ${error.code} ${error.message?.substring(0, 100)}`,
             );
             // If duplicate key error (code 11000), try to update
             if (error.code === 11000 || error.message?.includes('duplicate')) {
@@ -122,19 +122,17 @@ export class SyncVideomediaService {
                   data: updateData,
                 });
                 updated++;
-                console.log(`[sync-videomedia] Updated record: ${id}`);
+                this.logger.debug(`[syncFromJson] Updated record: ${id}`);
               } catch (updateError: any) {
-                console.error(
-                  `[sync-videomedia] Update failed for ${record.id}:`,
-                  updateError.message,
+                this.logger.error(
+                  `[syncFromJson] Update failed for ${record.id}: ${updateError.message}`,
                 );
                 skipped++;
                 errors.push({ id: record.id, error: updateError.message });
               }
             } else {
-              console.error(
-                `[sync-videomedia] Skipped ${record.id}:`,
-                error.message,
+              this.logger.error(
+                `[syncFromJson] Skipped ${record.id}: ${error.message}`,
               );
               skipped++;
               errors.push({ id: record.id, error: error.message });
@@ -144,11 +142,11 @@ export class SyncVideomediaService {
       );
     }
 
-    console.log(
-      `[sync-videomedia] Import complete: ${created} created, ${updated} updated, ${skipped} skipped`,
+    this.logger.log(
+      `[syncFromJson] Import complete: ${created} created, ${updated} updated, ${skipped} skipped`,
     );
     if (errors.length > 0) {
-      console.log(`[sync-videomedia] Errors:`, errors.slice(0, 5));
+      this.logger.log(`[syncFromJson] Errors:`, errors.slice(0, 5));
     }
 
     return {

+ 2 - 0
apps/box-mgnt-api/src/mgnt-backend/mgnt-backend.module.ts

@@ -19,6 +19,7 @@ import { ChannelModule } from './feature/channel/channel.module';
 import { TagModule } from './feature/tag/tag.module';
 import { VideoMediaModule } from './feature/video-media/video-media.module';
 import { HealthModule } from './feature/health/health.module';
+import { CacheSyncModule } from '../cache-sync/cache-sync.module';
 
 @Module({
   imports: [
@@ -44,6 +45,7 @@ import { HealthModule } from './feature/health/health.module';
           SyncVideomediaModule,
           VideoMediaModule,
           HealthModule,
+          CacheSyncModule,
         ],
       },
     ]),

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

@@ -11,33 +11,6 @@ export type VideoSortKey = 'latest' | 'popular' | 'manual';
  */
 export type VideoHomeSectionKey = 'featured' | 'latest' | 'editorPick';
 
-/**
- * Centralized Redis logical keys (without REDIS_KEY_PREFIX).
- * Actual keys in Redis will be: <REDIS_KEY_PREFIX><logicalKey>
- * e.g. "box:" + "app:channel:all" => "box:app:channel:all"
- *
- * ═══════════════════════════════════════════════════════════════════════════
- * REDIS KEY SEMANTICS & CONTRACT
- * ═══════════════════════════════════════════════════════════════════════════
- *
- * This object defines the complete Redis key namespace. Each key has:
- * - Defined Redis type (STRING, LIST, ZSET, SET, etc.)
- * - Element format (video ID strings, JSON objects, etc.)
- * - Ordering rules (if applicable)
- *
- * SEE: libs/common/src/cache/CACHE_SEMANTICS.md for complete documentation
- *
- * KEY NAMING RULES:
- * ─────────────────
- * - ":list:<id>" → LIST of VIDEO IDs (strings only, never JSON)
- * - ":tag:list:<id>" → LIST of VIDEO IDs or Tag JSON (see specific key)
- * - ":pool:<id>:<sort>" → ZSET of video IDs with score
- * - ":detail:<id>" → STRING containing single JSON object
- *
- * ATOMICITY:
- * ──────────
- * All writes use atomic pipelines (DEL + operation) to prevent partial reads.
- */
 export const CacheKeys = {
   // ─────────────────────────────────────────────
   // CHANNELS (existing)
@@ -66,51 +39,10 @@ export const CacheKeys = {
   // ─────────────────────────────────────────────
   // TAGS (new)
   // ─────────────────────────────────────────────
-  /**
-   * Global tag suggestion pool.
-   *
-   * Redis Type: JSON (Array of Tag objects)
-   * Elements: Tag objects (stringified JSON)
-   * Order: seq ascending
-   *
-   * Format per element: { id, name, seq, status, createAt, updateAt, channelId, categoryId }
-   *
-   * SEE: libs/common/src/cache/CACHE_SEMANTICS.md → "Complete Key Reference Table"
-   *
-   * Built by: TagCacheBuilder.buildAll()
-   * Read by: TagCacheService.getAllTags()
-   */
   appTagAll: 'app:tag:all',
 
-  /**
-   * Tag metadata list for a category (used for tag filter UI).
-   *
-   * Redis Type: LIST
-   * Elements: Tag JSON objects (stringified)
-   * Order: seq ascending (business order for dropdown display)
-   * Format per element: { id, name, seq, status, createAt, updateAt, channelId, categoryId }
-   *
-   * Operations:
-   * - Write: DEL + RPUSH (atomic via saveTagList in VideoCacheHelper)
-   * - Read: LRANGE key 0 -1, then parse each element as JSON
-   *
-   * Example: LRANGE "box:app:tag:list:cat-001" 0 -1
-   *          → ['{"id":"tag-1","name":"Action",...}', '{"id":"tag-2","name":"Drama",...}']
-   *
-   * ⚠️ CRITICAL CONTRACT:
-   * ───────────────────
-   * This is the ONLY key where Tag JSON objects should be stored.
-   * - ❌ NEVER store Tag JSON in "box:app:video:category:list:{categoryId}"
-   * - ❌ NEVER store Tag JSON in "box:app:video:tag:list:{categoryId}:{tagId}"
-   * - ✅ ONLY store Video IDs in those video list keys
-   *
-   * Built by: VideoCategoryCacheBuilder.buildTagMetadataListForCategory()
-   * Read by: VideoService.getTagListForCategory()
-   *
-   * SEE: libs/common/src/cache/CACHE_SEMANTICS.md → "Tag Metadata Lists"
-   */
   appTagByCategoryKey: (categoryId: string | number): string =>
-    `app:tag:list:${categoryId}`,
+    `box:app:tag:list:${categoryId}`,
 
   // ─────────────────────────────────────────────
   // ADS (existing)
@@ -137,158 +69,30 @@ export const CacheKeys = {
   // ─────────────────────────────────────────────
   // VIDEO DETAILS & METADATA
   // ─────────────────────────────────────────────
-
-  /**
-   * Single video detail/metadata.
-   *
-   * Redis Type: STRING (JSON object)
-   * Format: { id, title, description, categoryId, tagIds[], status, ... }
-   *
-   * Built by: VideoDetailCacheBuilder (if applicable)
-   * Read by: VideoService.getVideoDetail()
-   */
   appVideoDetailKey: (videoId: string): string => `app:video:detail:${videoId}`,
 
-  /**
-   * Category video list (all videos in a category).
-   *
-   * Redis Type: LIST
-   * Elements: Video IDs (strings, Mongo ObjectId format like "64a2b3c4d5e6f7g8h9i0j1k2")
-   * Order: Descending by business order (seq → newest first)
-   *
-   * ⚠️ CRITICAL CONTRACT:
-   * ────────────────────
-   * - ✅ ONLY VIDEO IDs (strings) are stored here
-   * - ❌ NEVER store JSON objects (video details, tag metadata, category metadata)
-   * - ❌ NEVER store Tag JSON in this key
-   * - For Tag metadata, use "box:app:tag:list:{categoryId}" instead
-   *
-   * Operations:
-   * - Write: DEL + RPUSH (atomic via rpushList)
-   * - Read: LRANGE key 0 -1
-   *
-   * Example: LRANGE "box:app:video:category:list:cat-001" 0 -1
-   *          → ["video-001", "video-002", "video-003"]
-   *
-   * Built by: VideoCategoryCacheBuilder.buildCategoryVideoListForCategory()
-   * Read by: VideoService.getCategoryListForChannel()
-   *
-   * SEE: libs/common/src/cache/CACHE_SEMANTICS.md → "Category Video List"
-   */
   appVideoCategoryListKey: (categoryId: string): string =>
-    `app:video:category:list:${categoryId}`,
+    `box:app:video:category:list:${categoryId}`,
 
-  /**
-   * Category + tag filtered video list (videos in category with specific tag).
-   *
-   * Redis Type: LIST
-   * Elements: Video IDs (strings, Mongo ObjectId format)
-   * Order: Same as category list (descending by business order)
-   *
-   * ⚠️ CRITICAL CONTRACT:
-   * ────────────────────
-   * - ✅ ONLY VIDEO IDs (strings) are stored here
-   * - ❌ NEVER store JSON objects (video details, tag metadata)
-   * - ❌ NEVER store Tag JSON in this key
-   * - This key contains VIDEO IDs filtered by a specific tag
-   * - For Tag metadata, use "box:app:tag:list:{categoryId}" instead
-   *
-   * Operations:
-   * - Write: DEL + RPUSH (atomic via rpushList)
-   * - Read: LRANGE key 0 -1
-   *
-   * Example: LRANGE "box:app:video:tag:list:cat-001:tag-sports" 0 -1
-   *          → ["video-001", "video-003", "video-007"]
-   *
-   * Note: Distinct from "box:app:tag:list:{categoryId}" which stores Tag JSON objects
-   *
-   * Built by: VideoCategoryCacheBuilder.buildTagFilteredVideoListForTag()
-   * Read by: VideoService.getVideosByTag()
-   *
-   * SEE: libs/common/src/cache/CACHE_SEMANTICS.md → "Category + Tag Filtered Video List"
-   */
   appVideoTagListKey: (categoryId: string, tagId: string): string =>
-    `app:video:tag:list:${categoryId}:${tagId}`,
+    `box:app:video:tag:list:${categoryId}:${tagId}`,
 
   // ─────────────────────────────────────────────
   // VIDEO POOLS (sorted listings with scores)
   // ─────────────────────────────────────────────
 
-  /**
-   * Category video pool (scored/sorted list for pagination).
-   *
-   * Redis Type: ZSET (sorted set)
-   * Members: Video IDs (strings)
-   * Scores: Timestamp (editedAt or updatedAt, descending)
-   * Order: Descending by score (newest/latest first)
-   *
-   * Operations:
-   * - Write: DEL + ZADD (atomic via zadd)
-   * - Read: ZREVRANGE key offset offset+limit-1 (for pagination)
-   *
-   * Example: ZREVRANGE "box:app:video:list:category:ch-1:cat-1:latest" 0 19
-   *          → ["video-001", "video-002", ..., "video-020"]
-   *
-   * Note: Distinct from appVideoCategoryListKey which is a LIST, not ZSET
-   *
-   * Built by: VideoListCacheBuilder.buildCategoryPoolsForChannel()
-   * Read by: VideoService.getVideosByCategoryWithPaging()
-   *
-   * SEE: libs/common/src/cache/CACHE_SEMANTICS.md → "Complete Key Reference Table"
-   */
   appVideoCategoryPoolKey: (
     channelId: string,
     categoryId: string,
     sort: VideoSortKey,
   ): string => `app:video:list:category:${channelId}:${categoryId}:${sort}`,
 
-  /**
-   * Tag video pool (scored/sorted list for pagination).
-   *
-   * Redis Type: ZSET (sorted set)
-   * Members: Video IDs (strings)
-   * Scores: Timestamp (editedAt or updatedAt, descending)
-   * Order: Descending by score (newest/latest first)
-   *
-   * Operations:
-   * - Write: DEL + ZADD (atomic via zadd)
-   * - Read: ZREVRANGE key offset offset+limit-1 (for pagination)
-   *
-   * Example: ZREVRANGE "box:app:video:list:tag:ch-1:tag-1:latest" 0 19
-   *          → ["video-001", "video-003", ..., "video-020"]
-   *
-   * Note: Distinct from appVideoTagListKey which is a LIST, not ZSET
-   *
-   * Built by: VideoListCacheBuilder.buildTagPoolsForChannel()
-   * Read by: VideoService.getVideosByTagWithPaging()
-   *
-   * SEE: libs/common/src/cache/CACHE_SEMANTICS.md → "Complete Key Reference Table"
-   */
   appVideoTagPoolKey: (
     channelId: string,
     tagId: string,
     sort: VideoSortKey,
   ): string => `app:video:list:tag:${channelId}:${tagId}:${sort}`,
 
-  /**
-   * Home page section video list.
-   *
-   * Redis Type: LIST
-   * Elements: Video IDs (strings)
-   * Order: Index order (most recent first, top N)
-   *
-   * Operations:
-   * - Write: DEL + RPUSH (atomic via rpushList)
-   * - Read: LRANGE key 0 -1
-   *
-   * Example: LRANGE "box:app:video:list:home:ch-1:latest" 0 -1
-   *          → ["video-001", "video-002", ..., "video-050"]
-   *
-   * Built by: VideoListCacheBuilder.buildHomeSectionsForChannel()
-   * Read by: VideoService.getHomeSectionVideos()
-   *
-   * SEE: libs/common/src/cache/CACHE_SEMANTICS.md → "Complete Key Reference Table"
-   */
   appVideoHomeSectionKey: (
     channelId: string,
     section: VideoHomeSectionKey,

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

@@ -90,6 +90,20 @@ export interface TsCacheKeyBuilder {
      *          → GET and parse as JSON array
      */
     all(): string;
+
+    /**
+     * Get tag metadata (JSON array) for a specific category.
+     * Redis Type: STRING (JSON array)
+     * Elements: Tag metadata objects with id, name, seq, etc.
+     *
+     * ✅ This key contains TAG METADATA (entire tag objects)
+     * ❌ Not video IDs (that's video.tagList)
+     *
+     * Example: tsCacheKeys.tag.metadataByCategory('cat-001')
+     *          → "box:app:tag:list:cat-001"
+     *          → GET and parse as JSON array
+     */
+    metadataByCategory(categoryId: string | number): string;
   };
 
   /**
@@ -227,6 +241,8 @@ export function createTsCacheKeyBuilder(): TsCacheKeyBuilder {
     },
     tag: {
       all: () => CacheKeys.appTagAll,
+      metadataByCategory: (categoryId) =>
+        CacheKeys.appTagByCategoryKey(categoryId),
     },
     ad: {
       byId: (adId) => CacheKeys.appAdById(adId),

+ 23 - 15
libs/common/src/cache/video-cache.helper.ts

@@ -80,8 +80,8 @@ export class VideoCacheHelper {
   ): Promise<void> {
     try {
       if (!videoIds || videoIds.length === 0) {
-        this.logger.debug(`Empty video list, skipping save for key: ${key}`);
-        await this.redis.del(key); // Clear the key if empty
+        await this.redis.del(key);
+        this.logger.debug(`[SaveVideoIdList] Cleared empty key: ${key}`);
         return;
       }
 
@@ -97,12 +97,12 @@ export class VideoCacheHelper {
       }
 
       this.logger.debug(
-        `Saved ${videoIds.length} video IDs to ${key}${ttlSeconds ? ` with TTL ${ttlSeconds}s` : ''}`,
+        `[SaveVideoIdList] Saved key=${key} count=${videoIds.length} ttl=${ttlSeconds ? ttlSeconds + 's' : 'none'}`,
       );
     } catch (err) {
       this.logger.error(
-        `Failed to save video ID list to ${key}`,
-        err instanceof Error ? err.stack : String(err),
+        `[SaveVideoIdList] Failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`,
+        err instanceof Error ? err.stack : undefined,
       );
       throw err;
     }
@@ -192,8 +192,8 @@ export class VideoCacheHelper {
   ): Promise<void> {
     try {
       if (!tags || tags.length === 0) {
-        this.logger.debug(`Empty tag list, skipping save for key: ${key}`);
         await this.redis.del(key);
+        this.logger.debug(`[SaveTagList] Cleared empty key: ${key}`);
         return;
       }
 
@@ -210,12 +210,12 @@ export class VideoCacheHelper {
       }
 
       this.logger.debug(
-        `Saved ${tags.length} tags to ${key}${ttlSeconds ? ` with TTL ${ttlSeconds}s` : ''}`,
+        `[SaveTagList] Saved key=${key} count=${tags.length} ttl=${ttlSeconds ? ttlSeconds + 's' : 'none'}`,
       );
     } catch (err) {
       this.logger.error(
-        `Failed to save tag list to ${key}`,
-        err instanceof Error ? err.stack : String(err),
+        `[SaveTagList] Failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`,
+        err instanceof Error ? err.stack : undefined,
       );
       throw err;
     }
@@ -247,23 +247,31 @@ export class VideoCacheHelper {
       }
 
       const tags: TagMetadata[] = [];
+      let malformedCount = 0;
       for (const item of items) {
         try {
           const parsed = JSON.parse(item) as TagMetadata;
           tags.push(parsed);
         } catch (parseErr) {
-          this.logger.warn(
-            `Failed to parse tag item from ${key}: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
-          );
-          // Skip malformed items
+          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;
     } catch (err) {
       this.logger.error(
-        `Failed to get tag list from ${key}`,
-        err instanceof Error ? err.stack : String(err),
+        `[GetTagList] Failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`,
+        err instanceof Error ? err.stack : undefined,
       );
       return [];
     }

+ 82 - 38
libs/core/src/cache/video/category/video-category-cache.builder.ts

@@ -98,17 +98,20 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
    */
   async buildAll(): Promise<void> {
     const channels = await this.mongoPrisma.channel.findMany();
+    this.logger.log(
+      `[BuildAll] Starting video cache rebuild for ${channels.length} channels`,
+    );
 
     let totalCategories = 0;
     let totalTagFilteredLists = 0;
     let totalTagMetadataLists = 0;
-
-    this.logger.log(
-      `🔨 Starting video cache rebuild for ${channels.length} channels...`,
-    );
+    let totalVideosProcessed = 0;
+    let totalTagsProcessed = 0;
 
     for (const channel of channels) {
       try {
+        this.logger.debug(`[BuildAll] Processing channel: ${channel.id}`);
+
         // Build video lists for each category
         const categories = await this.mongoPrisma.category.findMany({
           where: {
@@ -118,13 +121,15 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
         });
 
         this.logger.debug(
-          `  Channel ${channel.id}: Processing ${categories.length} categories`,
+          `[BuildAll] Channel ${channel.id}: Found ${categories.length} categories`,
         );
 
         for (const category of categories) {
           // 1. Build list of all video IDs in this category
-          await this.buildCategoryVideoListForCategory(category.id);
+          const categoryVideoCount =
+            await this.buildCategoryVideoListForCategory(category.id);
           totalCategories++;
+          totalVideosProcessed += categoryVideoCount;
 
           // 2. Build tag-filtered video lists
           const tags = await this.mongoPrisma.tag.findMany({
@@ -136,33 +141,36 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
           });
 
           for (const tag of tags) {
-            await this.buildTagFilteredVideoListForTag(category.id, tag.id);
+            const tagVideoCount = await this.buildTagFilteredVideoListForTag(
+              category.id,
+              tag.id,
+            );
             totalTagFilteredLists++;
           }
 
+          totalTagsProcessed += tags.length;
+
           // 3. Build tag metadata list for this category
-          await this.buildTagMetadataListForCategory(category.id);
+          const tagMetadataCount = await this.buildTagMetadataListForCategory(
+            category.id,
+          );
           totalTagMetadataLists++;
         }
 
-        this.logger.log(
-          `  ✅ Channel ${channel.id}: ${categories.length} categories, ${totalTagFilteredLists} tag filters`,
+        this.logger.debug(
+          `[BuildAll] Channel ${channel.id} complete: ${categories.length} categories`,
         );
       } catch (err) {
         this.logger.error(
-          `  ❌ Error building video cache for channel ${channel.id}`,
-          err instanceof Error ? err.stack : String(err),
+          `[BuildAll] Failed for channel ${channel.id}: ${err instanceof Error ? err.message : String(err)}`,
+          err instanceof Error ? err.stack : undefined,
         );
       }
     }
 
-    this.logger.log(`
-📊 Video Cache Rebuild Summary:
-   ├─ Channels processed: ${channels.length}
-   ├─ Category video lists built: ${totalCategories} (box:app:video:category:list:*)
-   ├─ Tag-filtered video lists built: ${totalTagFilteredLists} (box:app:video:tag:list:*)
-   └─ Tag metadata lists built: ${totalTagMetadataLists} (box:app:tag:list:*)
-`);
+    this.logger.log(
+      `[BuildAll] Rebuild complete: channels=${channels.length} categories=${totalCategories} videos=${totalVideosProcessed} tagLists=${totalTagFilteredLists} tagMetadataLists=${totalTagMetadataLists}`,
+    );
   }
 
   /**
@@ -194,13 +202,25 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
    * - If Redis operation fails: log error with stack trace and re-throw
    * - All operations are atomic (DEL + RPUSH + EXPIRE)
    */
-  async buildCategoryVideoListForCategory(categoryId: string): Promise<void> {
+  async buildCategoryVideoListForCategory(categoryId: string): Promise<number> {
     try {
-      // Fetch all videos in this category, ordered by addedTime (provider's timestamp)
+      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: {
           categoryId,
-          listStatus: 1, // Only "on shelf" videos
+          listStatus: 1, // Only "on shelf" videos (matches stats endpoint)
         },
         orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
         select: { id: true }, // Only fetch IDs, not full documents
@@ -210,21 +230,20 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
       const key = tsCacheKeys.video.categoryList(categoryId);
 
       if (videoIds.length === 0) {
-        this.logger.debug(
-          `No videos found for category ${categoryId}, clearing cache key`,
-        );
+        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);
 
       this.logger.debug(
-        `Built category video list for categoryId=${categoryId}: ${videoIds.length} videos stored`,
+        `[CategoryList] Complete: ${categoryId} → ${videoIds.length} videos`,
       );
+      return videoIds.length;
     } catch (err) {
       this.logger.error(
-        `Failed to build category video list for categoryId=${categoryId}`,
-        err instanceof Error ? err.stack : String(err),
+        `[CategoryList] Failed for categoryId=${categoryId}: ${err instanceof Error ? err.message : String(err)}`,
+        err instanceof Error ? err.stack : undefined,
       );
       throw err;
     }
@@ -263,14 +282,29 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
   async buildTagFilteredVideoListForTag(
     categoryId: string,
     tagId: string,
-  ): Promise<void> {
+  ): Promise<number> {
     try {
-      // Fetch all videos in this category with this tag
+      this.logger.debug(
+        `[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: {
           categoryId,
-          listStatus: 1, // Only "on shelf" videos
-          tagIds: { has: tagId }, // Has this specific tag
+          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
@@ -281,7 +315,7 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
 
       if (videoIds.length === 0) {
         this.logger.debug(
-          `No videos found for category ${categoryId} with tag ${tagId}, clearing cache key`,
+          `[TagList] Empty tag: categoryId=${categoryId}, tagId=${tagId}`,
         );
       }
 
@@ -289,12 +323,13 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
       await this.cacheHelper.saveVideoIdList(key, videoIds);
 
       this.logger.debug(
-        `Built tag video list for categoryId=${categoryId}, tagId=${tagId}: ${videoIds.length} videos stored`,
+        `[TagList] Complete: categoryId=${categoryId}, tagId=${tagId} → ${videoIds.length} videos`,
       );
+      return videoIds.length;
     } catch (err) {
       this.logger.error(
-        `Failed to build tag video list for categoryId=${categoryId}, tagId=${tagId}`,
-        err instanceof Error ? err.stack : String(err),
+        `[TagList] Failed for categoryId=${categoryId}, tagId=${tagId}: ${err instanceof Error ? err.message : String(err)}`,
+        err instanceof Error ? err.stack : undefined,
       );
       throw err;
     }
@@ -349,14 +384,20 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
    *   categoryId: string
    * }
    */
-  async buildTagMetadataListForCategory(categoryId: string): Promise<void> {
+  async buildTagMetadataListForCategory(categoryId: string): Promise<number> {
     try {
+      this.logger.debug(`[TagMeta] building for categoryId=${categoryId}`);
+
       // Fetch all enabled tags for this category, ordered by seq
       const tags = await this.mongoPrisma.tag.findMany({
         where: { status: 1, categoryId },
         orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
       });
 
+      this.logger.debug(
+        `[TagMeta] categoryId=${categoryId}, tags found=${tags.length}`,
+      );
+
       if (tags.length === 0) {
         this.logger.debug(
           `No tags found for category ${categoryId}, clearing cache key`,
@@ -375,7 +416,9 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
         categoryId: tag.categoryId,
       }));
 
-      const key = `box:app:tag:list:${categoryId}`; // Direct key construction
+      const key = tsCacheKeys.tag.metadataByCategory(categoryId);
+
+      this.logger.debug(`[TagMeta] Redis key=${key}`);
 
       // Atomic write: DEL existing key, RPUSH tag JSON, set TTL if configured
       await this.cacheHelper.saveTagList(key, tagPayloads);
@@ -383,6 +426,7 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
       this.logger.debug(
         `Built tag metadata list for categoryId=${categoryId}: ${tagPayloads.length} tags stored`,
       );
+      return tagPayloads.length;
     } catch (err) {
       this.logger.error(
         `Failed to build tag metadata list for categoryId=${categoryId}`,