Bladeren bron

feat: add DTOs for video and ad features, including pagination and filtering

- Introduced AdListResponseDto and related DTOs for ad listing functionality.
- Added VideoCategoryWithTagsItemDto and VideoCategoryWithTagsResponseDto for video categories with tags.
- Implemented VideoListRequestDto and VideoListResponseDto for paginated video listings.
- Created VideoSearchByTagRequestDto for searching videos by tag.
- Updated VideoController to handle new endpoints for video listing and searching by tag.
- Enhanced VideoService with methods for fetching categories with tags and paginated video lists.
- Refactored AdPoolService to improve Redis interactions for ad payloads.
Dave 4 maanden geleden
bovenliggende
commit
afa36f77bc

+ 7 - 0
.env.app

@@ -2,12 +2,19 @@
 APP_ENV=test
 
 # Prisma Config
+# MYSQL_URL="mysql://boxdbuser:dwR%3D%29whu2Ze@localhost:3306/box_admin"
 # MONGO_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_admin?authSource=admin"
+# MONGO_STATS_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_stats?authSource=admin"
+
 # Dave local
+MYSQL_URL="mysql://root:123456@localhost:3306/box_admin"
 MONGO_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_admin?authSource=admin&replicaSet=rs0"
+MONGO_STATS_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_stats?authSource=admin&replicaSet=rs0"
 
 # office dev env
+# MYSQL_URL="mysql://root:123456@192.168.0.100:3306/box_admin"
 # MONGO_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@192.168.0.100:27017/box_admin?authSource=admin&replicaSet=rs0"
+# MONGO_STATS_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@192.168.0.100:27017/box_stats?authSource=admin&replicaSet=rs0"
 
 # Redis Config
 REDIS_HOST=127.0.0.1

+ 8 - 0
.env.app.dev

@@ -2,11 +2,19 @@
 APP_ENV=development
 
 # Prisma Config
+# MYSQL_URL="mysql://boxdbuser:dwR%3D%29whu2Ze@localhost:3306/box_admin"
+# MONGO_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_admin?authSource=admin"
+# MONGO_STATS_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_stats?authSource=admin"
+
 # Dave local
+# MYSQL_URL="mysql://root:123456@localhost:3306/box_admin"
 # MONGO_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_admin?authSource=admin&replicaSet=rs0"
+# MONGO_STATS_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_stats?authSource=admin&replicaSet=rs0"
 
 # office dev env
+MYSQL_URL="mysql://root:123456@192.168.0.100:3306/box_admin"
 MONGO_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@192.168.0.100:27017/box_admin?authSource=admin&replicaSet=rs0"
+MONGO_STATS_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@192.168.0.100:27017/box_stats?authSource=admin&replicaSet=rs0"
 
 # Redis Config
 REDIS_HOST=127.0.0.1

+ 6 - 0
.env.app.test

@@ -2,13 +2,19 @@
 APP_ENV=test
 
 # Prisma Config
+MYSQL_URL="mysql://boxdbuser:dwR=)whu2Ze@localhost:3306/box_admin"
 MONGO_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_admin?authSource=admin"
+MONGO_STATS_URL="mongodb://boxstatuser:tQlVvHbXhge8RUy@localhost:27017/box_stats?authSource=admin"
 
 # Dave local
+# MYSQL_URL="mysql://root:123456@localhost:3306/box_admin"
 # MONGO_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_admin?authSource=admin&replicaSet=rs0"
+# MONGO_STATS_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_stats?authSource=admin&replicaSet=rs0"
 
 # office dev env
+# MYSQL_URL="mysql://root:123456@192.168.0.100:3306/box_admin"
 # MONGO_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@192.168.0.100:27017/box_admin?authSource=admin&replicaSet=rs0"
+# MONGO_STATS_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@192.168.0.100:27017/box_stats?authSource=admin&replicaSet=rs0"
 
 # Redis Config
 REDIS_HOST=127.0.0.1

+ 34 - 1
apps/box-app-api/src/feature/ads/ad.controller.ts

@@ -1,9 +1,10 @@
 // apps/box-app-api/src/feature/ads/ad.controller.ts
-import { Controller, Get, Logger, Query } from '@nestjs/common';
+import { Controller, Get, Logger, Query, Post, Body } from '@nestjs/common';
 import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
 import { AdService } from './ad.service';
 import { GetAdPlacementQueryDto } from './dto/get-ad-placement.dto';
 import { AdDto } from './dto/ad.dto';
+import { AdListRequestDto, AdListResponseDto } from './dto';
 
 @ApiTags('广告')
 @Controller('ads')
@@ -54,4 +55,36 @@ export class AdController {
     // { status, code, data, error, timestamp, ... }
     return ad;
   }
+
+  /**
+   * POST /api/v1/ads/list
+   *
+   * List ads by type with pagination.
+   * Request body contains page, size, and adType.
+   * Returns paginated list of ads with metadata.
+   */
+  @Post('list')
+  @ApiOperation({
+    summary: '分页列表获取广告',
+    description:
+      '按广告类型分页获取广告列表。支持指定页码和每页数量。数据来源:Mongo Ads 模型。',
+  })
+  @ApiResponse({
+    status: 200,
+    description: '成功返回分页广告列表',
+    type: AdListResponseDto,
+  })
+  async listAdsByType(
+    @Body() req: AdListRequestDto,
+  ): Promise<AdListResponseDto> {
+    const { page, size, adType } = req;
+
+    this.logger.debug(
+      `listAdsByType: page=${page}, size=${size}, adType=${adType}`,
+    );
+
+    const response = await this.adService.listAdsByType(adType, page, size);
+
+    return response;
+  }
 }

+ 2 - 0
apps/box-app-api/src/feature/ads/ad.module.ts

@@ -1,12 +1,14 @@
 // apps/box-app-api/src/feature/ads/ad.module.ts
 import { Module } from '@nestjs/common';
 import { RedisModule } from '@box/db/redis/redis.module';
+import { SharedModule } from '@box/db/shared.module';
 import { AdService } from './ad.service';
 import { AdController } from './ad.controller';
 
 @Module({
   imports: [
     RedisModule, // 👈 make RedisService available here
+    SharedModule, // 👈 make MongoPrismaService available here
   ],
   providers: [AdService],
   controllers: [AdController],

+ 165 - 1
apps/box-app-api/src/feature/ads/ad.service.ts

@@ -1,8 +1,11 @@
 // apps/box-app-api/src/feature/ads/ad.service.ts
 import { Injectable, Logger } from '@nestjs/common';
 import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import { CacheKeys } from '@box/common/cache/cache-keys';
 import { AdDto } from './dto/ad.dto';
+import { AdListResponseDto, AdItemDto } from './dto';
+import { AdType } from '@box/common/ads/ad-types';
 
 interface AdPoolEntry {
   id: string;
@@ -45,7 +48,10 @@ export interface GetAdForPlacementParams {
 export class AdService {
   private readonly logger = new Logger(AdService.name);
 
-  constructor(private readonly redis: RedisService) {}
+  constructor(
+    private readonly redis: RedisService,
+    private readonly mongoPrisma: MongoPrismaService,
+  ) {}
 
   /**
    * Core method for app-api:
@@ -186,4 +192,162 @@ export class AdService {
       targetUrl: cachedAd.adsUrl ?? undefined,
     };
   }
+
+  /**
+   * Get paginated list of ads by type from Redis pool.
+   * Reads the prebuilt ad pool from Redis, applies pagination, and fetches full ad details.
+   *
+   * Flow:
+   * 1. Get total count from pool
+   * 2. Compute start/stop indices for LRANGE
+   * 3. Fetch poolEntries (AdPoolEntry array as JSON)
+   * 4. Query MongoDB to fetch full ad details for the entries
+   * 5. Reorder results to match Redis pool order
+   * 6. Map to AdItemDto and return response
+   */
+  async listAdsByType(
+    adType: string,
+    page: number,
+    size: number,
+  ): Promise<AdListResponseDto> {
+    const poolKey = CacheKeys.appAdPoolByType(adType);
+
+    // Step 1: Get the entire pool from Redis
+    // Note: The key should be a STRING (JSON), but might be a LIST from old implementation
+    let poolEntries: AdPoolEntry[] = [];
+    try {
+      // First, try to get as JSON (STRING type)
+      const jsonData = await this.redis.getJson<AdPoolEntry[]>(poolKey);
+      if (jsonData && Array.isArray(jsonData)) {
+        poolEntries = jsonData;
+      } else {
+        // If getJson failed or returned null, the key might not exist
+        this.logger.warn(
+          `Ad pool cache miss or invalid for adType=${adType}, key=${poolKey}`,
+        );
+      }
+    } catch (err) {
+      // If WRONGTYPE error, the key is stored as a different Redis type (likely from old code)
+      // Delete the incompatible key so it can be rebuilt properly
+      if (err instanceof Error && err.message?.includes('WRONGTYPE')) {
+        this.logger.warn(
+          `Ad pool key ${poolKey} has wrong type, deleting incompatible key`,
+        );
+        try {
+          await this.redis.del(poolKey);
+          this.logger.log(
+            `Deleted incompatible ad pool key ${poolKey}. It will be rebuilt on next cache warmup.`,
+          );
+        } catch (delErr) {
+          this.logger.error(
+            `Failed to delete incompatible key ${poolKey}`,
+            delErr instanceof Error ? delErr.stack : String(delErr),
+          );
+        }
+      } else {
+        this.logger.error(
+          `Failed to read ad pool for adType=${adType}, key=${poolKey}`,
+          err instanceof Error ? err.stack : String(err),
+        );
+      }
+    }
+
+    if (!Array.isArray(poolEntries) || poolEntries.length === 0) {
+      this.logger.debug(
+        `Ad pool empty or invalid for adType=${adType}, key=${poolKey}`,
+      );
+      return {
+        page,
+        size,
+        total: 0,
+        adType: adType as AdType,
+        items: [],
+      };
+    }
+
+    const total = poolEntries.length;
+
+    // Step 2: Compute pagination indices
+    const start = (page - 1) * size;
+    const stop = start + size - 1;
+
+    // Check if page is out of range
+    if (start >= total) {
+      this.logger.debug(
+        `Page out of range: page=${page}, size=${size}, total=${total}`,
+      );
+      return {
+        page,
+        size,
+        total,
+        adType: adType as AdType,
+        items: [],
+      };
+    }
+
+    // Step 3: Slice the pool entries for this page
+    const pagedEntries = poolEntries.slice(start, stop + 1);
+    const adIds = pagedEntries.map((entry) => entry.id);
+
+    // Step 4: Query MongoDB for full ad details
+    let ads: Awaited<ReturnType<typeof this.mongoPrisma.ads.findMany>>;
+    try {
+      const now = BigInt(Date.now());
+      ads = await this.mongoPrisma.ads.findMany({
+        where: {
+          id: { in: adIds },
+          status: 1,
+          startDt: { lte: now },
+          OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
+        },
+      });
+    } catch (err) {
+      this.logger.error(
+        `Failed to query ads from MongoDB for adIds=${adIds.join(',')}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return {
+        page,
+        size,
+        total,
+        adType: adType as AdType,
+        items: [],
+      };
+    }
+
+    // Step 5: Create a map of ads by ID for fast lookup
+    const adMap = new Map(ads.map((ad) => [ad.id, ad]));
+
+    // Step 6: Reorder results to match the pool order and map to AdItemDto
+    const items: AdItemDto[] = [];
+    for (const entry of pagedEntries) {
+      const ad = adMap.get(entry.id);
+      if (!ad) {
+        this.logger.debug(
+          `Ad not found in MongoDB for adId=${entry.id} from pool`,
+        );
+        continue;
+      }
+
+      items.push({
+        id: ad.id,
+        advertiser: ad.advertiser ?? '',
+        title: ad.title ?? '',
+        adsContent: ad.adsContent ?? null,
+        adsCoverImg: ad.adsCoverImg ?? null,
+        adsUrl: ad.adsUrl ?? null,
+        startDt: ad.startDt.toString(),
+        expiryDt: ad.expiryDt.toString(),
+        seq: ad.seq ?? 0,
+      });
+    }
+
+    return {
+      page,
+      size,
+      total,
+      adType: adType as AdType,
+      items,
+    };
+  }
 }

+ 45 - 0
apps/box-app-api/src/feature/ads/dto/ad-item.dto.ts

@@ -0,0 +1,45 @@
+// apps/box-app-api/src/feature/ads/dto/ad-item.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+
+export class AdItemDto {
+  @ApiProperty({ description: '广告ID(Mongo Ads.id)' })
+  id: string;
+
+  @ApiProperty({ description: '广告主/来源(Ads.advertiser)' })
+  advertiser: string;
+
+  @ApiProperty({ description: '广告标题(Ads.title)' })
+  title: string;
+
+  @ApiProperty({
+    required: false,
+    description: '富文本或描述(Ads.adsContent)',
+  })
+  adsContent?: string | null;
+
+  @ApiProperty({
+    required: false,
+    description: '封面图CDN地址(Ads.adsCoverImg)',
+  })
+  adsCoverImg?: string | null;
+
+  @ApiProperty({ required: false, description: '跳转链接(Ads.adsUrl)' })
+  adsUrl?: string | null;
+
+  @ApiProperty({
+    description: '广告开始时间(毫秒时间戳,Ads.startDt)',
+    type: 'string',
+  })
+  startDt: string; // BigInt as string
+
+  @ApiProperty({
+    description: '广告过期时间(毫秒时间戳,Ads.expiryDt)',
+    type: 'string',
+  })
+  expiryDt: string; // BigInt as string
+
+  @ApiProperty({
+    description: '排序序号(Ads.seq)',
+  })
+  seq: number;
+}

+ 50 - 0
apps/box-app-api/src/feature/ads/dto/ad-list-request.dto.ts

@@ -0,0 +1,50 @@
+// apps/box-app-api/src/feature/ads/dto/ad-list-request.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNumber, Min, Max, IsEnum } from 'class-validator';
+import type { AdType } from '@box/common/ads/ad-types';
+
+export enum AdTypeEnum {
+  BANNER = 'BANNER',
+  CAROUSEL = 'CAROUSEL',
+  STARTUP = 'STARTUP',
+  POPUP_IMAGE = 'POPUP_IMAGE',
+  POPUP_ICON = 'POPUP_ICON',
+  POPUP_OFFICIAL = 'POPUP_OFFICIAL',
+  FLOATING_BOTTOM = 'FLOATING_BOTTOM',
+  FLOATING_EDGE = 'FLOATING_EDGE',
+  WATERFALL_VIDEO = 'WATERFALL_VIDEO',
+  WATERFALL_ICON = 'WATERFALL_ICON',
+  WATERFALL_TEXT = 'WATERFALL_TEXT',
+  PREROLL = 'PREROLL',
+  PAUSE = 'PAUSE',
+}
+
+export class AdListRequestDto {
+  @ApiProperty({
+    description: '页码,从1开始',
+    example: 1,
+    minimum: 1,
+  })
+  @IsNumber()
+  @Min(1)
+  page: number;
+
+  @ApiProperty({
+    description: '每页数量,最多50条',
+    example: 10,
+    minimum: 1,
+    maximum: 50,
+  })
+  @IsNumber()
+  @Min(1)
+  @Max(50)
+  size: number;
+
+  @ApiProperty({
+    description: '广告类型',
+    enum: AdTypeEnum,
+    example: 'BANNER',
+  })
+  @IsEnum(AdTypeEnum)
+  adType: AdType;
+}

+ 25 - 0
apps/box-app-api/src/feature/ads/dto/ad-list-response.dto.ts

@@ -0,0 +1,25 @@
+// apps/box-app-api/src/feature/ads/dto/ad-list-response.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+import { AdItemDto } from './ad-item.dto';
+import type { AdType } from '@box/common/ads/ad-types';
+
+export class AdListResponseDto {
+  @ApiProperty({ description: '当前页码' })
+  page: number;
+
+  @ApiProperty({ description: '每页数量' })
+  size: number;
+
+  @ApiProperty({ description: '广告总数' })
+  total: number;
+
+  @ApiProperty({ description: '广告类型' })
+  adType: AdType;
+
+  @ApiProperty({
+    description: '广告项列表',
+    type: AdItemDto,
+    isArray: true,
+  })
+  items: AdItemDto[];
+}

+ 6 - 0
apps/box-app-api/src/feature/ads/dto/index.ts

@@ -0,0 +1,6 @@
+// apps/box-app-api/src/feature/ads/dto/index.ts
+export { AdDto } from './ad.dto';
+export { GetAdPlacementQueryDto } from './get-ad-placement.dto';
+export { AdListRequestDto, AdTypeEnum } from './ad-list-request.dto';
+export { AdItemDto } from './ad-item.dto';
+export { AdListResponseDto } from './ad-list-response.dto';

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

@@ -5,3 +5,14 @@ export {
   VideoListItemDto,
   VideoPageDto,
 } from './video.dto';
+export {
+  VideoCategoryWithTagsItemDto,
+  VideoCategoryWithTagsResponseDto,
+  VideoTagItemDto,
+} from './video-category-with-tags.dto';
+export { VideoListRequestDto } from './video-list-request.dto';
+export {
+  VideoListItemDto as VideoListResponseItemDto,
+  VideoListResponseDto,
+} from './video-list-response.dto';
+export { VideoSearchByTagRequestDto } from './video-search-by-tag-request.dto';

+ 43 - 0
apps/box-app-api/src/feature/video/dto/video-category-with-tags.dto.ts

@@ -0,0 +1,43 @@
+// apps/box-app-api/src/feature/video/dto/video-category-with-tags.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+
+export class VideoTagItemDto {
+  @ApiProperty({ description: '标签名称' })
+  name: string;
+
+  @ApiProperty({ description: '排序序号' })
+  seq: number;
+}
+
+export class VideoCategoryWithTagsItemDto {
+  @ApiProperty({ description: '分类ID' })
+  id: string;
+
+  @ApiProperty({ description: '分类名称' })
+  name: string;
+
+  @ApiProperty({ required: false, description: '分类副标题' })
+  subtitle?: string;
+
+  @ApiProperty({ description: '排序序号' })
+  seq: number;
+
+  @ApiProperty({ description: '频道ID' })
+  channelId: string;
+
+  @ApiProperty({
+    description: '该分类下的标签列表',
+    type: VideoTagItemDto,
+    isArray: true,
+  })
+  tags: VideoTagItemDto[];
+}
+
+export class VideoCategoryWithTagsResponseDto {
+  @ApiProperty({
+    description: '分类及其标签列表',
+    type: VideoCategoryWithTagsItemDto,
+    isArray: true,
+  })
+  items: VideoCategoryWithTagsItemDto[];
+}

+ 41 - 0
apps/box-app-api/src/feature/video/dto/video-list-request.dto.ts

@@ -0,0 +1,41 @@
+// apps/box-app-api/src/feature/video/dto/video-list-request.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNumber, IsString, IsOptional, Min, Max } from 'class-validator';
+
+export class VideoListRequestDto {
+  @ApiProperty({
+    description: '页码,从1开始',
+    example: 1,
+    minimum: 1,
+  })
+  @IsNumber()
+  @Min(1)
+  page: number;
+
+  @ApiProperty({
+    description: '每页数量,最多100条',
+    example: 20,
+    minimum: 1,
+    maximum: 100,
+  })
+  @IsNumber()
+  @Min(1)
+  @Max(100)
+  size: number;
+
+  @ApiProperty({
+    description: '分类ID',
+    example: '6507f1f77bcf86cd799439011',
+  })
+  @IsString()
+  categoryId: string;
+
+  @ApiProperty({
+    required: false,
+    description: '标签名称(可选,用于过滤)',
+    example: '推荐',
+  })
+  @IsOptional()
+  @IsString()
+  tagName?: string;
+}

+ 74 - 0
apps/box-app-api/src/feature/video/dto/video-list-response.dto.ts

@@ -0,0 +1,74 @@
+// apps/box-app-api/src/feature/video/dto/video-list-response.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+
+export class VideoListItemDto {
+  @ApiProperty({ description: '视频ID' })
+  id: string;
+
+  @ApiProperty({ description: '视频标题' })
+  title: string;
+
+  @ApiProperty({
+    required: false,
+    description: '封面图CDN地址',
+  })
+  coverImg?: string;
+
+  @ApiProperty({
+    required: false,
+    description: '视频时长(秒)',
+  })
+  duration?: number;
+
+  @ApiProperty({ description: '分类ID' })
+  categoryId: string;
+
+  @ApiProperty({ description: '分类名称' })
+  name: string;
+
+  @ApiProperty({
+    required: false,
+    description: '分类副标题',
+  })
+  subtitle?: string;
+
+  @ApiProperty({ description: '频道ID' })
+  channelId: string;
+
+  @ApiProperty({
+    description: '关联的标签名列表',
+    type: String,
+    isArray: true,
+  })
+  tags: string[];
+
+  @ApiProperty({
+    description: '最后更新时间(ISO 8601格式)',
+    example: '2024-12-06T10:30:00.000Z',
+  })
+  updateAt: string;
+}
+
+export class VideoListResponseDto {
+  @ApiProperty({ description: '当前页码' })
+  page: number;
+
+  @ApiProperty({ description: '每页数量' })
+  size: number;
+
+  @ApiProperty({ description: '视频总数' })
+  total: number;
+
+  @ApiProperty({
+    required: false,
+    description: '过滤使用的标签名(如果有)',
+  })
+  tagName?: string;
+
+  @ApiProperty({
+    description: '视频列表',
+    type: VideoListItemDto,
+    isArray: true,
+  })
+  items: VideoListItemDto[];
+}

+ 32 - 0
apps/box-app-api/src/feature/video/dto/video-search-by-tag-request.dto.ts

@@ -0,0 +1,32 @@
+// apps/box-app-api/src/feature/video/dto/video-search-by-tag-request.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNumber, IsString, Min, Max } from 'class-validator';
+
+export class VideoSearchByTagRequestDto {
+  @ApiProperty({
+    description: '页码,从1开始',
+    example: 1,
+    minimum: 1,
+  })
+  @IsNumber()
+  @Min(1)
+  page: number;
+
+  @ApiProperty({
+    description: '每页数量,最多100条',
+    example: 20,
+    minimum: 1,
+    maximum: 100,
+  })
+  @IsNumber()
+  @Min(1)
+  @Max(100)
+  size: number;
+
+  @ApiProperty({
+    description: '标签名称(用于全局搜索)',
+    example: '推荐',
+  })
+  @IsString()
+  tagName: string;
+}

+ 199 - 127
apps/box-app-api/src/feature/video/video.controller.ts

@@ -1,4 +1,4 @@
-import { Controller, Get, Param, Query } from '@nestjs/common';
+import { Controller, Get, Param, Query, Post, Body } from '@nestjs/common';
 import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
 import { VideoService } from './video.service';
 import {
@@ -6,174 +6,246 @@ import {
   VideoDetailDto,
   VideoCategoryDto,
   VideoTagDto,
+  VideoCategoryWithTagsResponseDto,
+  VideoListRequestDto,
+  VideoListResponseDto,
+  VideoSearchByTagRequestDto,
 } from './dto';
 
 @ApiTags('Videos')
-@Controller('api/v1/video')
+@Controller('video')
 export class VideoController {
   constructor(private readonly videoService: VideoService) {}
 
   /**
    * Get categories for a channel from Redis cache.
    */
-  @Get('categories/:channelId')
-  @ApiOperation({
-    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('categories/:channelId')
+  // @ApiOperation({
+  //   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.
    * Note: channelId is kept in URL for backward compatibility but not used.
    */
-  @Get('tags/:channelId/:categoryId')
-  @ApiOperation({
-    summary: 'Get video tags for category',
-    description:
-      'Returns list of tags in a specific category from Redis cache.',
-  })
-  @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(categoryId);
-  }
+  // @Get('tags/:channelId/:categoryId')
+  // @ApiOperation({
+  //   summary: 'Get video tags for category',
+  //   description:
+  //     'Returns list of tags in a specific category from Redis cache.',
+  // })
+  // @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(categoryId);
+  // }
 
   /**
    * Get videos in a category with pagination.
    */
-  @Get('category/:channelId/:categoryId')
+  // @Get('category/:channelId/:categoryId')
+  // @ApiOperation({
+  //   summary: 'Get videos by category',
+  //   description:
+  //     'Returns paginated videos for a specific category from Redis cache.',
+  // })
+  // @ApiQuery({
+  //   name: 'page',
+  //   required: false,
+  //   description: 'Page number (default: 1)',
+  //   example: 1,
+  // })
+  // @ApiQuery({
+  //   name: 'pageSize',
+  //   required: false,
+  //   description: 'Items per page (default: 20)',
+  //   example: 20,
+  // })
+  // @ApiResponse({
+  //   status: 200,
+  //   description: 'Paginated video list',
+  //   type: VideoPageDto,
+  // })
+  // 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;
+
+  //   return this.videoService.getVideosByCategoryWithPaging({
+  //     channelId,
+  //     categoryId,
+  //     page: parsedPage,
+  //     pageSize: parsedPageSize,
+  //   });
+  // }
+
+  /**
+   * Get videos for a tag with pagination.
+   * Note: Need categoryId to use new cache semantics for tag list fallback.
+   */
+  // @Get('tag/:channelId/:categoryId/:tagId')
+  // @ApiOperation({
+  //   summary: 'Get videos by tag',
+  //   description:
+  //     'Returns paginated videos for a specific tag from Redis cache.',
+  // })
+  // @ApiQuery({
+  //   name: 'page',
+  //   required: false,
+  //   description: 'Page number (default: 1)',
+  //   example: 1,
+  // })
+  // @ApiQuery({
+  //   name: 'pageSize',
+  //   required: false,
+  //   description: 'Items per page (default: 20)',
+  //   example: 20,
+  // })
+  // @ApiResponse({
+  //   status: 200,
+  //   description: 'Paginated video list',
+  //   type: VideoPageDto,
+  // })
+  // async getVideosByTag(
+  //   @Param('channelId') channelId: string,
+  //   @Param('categoryId') categoryId: 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,
+  //     categoryId,
+  //     tagId,
+  //     page: parsedPage,
+  //     pageSize: parsedPageSize,
+  //   });
+  // }
+
+  /**
+   * 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 this.videoService.getHomeSectionVideos(channelId, section as any);
+  // }
+
+  /**
+   * GET /api/v1/video/categories-with-tags
+   *
+   * Get all video categories with their associated tags.
+   * Returns categories fetched from Redis cache (app:category:all),
+   * with tags for each category fetched from (box:app:tag:list:{categoryId}).
+   */
+  @Get('categories-with-tags')
   @ApiOperation({
-    summary: 'Get videos by category',
+    summary: '获取所有分类及其标签',
     description:
-      'Returns paginated videos for a specific category from Redis cache.',
-  })
-  @ApiQuery({
-    name: 'page',
-    required: false,
-    description: 'Page number (default: 1)',
-    example: 1,
-  })
-  @ApiQuery({
-    name: 'pageSize',
-    required: false,
-    description: 'Items per page (default: 20)',
-    example: 20,
+      '返回所有视频分类及其关联的标签。数据来源:Redis 缓存(由 box-mgnt-api 构建)。',
   })
   @ApiResponse({
     status: 200,
-    description: 'Paginated video list',
-    type: VideoPageDto,
+    description: '分类及其标签列表',
+    type: VideoCategoryWithTagsResponseDto,
   })
-  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;
-
-    return this.videoService.getVideosByCategoryWithPaging({
-      channelId,
-      categoryId,
-      page: parsedPage,
-      pageSize: parsedPageSize,
-    });
+  async getCategoriesWithTags(): Promise<VideoCategoryWithTagsResponseDto> {
+    return this.videoService.getCategoriesWithTags();
   }
 
   /**
-   * Get videos for a tag with pagination.
-   * Note: Need categoryId to use new cache semantics for tag list fallback.
+   * POST /api/v1/video/list
+   *
+   * Get paginated list of videos for a category with optional tag filtering.
+   * Request body contains page, size, categoryId, and optional tagName.
+   * Returns paginated video list with metadata.
    */
-  @Get('tag/:channelId/:categoryId/:tagId')
+  @Post('list')
   @ApiOperation({
-    summary: 'Get videos by tag',
+    summary: '分页获取视频列表',
     description:
-      'Returns paginated videos for a specific tag from Redis cache.',
-  })
-  @ApiQuery({
-    name: 'page',
-    required: false,
-    description: 'Page number (default: 1)',
-    example: 1,
-  })
-  @ApiQuery({
-    name: 'pageSize',
-    required: false,
-    description: 'Items per page (default: 20)',
-    example: 20,
+      '按分类(和可选的标签)分页获取视频列表。支持按页码和每页数量分页。',
   })
   @ApiResponse({
     status: 200,
-    description: 'Paginated video list',
-    type: VideoPageDto,
+    description: '成功返回分页视频列表',
+    type: VideoListResponseDto,
   })
-  async getVideosByTag(
-    @Param('channelId') channelId: string,
-    @Param('categoryId') categoryId: 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,
-      categoryId,
-      tagId,
-      page: parsedPage,
-      pageSize: parsedPageSize,
-    });
+  async getVideoList(
+    @Body() req: VideoListRequestDto,
+  ): Promise<VideoListResponseDto> {
+    return this.videoService.getVideoList(req);
   }
 
   /**
-   * Get home section videos (e.g., featured, latest, editorPick).
+   * POST /api/v1/video/search-by-tag
+   *
+   * Search videos by tag name across all categories.
+   * Collects all videos tagged with the specified tag name from all categories.
    */
-  @Get('home/:channelId/:section')
+  @Post('search-by-tag')
   @ApiOperation({
-    summary: 'Get home section videos',
-    description:
-      'Returns videos for home page sections (featured, latest, editorPick) from Redis cache.',
+    summary: '按标签名称全局搜索视频',
+    description: '跨所有分类搜索具有指定标签名称的视频,返回分页结果。',
   })
   @ApiResponse({
     status: 200,
-    description: 'List of videos in section',
-    type: VideoDetailDto,
-    isArray: true,
+    description: '成功返回搜索结果',
+    type: VideoListResponseDto,
   })
-  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 this.videoService.getHomeSectionVideos(channelId, section as any);
+  async searchByTag(
+    @Body() req: VideoSearchByTagRequestDto,
+  ): Promise<VideoListResponseDto> {
+    return this.videoService.searchVideosByTagName(req);
   }
 }

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

@@ -9,6 +9,10 @@ import {
   VideoTagDto,
   VideoDetailDto,
   VideoPageDto,
+  VideoCategoryWithTagsResponseDto,
+  VideoListRequestDto,
+  VideoListResponseDto,
+  VideoSearchByTagRequestDto,
 } from './dto';
 
 /**
@@ -665,4 +669,660 @@ export class VideoService {
       return false;
     }
   }
+
+  /**
+   * Get all categories with their associated tags.
+   * Reads categories from Redis cache (app:category:all).
+   * For each category, reads tags from Redis (box:app:tag:list:{categoryId}).
+   * If cache is missing/empty, treats as empty list.
+   */
+  async getCategoriesWithTags(): Promise<VideoCategoryWithTagsResponseDto> {
+    try {
+      const key = tsCacheKeys.category.all();
+      const rawCategories = await this.redis.getJson<
+        Array<{
+          id: string;
+          name: string;
+          subtitle?: string | null;
+          seq: number;
+          channelId: string;
+        }>
+      >(key);
+
+      if (!rawCategories || rawCategories.length === 0) {
+        this.logger.debug(`Cache miss for categories list key: ${key}`);
+        return { items: [] };
+      }
+
+      // For each category, fetch its tags from cache
+      const items = await Promise.all(
+        rawCategories.map(async (category) => {
+          try {
+            const tagKey = tsCacheKeys.tag.metadataByCategory(category.id);
+            // Tag metadata is stored as a LIST of JSON strings, not a single JSON string
+            const tagJsonStrings = await this.redis.lrange(tagKey, 0, -1);
+
+            const tags: Array<{ name: string; seq: number }> = [];
+            if (tagJsonStrings && tagJsonStrings.length > 0) {
+              for (const jsonStr of tagJsonStrings) {
+                try {
+                  const tag = JSON.parse(jsonStr);
+                  tags.push({
+                    name: tag.name,
+                    seq: tag.seq,
+                  });
+                } catch (parseErr) {
+                  this.logger.debug(
+                    `Failed to parse tag JSON for category ${category.id}`,
+                  );
+                }
+              }
+            }
+
+            return {
+              id: category.id,
+              name: category.name,
+              subtitle: category.subtitle ?? undefined,
+              seq: category.seq,
+              channelId: category.channelId,
+              tags,
+            };
+          } catch (err) {
+            this.logger.error(
+              `Error fetching tags for categoryId=${category.id}`,
+              err instanceof Error ? err.stack : String(err),
+            );
+            // Return category with empty tags on error
+            return {
+              id: category.id,
+              name: category.name,
+              subtitle: category.subtitle ?? undefined,
+              seq: category.seq,
+              channelId: category.channelId,
+              tags: [],
+            };
+          }
+        }),
+      );
+
+      return { items };
+    } catch (err) {
+      this.logger.error(
+        `Error fetching categories with tags`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return { items: [] };
+    }
+  }
+
+  /**
+   * Get paginated list of videos for a category with optional tag filtering.
+   * Reads video IDs from Redis cache, fetches full details from MongoDB,
+   * and returns paginated results.
+   */
+  async getVideoList(dto: VideoListRequestDto): Promise<VideoListResponseDto> {
+    const { page, size, categoryId, tagName } = dto;
+    let key: string;
+    let tagId: string | undefined;
+
+    // Step 1: Resolve the Redis key
+    if (!tagName) {
+      // No tag filter - use category list
+      key = tsCacheKeys.video.categoryList(categoryId);
+    } else {
+      // Tag filter - need to find tag ID first
+      try {
+        const tagKey = tsCacheKeys.tag.metadataByCategory(categoryId);
+        const tags =
+          await this.redis.getJson<
+            Array<{ id: string; name: string; seq: number }>
+          >(tagKey);
+
+        if (!tags || tags.length === 0) {
+          this.logger.debug(
+            `No tags found for categoryId=${categoryId}, tagName=${tagName}`,
+          );
+          return {
+            page,
+            size,
+            total: 0,
+            tagName,
+            items: [],
+          };
+        }
+
+        const tag = tags.find((t) => t.name === tagName);
+        if (!tag) {
+          this.logger.debug(
+            `Tag not found: categoryId=${categoryId}, tagName=${tagName}`,
+          );
+          return {
+            page,
+            size,
+            total: 0,
+            tagName,
+            items: [],
+          };
+        }
+
+        tagId = tag.id;
+        key = tsCacheKeys.video.tagList(categoryId, tagId);
+      } catch (err) {
+        this.logger.error(
+          `Error fetching tag for categoryId=${categoryId}, tagName=${tagName}`,
+          err instanceof Error ? err.stack : String(err),
+        );
+        return {
+          page,
+          size,
+          total: 0,
+          tagName,
+          items: [],
+        };
+      }
+    }
+
+    // Step 2: Get total count and compute pagination
+    let total: number;
+    try {
+      total = await this.redis.llen(key);
+    } catch (err) {
+      this.logger.error(
+        `Error getting list length for key=${key}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return {
+        page,
+        size,
+        total: 0,
+        tagName,
+        items: [],
+      };
+    }
+
+    if (total === 0) {
+      this.logger.debug(`Empty video list for key=${key}`);
+      return {
+        page,
+        size,
+        total: 0,
+        tagName,
+        items: [],
+      };
+    }
+
+    // Step 3: Compute pagination indices
+    const start = (page - 1) * size;
+    const stop = start + size - 1;
+
+    // Check if page is out of range
+    if (start >= total) {
+      this.logger.debug(
+        `Page out of range: page=${page}, size=${size}, total=${total}, key=${key}`,
+      );
+      return {
+        page,
+        size,
+        total,
+        tagName,
+        items: [],
+      };
+    }
+
+    // Step 4: Fetch video IDs from Redis
+    let videoIds: string[];
+    try {
+      videoIds = await this.redis.lrange(key, start, stop);
+    } catch (err) {
+      this.logger.error(
+        `Error fetching video IDs from key=${key}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return {
+        page,
+        size,
+        total,
+        tagName,
+        items: [],
+      };
+    }
+
+    if (!videoIds || videoIds.length === 0) {
+      this.logger.debug(`No video IDs found for key=${key}`);
+      return {
+        page,
+        size,
+        total,
+        tagName,
+        items: [],
+      };
+    }
+
+    // Step 5: Fetch video details from MongoDB
+    let videos: Awaited<
+      ReturnType<typeof this.mongoPrisma.videoMedia.findMany>
+    >;
+    try {
+      videos = await this.mongoPrisma.videoMedia.findMany({
+        where: {
+          id: { in: videoIds },
+        },
+      });
+    } catch (err) {
+      this.logger.error(
+        `Error fetching videos from MongoDB for ids=${videoIds.join(',')}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return {
+        page,
+        size,
+        total,
+        tagName,
+        items: [],
+      };
+    }
+
+    // Step 6: Fetch category info
+    let category;
+    try {
+      category = await this.mongoPrisma.category.findUnique({
+        where: { id: categoryId },
+      });
+    } catch (err) {
+      this.logger.error(
+        `Error fetching category for categoryId=${categoryId}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      // Continue without category info
+    }
+
+    // Step 7: Fetch all tags for these videos
+    const allTagIds = new Set<string>();
+    for (const video of videos) {
+      if (video.tagIds && Array.isArray(video.tagIds)) {
+        for (const tid of video.tagIds) {
+          allTagIds.add(tid);
+        }
+      }
+    }
+
+    let tagsById = new Map<string, string>();
+    if (allTagIds.size > 0) {
+      try {
+        const tagsList = await this.mongoPrisma.tag.findMany({
+          where: {
+            id: { in: Array.from(allTagIds) },
+          },
+          select: { id: true, name: true },
+        });
+
+        tagsById = new Map(tagsList.map((t) => [t.id, t.name]));
+      } catch (err) {
+        this.logger.error(
+          `Error fetching tags from MongoDB`,
+          err instanceof Error ? err.stack : String(err),
+        );
+        // Continue without tag names
+      }
+    }
+
+    // Step 8: Create a map for O(1) lookup and maintain order
+    const videoMap = new Map(videos.map((v) => [v.id, v]));
+
+    // Step 9: Map to VideoListItemDto in the order of videoIds
+    const items = videoIds
+      .map((videoId) => {
+        const video = videoMap.get(videoId);
+        if (!video) {
+          this.logger.debug(
+            `Video not found in MongoDB for videoId=${videoId}`,
+          );
+          return null;
+        }
+
+        // Map tag IDs to tag names
+        const tags: string[] = [];
+        if (video.tagIds && Array.isArray(video.tagIds)) {
+          for (const tid of video.tagIds) {
+            const tagName = tagsById.get(tid);
+            if (tagName) {
+              tags.push(tagName);
+            }
+          }
+        }
+
+        return {
+          id: video.id,
+          title: video.title ?? '',
+          coverImg: video.coverImg ?? undefined,
+          duration: video.videoTime ?? undefined,
+          categoryId: video.categoryId ?? categoryId,
+          name: category?.name ?? '',
+          subtitle: category?.subtitle ?? undefined,
+          channelId: category?.channelId ?? '',
+          tags,
+          updateAt: video.updatedAt?.toString() ?? new Date().toISOString(),
+        };
+      })
+      .filter((item): item is NonNullable<typeof item> => item !== null);
+
+    return {
+      page,
+      size,
+      total,
+      tagName,
+      items,
+    };
+  }
+
+  /**
+   * Search videos by tag name across all categories.
+   *
+   * Algorithm:
+   * 1. Load all categories (from Redis or MongoDB)
+   * 2. For each category, read tag metadata and find tags matching tagName
+   * 3. Collect (categoryId, tagId) pairs where tag.name === dto.tagName
+   * 4. For each pair, read all video IDs from box:app:video:tag:list:{categoryId}:{tagId}
+   * 5. Combine all IDs, deduplicate, compute total
+   * 6. Apply in-memory pagination on unique ID list
+   * 7. Fetch videos from MongoDB, join with category metadata
+   * 8. Map to VideoListItemDto and return response
+   */
+  async searchVideosByTagName(
+    dto: VideoSearchByTagRequestDto,
+  ): Promise<VideoListResponseDto> {
+    const { page, size, tagName } = dto;
+
+    // Step 1: Load all categories
+    let categories: Array<{
+      id: string;
+      name: string;
+      subtitle?: string;
+      channelId: string;
+    }>;
+    try {
+      const categoriesKey = tsCacheKeys.category.all();
+      categories = await this.redis.getJson<
+        Array<{
+          id: string;
+          name: string;
+          subtitle?: string;
+          channelId: string;
+        }>
+      >(categoriesKey);
+
+      if (!categories || categories.length === 0) {
+        // Fallback to MongoDB if Redis cache is empty
+        this.logger.debug(
+          'Categories not found in Redis, fetching from MongoDB',
+        );
+        const categoriesFromDb = await this.mongoPrisma.category.findMany({
+          select: {
+            id: true,
+            name: true,
+            subtitle: true,
+            channelId: true,
+          },
+        });
+        categories = categoriesFromDb.map((c) => ({
+          id: c.id,
+          name: c.name ?? '',
+          subtitle: c.subtitle ?? undefined,
+          channelId: c.channelId ?? '',
+        }));
+      }
+    } catch (err) {
+      this.logger.error(
+        'Error loading categories for tag search',
+        err instanceof Error ? err.stack : String(err),
+      );
+      return {
+        page,
+        size,
+        total: 0,
+        tagName,
+        items: [],
+      };
+    }
+
+    if (!categories || categories.length === 0) {
+      this.logger.debug('No categories found');
+      return {
+        page,
+        size,
+        total: 0,
+        tagName,
+        items: [],
+      };
+    }
+
+    // Step 2 & 3: For each category, find matching tags and collect (categoryId, tagId) pairs
+    const categoryTagPairs: Array<{ categoryId: string; tagId: string }> = [];
+
+    for (const category of categories) {
+      try {
+        const tagKey = tsCacheKeys.tag.metadataByCategory(category.id);
+        // Tag metadata is stored as a LIST of JSON strings, not a single JSON string
+        const tagJsonStrings = await this.redis.lrange(tagKey, 0, -1);
+
+        if (tagJsonStrings && tagJsonStrings.length > 0) {
+          const tags: Array<{ id: string; name: string; seq: number }> = [];
+          for (const jsonStr of tagJsonStrings) {
+            try {
+              const tag = JSON.parse(jsonStr);
+              tags.push(tag);
+            } catch (parseErr) {
+              this.logger.debug(
+                `Failed to parse tag JSON for category ${category.id}`,
+              );
+            }
+          }
+
+          const matchingTags = tags.filter((t) => t.name === tagName);
+          for (const tag of matchingTags) {
+            categoryTagPairs.push({
+              categoryId: category.id,
+              tagId: tag.id,
+            });
+          }
+        }
+      } catch (err) {
+        this.logger.debug(
+          `Error fetching tags for categoryId=${category.id}`,
+          err instanceof Error ? err.stack : String(err),
+        );
+        // Continue with next category
+      }
+    }
+
+    if (categoryTagPairs.length === 0) {
+      this.logger.debug(`No categories found with tag: ${tagName}`);
+      return {
+        page,
+        size,
+        total: 0,
+        tagName,
+        items: [],
+      };
+    }
+
+    // Step 4: For each (categoryId, tagId) pair, read all video IDs
+    const allVideoIds: string[] = [];
+
+    for (const pair of categoryTagPairs) {
+      try {
+        const key = tsCacheKeys.video.tagList(pair.categoryId, pair.tagId);
+        const videoIds = await this.redis.lrange(key, 0, -1);
+        if (videoIds && videoIds.length > 0) {
+          allVideoIds.push(...videoIds);
+        }
+      } catch (err) {
+        this.logger.debug(
+          `Error reading video IDs for categoryId=${pair.categoryId}, tagId=${pair.tagId}`,
+          err instanceof Error ? err.stack : String(err),
+        );
+        // Continue with next pair
+      }
+    }
+
+    if (allVideoIds.length === 0) {
+      this.logger.debug(`No videos found for tag: ${tagName}`);
+      return {
+        page,
+        size,
+        total: 0,
+        tagName,
+        items: [],
+      };
+    }
+
+    // Step 5: Deduplicate and compute total
+    const uniqueVideoIds = Array.from(new Set(allVideoIds));
+    const total = uniqueVideoIds.length;
+
+    // Step 6: Apply in-memory pagination
+    const start = (page - 1) * size;
+    const end = start + size;
+    const pagedIds = uniqueVideoIds.slice(start, end);
+
+    if (pagedIds.length === 0) {
+      return {
+        page,
+        size,
+        total,
+        tagName,
+        items: [],
+      };
+    }
+
+    // Step 7: Fetch videos from MongoDB
+    let videos: Awaited<
+      ReturnType<typeof this.mongoPrisma.videoMedia.findMany>
+    >;
+    try {
+      videos = await this.mongoPrisma.videoMedia.findMany({
+        where: {
+          id: { in: pagedIds },
+        },
+      });
+    } catch (err) {
+      this.logger.error(
+        `Error fetching videos from MongoDB for tag search`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return {
+        page,
+        size,
+        total,
+        tagName,
+        items: [],
+      };
+    }
+
+    if (!videos || videos.length === 0) {
+      return {
+        page,
+        size,
+        total,
+        tagName,
+        items: [],
+      };
+    }
+
+    // Fetch category data for each video
+    const categoryIds = Array.from(
+      new Set(
+        videos.map((v) => v.categoryId).filter((id): id is string => !!id),
+      ),
+    );
+    const categoriesMap = new Map(
+      categories
+        .filter((c) => categoryIds.includes(c.id))
+        .map((c) => [c.id, c]),
+    );
+
+    // Fetch all tags for mapping tag IDs to names
+    const allTagIds = Array.from(
+      new Set(
+        videos.flatMap((v) =>
+          v.tagIds && Array.isArray(v.tagIds) ? v.tagIds : [],
+        ),
+      ),
+    );
+
+    const tagsById = new Map<string, string>();
+    try {
+      if (allTagIds.length > 0) {
+        const tags = await this.mongoPrisma.tag.findMany({
+          where: {
+            id: { in: allTagIds },
+          },
+          select: {
+            id: true,
+            name: true,
+          },
+        });
+
+        for (const tag of tags) {
+          if (tag.name) {
+            tagsById.set(tag.id, tag.name);
+          }
+        }
+      }
+    } catch (err) {
+      this.logger.error(
+        'Error fetching tags for search results',
+        err instanceof Error ? err.stack : String(err),
+      );
+      // Continue without tag names
+    }
+
+    // Step 8: Map to VideoListItemDto (maintain order of pagedIds)
+    const videoMap = new Map(videos.map((v) => [v.id, v]));
+
+    const items = pagedIds
+      .map((videoId) => {
+        const video = videoMap.get(videoId);
+        if (!video) {
+          return null;
+        }
+
+        const category = video.categoryId
+          ? categoriesMap.get(video.categoryId)
+          : undefined;
+
+        // Map tag IDs to tag names
+        const tags: string[] = [];
+        if (video.tagIds && Array.isArray(video.tagIds)) {
+          for (const tid of video.tagIds) {
+            const tagName = tagsById.get(tid);
+            if (tagName) {
+              tags.push(tagName);
+            }
+          }
+        }
+
+        return {
+          id: video.id,
+          title: video.title ?? '',
+          coverImg: video.coverImg ?? undefined,
+          duration: video.videoTime ?? undefined,
+          categoryId: video.categoryId ?? '',
+          name: category?.name ?? '',
+          subtitle: category?.subtitle ?? undefined,
+          channelId: category?.channelId ?? '',
+          tags,
+          updateAt: video.updatedAt?.toString() ?? new Date().toISOString(),
+        };
+      })
+      .filter((item): item is NonNullable<typeof item> => item !== null);
+
+    return {
+      page,
+      size,
+      total,
+      tagName,
+      items,
+    };
+  }
 }

+ 18 - 17
libs/core/src/ad/ad-pool.service.ts

@@ -20,6 +20,8 @@ export interface AdPayload {
   trackingId?: string;
 }
 
+type AdPoolEntry = AdPayload;
+
 @Injectable()
 export class AdPoolService {
   private readonly logger = new Logger(AdPoolService.name);
@@ -88,17 +90,10 @@ export class AdPoolService {
     }));
 
     const key = CacheKeys.appAdPoolByType(adType);
-    await this.redis.del(key);
 
-    if (!payloads.length) {
-      // Ensure the key exists (as an empty SET) so checklist doesn't fail
-      // Use a placeholder that will be ignored by consumers
-      await this.redis.sadd(key, '__empty__');
-      return 0;
-    }
-
-    const members = payloads.map((p) => JSON.stringify(p));
-    await this.redis.sadd(key, ...members);
+    // Always overwrite with a JSON string so consumers never hit WRONGTYPE
+    await this.redis.del(key);
+    await this.redis.setJson<AdPoolEntry[]>(key, payloads);
 
     return payloads.length;
   }
@@ -107,13 +102,19 @@ export class AdPoolService {
   async getRandomFromRedisPool(adType: AdType): Promise<AdPayload | null> {
     try {
       const key = CacheKeys.appAdPoolByType(adType);
-      const raw = await this.redis.srandmember(key);
-      if (!raw) return null;
-      // Skip the empty placeholder marker
-      if (raw === '__empty__') return null;
-      const parsed = JSON.parse(raw) as Partial<AdPayload>;
-      if (!parsed || typeof parsed !== 'object' || !parsed.id) return null;
-      return parsed as AdPayload;
+      const pool = await this.redis.getJson<AdPoolEntry[]>(key);
+
+      if (!pool || pool.length === 0) return null;
+
+      const pickIndex = Math.floor(Math.random() * pool.length);
+      const picked = pool[pickIndex];
+
+      if (!picked || !picked.id) return null;
+
+      return {
+        ...picked,
+        trackingId: picked.trackingId ?? this.generateTrackingId(),
+      };
     } catch (err) {
       this.logger.warn(
         `getRandomFromRedisPool error for adType=${adType}`,