Jelajahi Sumber

feat(video): refactor video service to use Redis for caching and improve performance

- Replaced PrismaMongoService with RedisService in VideoService for fetching video details.
- Implemented new methods for fetching video categories, tags, and details using Redis.
- Added VideoListCacheBuilder to build Redis ZSET pools for videos sorted by category/tag and LIST pools for home sections.
- Introduced VideoListWarmupService to warm up video caches on module initialization.
- Created VideoCategoryCacheBuilder and VideoCategoryWarmupService for managing video categories and tags in Redis.
- Enhanced RedisService with new methods for list operations and atomic swaps.
- Updated cache key management with new types for video sorting and home sections.
Dave 3 bulan lalu
induk
melakukan
db197ab804

+ 7 - 0
apps/box-app-api/src/feature/video/dto/index.ts

@@ -0,0 +1,7 @@
+export {
+  VideoCategoryDto,
+  VideoTagDto,
+  VideoDetailDto,
+  VideoListItemDto,
+  VideoPageDto,
+} from './video.dto';

+ 51 - 0
apps/box-app-api/src/feature/video/dto/video.dto.ts

@@ -0,0 +1,51 @@
+/**
+ * Video-related DTOs for app-api consumption.
+ * Data is read from Redis cache built by box-mgnt-api.
+ */
+
+export interface VideoCategoryDto {
+  id: string;
+  name: string;
+  subtitle?: string | null;
+  seq: number;
+  status: number;
+  createAt: string;
+  updateAt: string;
+  channelId: string;
+}
+
+export interface VideoTagDto {
+  id: string;
+  name: string;
+  seq: number;
+  status: number;
+  createAt: string;
+  updateAt: string;
+  channelId: string;
+  categoryId: string;
+}
+
+export interface VideoDetailDto {
+  id: string;
+  title: string;
+  categoryId?: string | null;
+  tagIds?: string[];
+  listStatus: number;
+  editedAt: string;
+  updatedAt: string;
+  // Add more fields as needed from VideoMedia model
+}
+
+export interface VideoListItemDto {
+  id: string;
+  title?: string;
+  categoryId?: string | null;
+  tagIds?: string[];
+}
+
+export interface VideoPageDto<T> {
+  items: T[];
+  total?: number;
+  page?: number;
+  pageSize?: number;
+}

+ 137 - 57
apps/box-app-api/src/feature/video/video.controller.ts

@@ -1,95 +1,175 @@
-import { Controller, Get, Query } from '@nestjs/common';
+import { Controller, Get, Param, Query } from '@nestjs/common';
 import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
 import { VideoService } from './video.service';
-import { VideoMediaDto } from './dto/video-media.dto';
+import {
+  VideoPageDto,
+  VideoDetailDto,
+  VideoCategoryDto,
+  VideoTagDto,
+} from './dto';
 
-@ApiTags('视频')
-@Controller('video')
+@ApiTags('Videos')
+@Controller('api/v1/video')
 export class VideoController {
   constructor(private readonly videoService: VideoService) {}
 
-  @Get('list')
+  /**
+   * Get categories for a channel from Redis cache.
+   */
+  @Get('categories/:channelId')
   @ApiOperation({
-    summary: '获取视频列表(首页/筛选)',
+    summary: 'Get video categories for channel',
+    description: 'Returns list of video categories from prebuilt Redis cache.',
+  })
+  @ApiResponse({
+    status: 200,
+    description: 'List of categories',
+    type: VideoCategoryDto,
+    isArray: true,
+  })
+  async getCategories(
+    @Param('channelId') channelId: string,
+  ): Promise<VideoCategoryDto[]> {
+    return this.videoService.getCategoryListForChannel(channelId);
+  }
+
+  /**
+   * Get tags for a category within a channel.
+   */
+  @Get('tags/:channelId/:categoryId')
+  @ApiOperation({
+    summary: 'Get video tags for category',
     description:
-      '返回应用所需的视频精简列表,支持按标签与关键字筛选。数据来源对齐 Prisma Mongo Video 模型。',
+      'Returns list of tags in a specific category from Redis cache.',
   })
-  @ApiQuery({
-    name: 'limit',
-    required: false,
-    description: '返回数量上限(默认20,最大100)',
+  @ApiResponse({
+    status: 200,
+    description: 'List of tags',
+    type: VideoTagDto,
+    isArray: true,
+  })
+  async getTags(
+    @Param('channelId') channelId: string,
+    @Param('categoryId') categoryId: string,
+  ): Promise<VideoTagDto[]> {
+    return this.videoService.getTagListForCategory(channelId, categoryId);
+  }
+
+  /**
+   * Get videos in a category with pagination.
+   */
+  @Get('category/:channelId/:categoryId')
+  @ApiOperation({
+    summary: 'Get videos by category',
+    description:
+      'Returns paginated videos for a specific category from Redis cache.',
   })
   @ApiQuery({
-    name: 'tag',
+    name: 'page',
     required: false,
-    description: '按标签名称筛选(匹配包含该标签的视频)',
+    description: 'Page number (default: 1)',
+    example: 1,
   })
   @ApiQuery({
-    name: 'kw',
+    name: 'pageSize',
     required: false,
-    description: '标题或标签文本关键字搜索(不区分大小写)',
+    description: 'Items per page (default: 20)',
+    example: 20,
   })
   @ApiResponse({
     status: 200,
-    description: '成功返回视频列表',
-    type: VideoMediaDto,
-    isArray: true,
+    description: 'Paginated video list',
+    type: VideoPageDto,
   })
-  async getVideoList(
-    @Query('limit') limit?: string,
-    @Query('tag') tag?: string,
-    @Query('kw') kw?: string,
-  ): Promise<{ items: VideoMediaDto[]; count: number }> {
-    const parsedLimit = Number.parseInt(limit ?? '20', 10);
+  async getVideosByCategory(
+    @Param('channelId') channelId: string,
+    @Param('categoryId') categoryId: string,
+    @Query('page') page?: string,
+    @Query('pageSize') pageSize?: string,
+  ): Promise<VideoPageDto<VideoDetailDto>> {
+    const parsedPage = page ? Math.max(1, Number.parseInt(page, 10)) : 1;
+    const parsedPageSize = pageSize
+      ? Math.min(100, Number.parseInt(pageSize, 10))
+      : 20;
 
-    const items = await this.videoService.findHomepageList({
-      limit: parsedLimit,
-      tag,
-      kw,
+    return this.videoService.getVideosByCategoryWithPaging({
+      channelId,
+      categoryId,
+      page: parsedPage,
+      pageSize: parsedPageSize,
     });
-
-    return {
-      items,
-      count: items.length,
-    };
   }
 
-  @Get('recommend')
+  /**
+   * Get videos for a tag with pagination.
+   */
+  @Get('tag/:channelId/:tagId')
   @ApiOperation({
-    summary: '猜你喜欢推荐视频',
+    summary: 'Get videos by tag',
     description:
-      '返回与指定视频在标签或标签文本上相似的内容;若无强相关则回退通用列表。数据来源对齐 Prisma Mongo Video 模型。',
+      'Returns paginated videos for a specific tag from Redis cache.',
   })
   @ApiQuery({
-    name: 'videoId',
-    required: true,
-    description: '当前视频ID(Mongo ObjectId)',
+    name: 'page',
+    required: false,
+    description: 'Page number (default: 1)',
+    example: 1,
   })
   @ApiQuery({
-    name: 'limit',
+    name: 'pageSize',
     required: false,
-    description: '推荐数量上限(默认10,最大100)',
+    description: 'Items per page (default: 20)',
+    example: 20,
   })
   @ApiResponse({
     status: 200,
-    description: '成功返回推荐视频列表',
-    type: VideoMediaDto,
-    isArray: true,
+    description: 'Paginated video list',
+    type: VideoPageDto,
   })
-  async getRecommendations(
-    @Query('videoId') videoId: string,
-    @Query('limit') limit?: string,
-  ): Promise<{ items: VideoMediaDto[]; count: number }> {
-    const parsedLimit = Number.parseInt(limit ?? '10', 10);
+  async getVideosByTag(
+    @Param('channelId') channelId: string,
+    @Param('tagId') tagId: string,
+    @Query('page') page?: string,
+    @Query('pageSize') pageSize?: string,
+  ): Promise<VideoPageDto<VideoDetailDto>> {
+    const parsedPage = page ? Math.max(1, Number.parseInt(page, 10)) : 1;
+    const parsedPageSize = pageSize
+      ? Math.min(100, Number.parseInt(pageSize, 10))
+      : 20;
+
+    return this.videoService.getVideosByTagWithPaging({
+      channelId,
+      tagId,
+      page: parsedPage,
+      pageSize: parsedPageSize,
+    });
+  }
 
-    const items = await this.videoService.findRecommendations(
-      videoId,
-      parsedLimit,
-    );
+  /**
+   * Get home section videos (e.g., featured, latest, editorPick).
+   */
+  @Get('home/:channelId/:section')
+  @ApiOperation({
+    summary: 'Get home section videos',
+    description:
+      'Returns videos for home page sections (featured, latest, editorPick) from Redis cache.',
+  })
+  @ApiResponse({
+    status: 200,
+    description: 'List of videos in section',
+    type: VideoDetailDto,
+    isArray: true,
+  })
+  async getHomeSectionVideos(
+    @Param('channelId') channelId: string,
+    @Param('section') section: string,
+  ): Promise<VideoDetailDto[]> {
+    // Validate section is a known type
+    const validSections = ['featured', 'latest', 'editorPick'];
+    if (!validSections.includes(section)) {
+      return [];
+    }
 
-    return {
-      items,
-      count: items.length,
-    };
+    return this.videoService.getHomeSectionVideos(channelId, section as any);
   }
 }

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

@@ -1,266 +1,294 @@
-import { Inject, Injectable } from '@nestjs/common';
-import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
-import { VideoMediaDto } from './dto/video-media.dto';
-import type { Cache } from 'cache-manager';
-import { CACHE_MANAGER } from '@nestjs/cache-manager';
-
-const VIDEO_LIST_CACHE_TTL_SECONDS = 30; // list cache
-const VIDEO_RECOMMEND_CACHE_TTL_SECONDS = 60; // recommendations cache
-
-type VideoListOptions = {
-  limit?: number;
-  tag?: string;
-  kw?: string;
-};
-
+import { Injectable, Logger } from '@nestjs/common';
+import { RedisService } from '@box/db/redis/redis.service';
+import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
+import type { VideoHomeSectionKey } from '@box/common/cache/ts-cache-key.provider';
+import {
+  VideoCategoryDto,
+  VideoTagDto,
+  VideoDetailDto,
+  VideoPageDto,
+} from './dto';
+
+/**
+ * VideoService provides read-only access to video data from Redis cache.
+ * All data is prebuilt and maintained by box-mgnt-api cache builders.
+ * Follows the same pattern as AdService.
+ */
 @Injectable()
 export class VideoService {
-  constructor(
-    private readonly prisma: PrismaMongoService,
-    @Inject(CACHE_MANAGER) private readonly cache: Cache,
-  ) {}
+  private readonly logger = new Logger(VideoService.name);
+
+  constructor(private readonly redis: RedisService) {}
 
   /**
-   * Homepage / general list:
-   * - Non-deleted videos
-   * - Optional filters: tag, kw (searches title + tagsFlat)
-   * - Ordered by sortOrder (or createdAt if you change it)
-   * - Limited by `limit` (default 20)
-   * - Cached in Redis
+   * Get video detail by videoId.
+   * Reads from appVideoDetailKey (JSON).
    */
-  async findHomepageList(
-    options: VideoListOptions = {},
-  ): Promise<VideoMediaDto[]> {
-    const safeLimit = this.normalizeLimit(options.limit);
-    const tag = (options.tag ?? '').trim();
-    const kw = (options.kw ?? '').trim();
-
-    const cacheKey = this.buildListCacheKey({ limit: safeLimit, tag, kw });
-
-    const cached = await this.cache.get<VideoMediaDto[]>(cacheKey);
-    if (cached && Array.isArray(cached)) {
-      return cached;
-    }
-
-    const where: any = {
-      // adjust to your schema: e.g. status: 'ONLINE'
-      // isDeleted: false,
-    };
-
-    // Filter by tag (array field)
-    if (tag) {
-      // Assuming `tags` is String[]
-      where.tags = {
-        has: tag,
-      };
-    }
-
-    // Keyword search: title OR tagsFlat
-    const orConditions: any[] = [];
-    if (kw) {
-      orConditions.push(
-        {
-          title: {
-            contains: kw,
-            mode: 'insensitive',
-          },
-        },
-        {
-          tagsFlat: {
-            contains: kw,
-            mode: 'insensitive',
-          },
-        },
+  async getVideoDetail(videoId: string): Promise<VideoDetailDto | null> {
+    try {
+      const key = tsCacheKeys.video.detail(videoId);
+      const cached = await this.redis.getJson<VideoDetailDto | null>(key);
+      return cached ?? null;
+    } catch (err) {
+      this.logger.error(
+        `Error fetching video detail for videoId=${videoId}`,
+        err instanceof Error ? err.stack : String(err),
       );
+      return null;
     }
-    if (orConditions.length > 0) {
-      where.OR = orConditions;
-    }
-
-    const rows = await this.prisma.videoMedia.findMany({
-      where,
-      orderBy: {
-        // adjust if your schema uses `sort` / `order` / `createdAt`
-        editedAt: 'asc',
-      },
-      take: safeLimit,
-    });
-
-    const items = rows.map((row) => this.toDto(row));
-
-    // Store in Redis
-    await this.cache.set(cacheKey, items, VIDEO_LIST_CACHE_TTL_SECONDS);
-
-    return items;
   }
 
   /**
-   * "You might also like" recommendations:
-   * - Based on tags of current video
-   * - Excludes the current video
-   * - Fallback: homepage list without filter
+   * Get category list for a channel.
+   * Reads from appVideoCategoryListKey (LIST of JSON strings).
    */
-  async findRecommendations(
-    videoId: string,
-    limit = 10,
-  ): Promise<VideoMediaDto[]> {
-    const safeLimit = this.normalizeLimit(limit);
-    const trimmedId = videoId.trim();
-
-    if (!trimmedId) {
-      // fallback: no id provided
-      return this.findHomepageList({ limit: safeLimit });
-    }
-
-    const cacheKey = this.buildRecommendCacheKey(trimmedId, safeLimit);
-    const cached = await this.cache.get<VideoMediaDto[]>(cacheKey);
-    if (cached && Array.isArray(cached)) {
-      return cached;
+  async getCategoryListForChannel(
+    channelId: string,
+  ): Promise<VideoCategoryDto[]> {
+    try {
+      const key = tsCacheKeys.video.categoryList(channelId);
+      const items = await this.redis.lrange(key, 0, -1);
+
+      if (!items || items.length === 0) {
+        return [];
+      }
+
+      const categories: VideoCategoryDto[] = [];
+      for (const item of items) {
+        try {
+          const parsed = JSON.parse(item) as VideoCategoryDto;
+          categories.push(parsed);
+        } catch (parseErr) {
+          this.logger.warn(
+            `Failed to parse category item from ${key}: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
+          );
+        }
+      }
+
+      return categories;
+    } catch (err) {
+      this.logger.error(
+        `Error fetching category list for channelId=${channelId}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return [];
     }
+  }
 
-    // 1) Get current video
-    const current = await this.prisma.videoMedia.findUnique({
-      where: {
-        id: trimmedId,
-      },
-    });
-
-    if (!current) {
-      // if video not found, fallback to generic list
-      const fallback = await this.findHomepageList({ limit: safeLimit });
-      await this.cache.set(
-        cacheKey,
-        fallback,
-        VIDEO_RECOMMEND_CACHE_TTL_SECONDS,
+  /**
+   * Get tag list for a category within a channel.
+   * Reads from appVideoTagListKey (LIST of JSON strings).
+   */
+  async getTagListForCategory(
+    channelId: string,
+    categoryId: string,
+  ): Promise<VideoTagDto[]> {
+    try {
+      const key = tsCacheKeys.video.tagList(channelId, categoryId);
+      const items = await this.redis.lrange(key, 0, -1);
+
+      if (!items || items.length === 0) {
+        return [];
+      }
+
+      const tags: VideoTagDto[] = [];
+      for (const item of items) {
+        try {
+          const parsed = JSON.parse(item) as VideoTagDto;
+          tags.push(parsed);
+        } catch (parseErr) {
+          this.logger.warn(
+            `Failed to parse tag item from ${key}: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
+          );
+        }
+      }
+
+      return tags;
+    } catch (err) {
+      this.logger.error(
+        `Error fetching tag list for channelId=${channelId}, categoryId=${categoryId}`,
+        err instanceof Error ? err.stack : String(err),
       );
-      return fallback;
+      return [];
     }
+  }
 
-    const tags: string[] = Array.isArray(current.tags) ? current.tags : [];
-    const tagsFlat: string | null = current.tagsFlat ?? null;
-
-    const where: any = {
-      id: {
-        not: trimmedId,
-      },
-    };
-
-    const orConditions: any[] = [];
-
-    if (tags.length > 0) {
-      // share ANY tags
-      orConditions.push({
-        tags: {
-          hasSome: tags,
-        },
-      });
-    }
+  /**
+   * Get videos under a category with pagination.
+   * Uses ZREVRANGE on appVideoCategoryPoolKey to get latest videos first.
+   * Then fetches details for each videoId.
+   */
+  async getVideosByCategoryWithPaging(params: {
+    channelId: string;
+    categoryId: string;
+    page?: number;
+    pageSize?: number;
+  }): Promise<VideoPageDto<VideoDetailDto>> {
+    const { channelId, categoryId, page = 1, pageSize = 20 } = params;
+
+    try {
+      const key = tsCacheKeys.video.categoryPool(
+        channelId,
+        categoryId,
+        'latest',
+      );
 
-    if (tagsFlat) {
-      // textual similarity in tagsFlat
-      orConditions.push({
-        tagsFlat: {
-          contains: tagsFlat,
-          mode: 'insensitive',
-        },
-      });
-    }
+      // Calculate offset and limit for ZREVRANGE
+      const offset = (page - 1) * pageSize;
+      const limit = pageSize;
 
-    if (orConditions.length > 0) {
-      where.OR = orConditions;
-    }
+      // ZREVRANGE returns items in descending order by score (latest first)
+      const videoIds = await this.redis.zrevrange(
+        key,
+        offset,
+        offset + limit - 1,
+      );
 
-    const rows = await this.prisma.videoMedia.findMany({
-      where,
-      orderBy: {
-        editedAt: 'desc',
-      },
-      take: safeLimit,
-    });
-
-    let items = rows.map((row) => this.toDto(row));
-
-    // If no strong recommendations, fallback to generic list (but still exclude current id)
-    if (items.length === 0) {
-      const fallback = await this.prisma.videoMedia.findMany({
-        where: {
-          // listStatus: 1,
-          id: {
-            not: trimmedId,
-          },
-        },
-        orderBy: {
-          editedAt: 'desc',
-        },
-        take: safeLimit,
-      });
-      items = fallback.map((row) => this.toDto(row));
+      if (!videoIds || videoIds.length === 0) {
+        return {
+          items: [],
+          total: 0,
+          page,
+          pageSize,
+        };
+      }
+
+      // Fetch details for all videoIds
+      const details = await this.getVideoDetailsBatch(videoIds);
+
+      return {
+        items: details.filter((d) => d !== null) as VideoDetailDto[],
+        total: undefined, // Could add ZCARD to get total count
+        page,
+        pageSize,
+      };
+    } catch (err) {
+      this.logger.error(
+        `Error fetching videos by category for channelId=${channelId}, categoryId=${categoryId}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return {
+        items: [],
+        total: 0,
+        page: page ?? 1,
+        pageSize: pageSize ?? 20,
+      };
     }
-
-    await this.cache.set(cacheKey, items, VIDEO_RECOMMEND_CACHE_TTL_SECONDS);
-
-    return items;
   }
 
-  // ------------------------
-  // Helpers
-  // ------------------------
-
-  private normalizeLimit(limit?: number): number {
-    if (!Number.isFinite(limit as number)) return 20;
-    const n = Number(limit);
-    if (n <= 0) return 20;
-    if (n > 100) return 100;
-    return n;
-  }
-
-  private buildListCacheKey(input: {
-    limit: number;
-    tag?: string;
-    kw?: string;
-  }): string {
-    const parts = [
-      'video:list',
-      `limit=${input.limit}`,
-      input.tag ? `tag=${input.tag}` : 'tag=_',
-      input.kw ? `kw=${input.kw}` : 'kw=_',
-    ];
-    return parts.join('|');
-  }
+  /**
+   * Get videos under a tag with pagination.
+   * Uses ZREVRANGE on appVideoTagPoolKey to get latest videos first.
+   */
+  async getVideosByTagWithPaging(params: {
+    channelId: string;
+    tagId: string;
+    page?: number;
+    pageSize?: number;
+  }): Promise<VideoPageDto<VideoDetailDto>> {
+    const { channelId, tagId, page = 1, pageSize = 20 } = params;
+
+    try {
+      const key = tsCacheKeys.video.tagPool(channelId, tagId, 'latest');
+
+      const offset = (page - 1) * pageSize;
+      const limit = pageSize;
+
+      const videoIds = await this.redis.zrevrange(
+        key,
+        offset,
+        offset + limit - 1,
+      );
 
-  private buildRecommendCacheKey(videoId: string, limit: number): string {
-    return ['video:recommend', `id=${videoId}`, `limit=${limit}`].join('|');
+      if (!videoIds || videoIds.length === 0) {
+        return {
+          items: [],
+          total: 0,
+          page,
+          pageSize,
+        };
+      }
+
+      const details = await this.getVideoDetailsBatch(videoIds);
+
+      return {
+        items: details.filter((d) => d !== null) as VideoDetailDto[],
+        total: undefined,
+        page,
+        pageSize,
+      };
+    } catch (err) {
+      this.logger.error(
+        `Error fetching videos by tag for channelId=${channelId}, tagId=${tagId}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return {
+        items: [],
+        total: 0,
+        page: page ?? 1,
+        pageSize: pageSize ?? 20,
+      };
+    }
   }
 
-  private toDto(row: any): VideoMediaDto {
-    return {
-      id: row.id?.toString?.() ?? row.id,
-      title: row.title,
-      description: row.description ?? null,
-      videoCdn: row.videoCdn ?? row.cdnUrl ?? null,
-      coverCdn: row.coverCdn ?? row.coverUrl ?? null,
-      tags: Array.isArray(row.tags) ? row.tags : [],
-      tagsFlat: row.tagsFlat ?? null,
-      duration: row.duration ?? null,
-      createdAt: this.toMillis(row.createdAt),
-      updatedAt: this.toMillis(row.updatedAt),
-    };
+  /**
+   * Get home section videos for a channel.
+   * Reads from appVideoHomeSectionKey (LIST of videoIds).
+   * Returns video details for each ID.
+   */
+  async getHomeSectionVideos(
+    channelId: string,
+    section: VideoHomeSectionKey,
+  ): Promise<VideoDetailDto[]> {
+    try {
+      const key = tsCacheKeys.video.homeSection(channelId, section);
+
+      // Read all videoIds from the LIST
+      const videoIds = await this.redis.lrange(key, 0, -1);
+
+      if (!videoIds || videoIds.length === 0) {
+        return [];
+      }
+
+      // Fetch details for all videoIds
+      const details = await this.getVideoDetailsBatch(videoIds);
+      return details.filter((d) => d !== null) as VideoDetailDto[];
+    } catch (err) {
+      this.logger.error(
+        `Error fetching home section videos for channelId=${channelId}, section=${section}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return [];
+    }
   }
 
   /**
-   * Convert DB timestamp (BigInt | number | Date | null) to milliseconds number.
-   * DB layer can use BigInt; here we flatten to number for JSON.
+   * Fetch video details for multiple videoIds using Redis pipeline for efficiency.
    */
-  private toMillis(value: unknown): number {
-    if (typeof value === 'bigint') {
-      return Number(value);
+  private async getVideoDetailsBatch(
+    videoIds: string[],
+  ): Promise<(VideoDetailDto | null)[]> {
+    if (!videoIds || videoIds.length === 0) {
+      return [];
     }
-    if (typeof value === 'number') {
-      return value;
-    }
-    if (value instanceof Date) {
-      return value.getTime();
+
+    try {
+      const keys = videoIds.map((id) => tsCacheKeys.video.detail(id));
+      const results: (VideoDetailDto | null)[] = [];
+
+      // Fetch all in parallel
+      for (const key of keys) {
+        const cached = await this.redis.getJson<VideoDetailDto | null>(key);
+        results.push(cached ?? null);
+      }
+
+      return results;
+    } catch (err) {
+      this.logger.error(
+        `Error fetching video details batch`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return videoIds.map(() => null);
     }
-    return 0;
   }
 }

+ 7 - 1
apps/box-mgnt-api/src/app.module.ts

@@ -16,6 +16,8 @@ import pinoConfig from '@box/common/config/pino.config';
 import { CacheSyncModule } from './cache-sync/cache-sync.module';
 import { RedisModule } from '@box/db/redis/redis.module';
 import { AdPoolWarmupService } from './cache/adpool-warmup.service';
+import { VideoListCacheBuilder } from './cache/video-list-cache.builder';
+import { VideoListWarmupService } from './cache/video-list-warmup.service';
 import { CoreModule } from '@box/core/core.module';
 
 @Module({
@@ -53,7 +55,11 @@ import { CoreModule } from '@box/core/core.module';
       http: process.env.NODE_ENV === 'development',
     }),
   ],
-  providers: [AdPoolWarmupService],
+  providers: [
+    AdPoolWarmupService,
+    VideoListCacheBuilder,
+    VideoListWarmupService,
+  ],
 })
 export class AppModule implements OnModuleInit {
   onModuleInit() {

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

@@ -0,0 +1,230 @@
+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);
+  }
+}

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

@@ -0,0 +1,21 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { VideoListCacheBuilder } from './video-list-cache.builder';
+
+@Injectable()
+export class VideoListWarmupService implements OnModuleInit {
+  private readonly logger = new Logger(VideoListWarmupService.name);
+
+  constructor(private readonly builder: VideoListCacheBuilder) {}
+
+  async onModuleInit(): Promise<void> {
+    try {
+      await this.builder.buildAll();
+      this.logger.log('Video list cache warmup completed');
+    } catch (err) {
+      this.logger.error(
+        'Video list cache warmup encountered an error but will not block startup',
+        err instanceof Error ? err.stack : String(err),
+      );
+    }
+  }
+}

+ 47 - 0
libs/common/src/cache/cache-keys.ts

@@ -2,6 +2,16 @@
 import type { AdType } from '../ads/ad-types';
 
 /**
+ * Video sort key type for video listing and pooling.
+ */
+export type VideoSortKey = 'latest' | 'popular' | 'manual';
+
+/**
+ * Video home section key type for home page sections.
+ */
+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"
@@ -64,4 +74,41 @@ export const CacheKeys = {
 
   appTrendingVideoPage: (countryCode: string, page: number): string =>
     `app:videolist:trending:${countryCode}:page:${page}`,
+
+  // ─────────────────────────────────────────────
+  // VIDEO DETAILS & METADATA
+  // ─────────────────────────────────────────────
+  /** Get cache key for video detail data. */
+  appVideoDetailKey: (videoId: string): string => `app:video:detail:${videoId}`,
+
+  /** Get cache key for video category list by channel. */
+  appVideoCategoryListKey: (channelId: string): string =>
+    `app:video:category:list:${channelId}`,
+
+  /** Get cache key for video tag list by channel and category. */
+  appVideoTagListKey: (channelId: string, categoryId: string): string =>
+    `app:video:tag:list:${channelId}:${categoryId}`,
+
+  // ─────────────────────────────────────────────
+  // VIDEO POOLS (sorted listings)
+  // ─────────────────────────────────────────────
+  /** Get cache key for videos in a category with sort order. */
+  appVideoCategoryPoolKey: (
+    channelId: string,
+    categoryId: string,
+    sort: VideoSortKey,
+  ): string => `app:video:list:category:${channelId}:${categoryId}:${sort}`,
+
+  /** Get cache key for videos with a specific tag with sort order. */
+  appVideoTagPoolKey: (
+    channelId: string,
+    tagId: string,
+    sort: VideoSortKey,
+  ): string => `app:video:list:tag:${channelId}:${tagId}:${sort}`,
+
+  /** Get cache key for home page video section. */
+  appVideoHomeSectionKey: (
+    channelId: string,
+    section: VideoHomeSectionKey,
+  ): string => `app:video:list:home:${channelId}:${section}`,
 };

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

@@ -0,0 +1,146 @@
+// libs/common/src/cache/ts-cache-key.provider.ts
+import {
+  CacheKeys,
+  type VideoSortKey,
+  type VideoHomeSectionKey,
+} from './cache-keys';
+import type { AdType } from '../ads/ad-types';
+
+// Re-export video types for convenience
+export type { VideoSortKey, VideoHomeSectionKey };
+
+/**
+ * TypeScript-friendly interface for Redis cache key generation.
+ * Provides a structured, categorized access to all cache key builders.
+ */
+export interface TsCacheKeyBuilder {
+  /**
+   * Channel-related cache keys.
+   */
+  channel: {
+    /** Get all channels. */
+    all(): string;
+    /** Get channel by ID. */
+    byId(channelId: string | number): string;
+    /** Get channel with all its categories. */
+    withCategories(channelId: string | number): string;
+  };
+
+  /**
+   * Category-related cache keys.
+   */
+  category: {
+    /** Get category by ID. */
+    byId(categoryId: string | number): string;
+    /** Get all categories. */
+    all(): string;
+    /** Get category by ID (legacy variant). */
+    general(categoryId: string | number): string;
+    /** Get category with all its tags. */
+    withTags(categoryId: string | number): string;
+  };
+
+  /**
+   * Tag-related cache keys.
+   */
+  tag: {
+    /** Get all tags (global suggestion pool). */
+    all(): string;
+  };
+
+  /**
+   * Ad-related cache keys.
+   */
+  ad: {
+    /** Get ad by ID. */
+    byId(adId: string | number): string;
+    /** Get ad pool by type. */
+    poolByType(adType: AdType | string): string;
+  };
+
+  /**
+   * Video-related cache keys.
+   */
+  video: {
+    /** Get video detail data. */
+    detail(videoId: string): string;
+    /** Get video category list by channel. */
+    categoryList(channelId: string): string;
+    /** Get video tag list by channel and category. */
+    tagList(channelId: string, categoryId: string): string;
+    /** Get videos in a category with sort order. */
+    categoryPool(
+      channelId: string,
+      categoryId: string,
+      sort: VideoSortKey,
+    ): string;
+    /** Get videos with a specific tag with sort order. */
+    tagPool(channelId: string, tagId: string, sort: VideoSortKey): string;
+    /** Get home page video section. */
+    homeSection(channelId: string, section: VideoHomeSectionKey): string;
+  };
+
+  /**
+   * Legacy video list cache keys (pagination-based).
+   */
+  videoList: {
+    /** Get home video page by page number. */
+    homePage(page: number): string;
+    /** Get channel video page by channel ID and page number. */
+    channelPage(channelId: string | number, page: number): string;
+    /** Get trending video page by country code and page number. */
+    trendingPage(countryCode: string, page: number): string;
+  };
+}
+
+/**
+ * Create a TypeScript-friendly cache key builder.
+ * This provides structured access to all cache key generators with proper typing.
+ */
+export function createTsCacheKeyBuilder(): TsCacheKeyBuilder {
+  return {
+    channel: {
+      all: () => CacheKeys.appChannelAll,
+      byId: (channelId) => CacheKeys.appChannelById(channelId),
+      withCategories: (channelId) =>
+        CacheKeys.appChannelWithCategories(channelId),
+    },
+    category: {
+      byId: (categoryId) => CacheKeys.appCategoryById(categoryId),
+      all: () => CacheKeys.appCategoryAll,
+      general: (categoryId) => CacheKeys.appCategory(categoryId),
+      withTags: (categoryId) => CacheKeys.appCategoryWithTags(categoryId),
+    },
+    tag: {
+      all: () => CacheKeys.appTagAll,
+    },
+    ad: {
+      byId: (adId) => CacheKeys.appAdById(adId),
+      poolByType: (adType) => CacheKeys.appAdPoolByType(adType),
+    },
+    video: {
+      detail: (videoId) => CacheKeys.appVideoDetailKey(videoId),
+      categoryList: (channelId) => CacheKeys.appVideoCategoryListKey(channelId),
+      tagList: (channelId, categoryId) =>
+        CacheKeys.appVideoTagListKey(channelId, categoryId),
+      categoryPool: (channelId, categoryId, sort) =>
+        CacheKeys.appVideoCategoryPoolKey(channelId, categoryId, sort),
+      tagPool: (channelId, tagId, sort) =>
+        CacheKeys.appVideoTagPoolKey(channelId, tagId, sort),
+      homeSection: (channelId, section) =>
+        CacheKeys.appVideoHomeSectionKey(channelId, section),
+    },
+    videoList: {
+      homePage: (page) => CacheKeys.appHomeVideoPage(page),
+      channelPage: (channelId, page) =>
+        CacheKeys.appChannelVideoPage(channelId, page),
+      trendingPage: (countryCode, page) =>
+        CacheKeys.appTrendingVideoPage(countryCode, page),
+    },
+  };
+}
+
+/**
+ * Singleton instance of the TypeScript-friendly cache key builder.
+ */
+export const tsCacheKeys = createTsCacheKeyBuilder();

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

@@ -12,6 +12,8 @@ import { TagWarmupService } from './tag/tag-warmup.service';
 import { ChannelCacheService } from './channel/channel-cache.service';
 import { ChannelCacheBuilder } from './channel/channel-cache.builder';
 import { ChannelWarmupService } from './channel/channel-warmup.service';
+import { VideoCategoryCacheBuilder } from './video/video-category-cache.builder';
+import { VideoCategoryWarmupService } from './video/video-category-warmup.service';
 
 @Module({
   providers: [
@@ -37,6 +39,10 @@ import { ChannelWarmupService } from './channel/channel-warmup.service';
     ChannelCacheService,
     ChannelCacheBuilder,
     ChannelWarmupService,
+
+    // Videos (Categories & Tags)
+    VideoCategoryCacheBuilder,
+    VideoCategoryWarmupService,
   ],
   exports: [
     AdPoolService,
@@ -47,6 +53,7 @@ import { ChannelWarmupService } from './channel/channel-warmup.service';
     TagCacheBuilder,
     ChannelCacheService,
     ChannelCacheBuilder,
+    VideoCategoryCacheBuilder,
   ],
 })
 export class CacheManagerModule {}

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

@@ -0,0 +1,135 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { BaseCacheBuilder } from '@box/common/cache/cache-builder';
+import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
+
+/**
+ * Category payload for Redis cache.
+ */
+export interface VideoCategoryPayload {
+  id: string;
+  name: string;
+  subtitle?: string | null;
+  seq: number;
+  status: number;
+  createAt: string; // BigInt as string to preserve precision
+  updateAt: string; // BigInt as string
+  channelId: string;
+}
+
+/**
+ * Tag payload for Redis cache.
+ */
+export interface VideoTagPayload {
+  id: string;
+  name: string;
+  seq: number;
+  status: number;
+  createAt: string; // BigInt as string
+  updateAt: string; // BigInt as string
+  channelId: string;
+  categoryId: string;
+}
+
+/**
+ * Cache builder for video categories and tags.
+ * Stores categories and tags as Redis LISTs, sorted by seq + createdAt/id.
+ * Only includes enabled (status === 1) entries.
+ */
+@Injectable()
+export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
+  constructor(redis: RedisService, mongoPrisma: MongoPrismaService) {
+    super(redis, mongoPrisma, VideoCategoryCacheBuilder.name);
+  }
+
+  /**
+   * Build all category and tag lists for all channels.
+   * Iterates every channel, then for each channel:
+   *  - Build category list
+   *  - For each category, build tag list
+   */
+  async buildAll(): Promise<void> {
+    const channels = await this.mongoPrisma.channel.findMany();
+
+    for (const channel of channels) {
+      try {
+        await this.buildCategoryListForChannel(channel.id);
+        const categories = await this.mongoPrisma.category.findMany({
+          where: { status: 1, channelId: channel.id },
+        });
+        for (const category of categories) {
+          await this.buildTagListForCategory(channel.id, category.id);
+        }
+      } catch (err) {
+        this.logger.error(
+          `Error building video cache for channel ${channel.id}`,
+          err instanceof Error ? err.stack : String(err),
+        );
+      }
+    }
+
+    this.logger.log(
+      `Built video category and tag cache for ${channels.length} channels`,
+    );
+  }
+
+  /**
+   * Build category list for a given channel.
+   * Fetches all enabled categories, orders by seq + createAt, and stores as LIST in Redis.
+   */
+  async buildCategoryListForChannel(channelId: string): Promise<void> {
+    const categories = await this.mongoPrisma.category.findMany({
+      where: { status: 1, channelId },
+      orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
+    });
+
+    const payloads: VideoCategoryPayload[] = categories.map((cat) => ({
+      id: cat.id,
+      name: cat.name,
+      subtitle: cat.subtitle ?? null,
+      seq: cat.seq,
+      status: cat.status,
+      createAt: this.toMillis(cat.createAt)?.toString() ?? '0',
+      updateAt: this.toMillis(cat.updateAt)?.toString() ?? '0',
+      channelId: cat.channelId,
+    }));
+
+    const key = tsCacheKeys.video.categoryList(channelId);
+    await this.redis.rpushList(
+      key,
+      payloads.map((p) => JSON.stringify(p)),
+    );
+  }
+
+  /**
+   * Build tag list for a given category within a channel.
+   * Fetches all enabled tags, orders by seq + createAt, and stores as LIST in Redis.
+   */
+  async buildTagListForCategory(
+    channelId: string,
+    categoryId: string,
+  ): Promise<void> {
+    const tags = await this.mongoPrisma.tag.findMany({
+      where: { status: 1, channelId, categoryId },
+      orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
+    });
+
+    const payloads: VideoTagPayload[] = tags.map((tag) => ({
+      id: tag.id,
+      name: tag.name,
+      seq: tag.seq,
+      status: tag.status,
+      createAt: this.toMillis(tag.createAt)?.toString() ?? '0',
+      updateAt: this.toMillis(tag.updateAt)?.toString() ?? '0',
+      channelId: tag.channelId,
+      categoryId: tag.categoryId,
+    }));
+
+    const key = tsCacheKeys.video.tagList(channelId, categoryId);
+    await this.redis.rpushList(
+      key,
+      payloads.map((p) => JSON.stringify(p)),
+    );
+  }
+}

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

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

+ 67 - 2
libs/db/src/redis/redis.service.ts

@@ -117,10 +117,75 @@ export class RedisService {
   }
 
   // ─────────────────────────────────────────────
-  // Pipelines & atomic swap helpers
+  // List operations
   // ─────────────────────────────────────────────
 
-  async pipelineSetJson(
+  /**
+   * Push items to a Redis LIST (right push).
+   * Atomically deletes the old key first, then pushes all items.
+   */
+  async rpushList(key: string, items: string[]): Promise<void> {
+    const client = this.ensureClient();
+    const pipeline = client.pipeline();
+
+    // Delete old key first
+    pipeline.del(key);
+
+    // Push all items if any exist
+    if (items.length > 0) {
+      pipeline.rpush(key, ...items);
+    }
+
+    await pipeline.exec();
+  }
+
+  /**
+   * Build a Redis ZSET with members and scores.
+   * Atomically deletes the old key first, then adds all members.
+   */
+  async zadd(
+    key: string,
+    members: Array<{ score: number; member: string }>,
+  ): Promise<void> {
+    const client = this.ensureClient();
+    const pipeline = client.pipeline();
+
+    // Delete old key first
+    pipeline.del(key);
+
+    // Add members if any exist
+    if (members.length > 0) {
+      for (const { score, member } of members) {
+        pipeline.zadd(key, score, member);
+      }
+    }
+
+    await pipeline.exec();
+  }
+
+  /**
+   * Get a range of elements from a Redis LIST by index.
+   * Returns elements from start to stop (inclusive, 0-based).
+   * Use 0 to -1 to get all elements.
+   */
+  async lrange(key: string, start: number, stop: number): Promise<string[]> {
+    const client = this.ensureClient();
+    return client.lrange(key, start, stop);
+  }
+
+  /**
+   * Get a range of elements from a Redis ZSET in reverse score order.
+   * Returns elements from offset to offset+limit-1.
+   * Useful for pagination: zrevrange(key, (page-1)*pageSize, page*pageSize-1)
+   */
+  async zrevrange(key: string, start: number, stop: number): Promise<string[]> {
+    const client = this.ensureClient();
+    return client.zrevrange(key, start, stop);
+  }
+
+  // ─────────────────────────────────────────────
+  // Pipelines & atomic swap helpers
+  // ─────────────────────────────────────────────  async pipelineSetJson(
     entries: Array<{ key: string; value: unknown; ttlSeconds?: number }>,
   ): Promise<void> {
     const client = this.ensureClient();