浏览代码

feat: implement video search by secondTags and cache management for video list

Dave 1 月之前
父节点
当前提交
091d3c5263

+ 30 - 0
apps/box-app-api/src/feature/video/video.controller.ts

@@ -26,6 +26,7 @@ import {
   VideoSearchByTagRequestDto,
   VideoClickDto,
   RecommendedVideosDto,
+  VideoItemDto,
 } from './dto';
 import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
 
@@ -165,6 +166,35 @@ export class VideoController {
   }
 
   /**
+   * GET /api/v1/video/search
+   *
+   * Search cached recommended videos by secondTags (supports comma-separated tags).
+   */
+  @Get('search')
+  @ApiOperation({
+    summary: '基于 secondTags 搜索视频',
+    description:
+      '使用缓存中 secondTags 值过滤视频;支持多个标签,用逗号分隔,tag 为空则返回全部。',
+  })
+  @ApiQuery({
+    name: 'tags',
+    required: false,
+    description: '逗号分隔的 secondTags 标签列表(精确匹配)',
+  })
+  @ApiResponse({
+    status: 200,
+    description: '匹配到的视频列表',
+    type: VideoItemDto,
+    isArray: true,
+  })
+  async searchBySecondTags(
+    @Query('tags') tags?: string,
+  ): Promise<{ total: number; list: VideoItemDto[] }> {
+    const list = await this.videoService.searchVideosBySecondTags(tags);
+    return { total: list.length, list };
+  }
+
+  /**
    * POST /video/click
    *
    * Record video click event for analytics.

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

@@ -1712,7 +1712,7 @@ export class VideoService {
     try {
       // Try to fetch from Redis cache first
       const cached = await this.redis.getJson<VideoItemDto[]>(
-    tsCacheKeys.video.recommended(),
+        tsCacheKeys.video.recommended(),
       );
 
       if (cached && Array.isArray(cached) && cached.length > 0) {
@@ -1784,4 +1784,70 @@ export class VideoService {
           : undefined,
     };
   }
+
+  /**
+   * Read the cached video list key built by box-mgnt-api.
+   */
+  async getVideoListFromCache(): Promise<VideoItemDto[]> {
+    const key = tsCacheKeys.video.list();
+    try {
+      const raw = await this.redis.get(key);
+      if (!raw) {
+        return [];
+      }
+
+      const parsed = JSON.parse(raw);
+      if (Array.isArray(parsed)) {
+        return parsed;
+      }
+    } catch (err) {
+      this.logger.error(
+        `Failed to read video list cache (${key})`,
+        err instanceof Error ? err.stack : String(err),
+      );
+    }
+
+    return [];
+  }
+
+  /**
+   * Search the cached video list by secondTags, with fallback for videos that have no secondTags.
+   */
+  async searchVideosBySecondTags(tags?: string): Promise<VideoItemDto[]> {
+    const videos = await this.getVideoListFromCache();
+    if (!tags) {
+      return videos;
+    }
+
+    const requestedTags = tags
+      .split(',')
+      .map((tag) => tag.trim())
+      .filter((tag) => tag.length > 0);
+
+    if (requestedTags.length === 0) {
+      return videos;
+    }
+
+    const tagSet = new Set(requestedTags);
+    return videos.filter((video) => this.matchesSecondTags(video, tagSet));
+  }
+
+  private matchesSecondTags(
+    video: VideoItemDto,
+    filters: Set<string>,
+  ): boolean {
+    const secondTags = Array.isArray(video.secondTags)
+      ? video.secondTags
+          .map((tag) => tag?.trim())
+          .filter(
+            (tag): tag is string => typeof tag === 'string' && tag.length > 0,
+          )
+      : [];
+
+    if (secondTags.length === 0) {
+      // return true;
+    }
+
+    return secondTags.some((tag) => filters.has(tag));
+  }
 }

+ 1 - 0
apps/box-mgnt-api/src/cache-sync/cache-checklist.service.ts

@@ -118,6 +118,7 @@ export class CacheChecklistService implements OnApplicationBootstrap {
   private computeRequiredKeys(): string[] {
     const keys: string[] = [CHANNEL_ALL_KEY, CATEGORY_ALL_KEY, TAG_ALL_KEY];
 
+    keys.push(tsCacheKeys.video.list());
     // Add one ad pool key per AdType (no scene/slot - simplified to one pool per type)
     const adTypes = Object.values(PrismaAdType) as AdType[];
     for (const adType of adTypes) {

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

@@ -101,4 +101,5 @@ export const CacheKeys = {
   // RECOMMENDED VIDEOS
   // ─────────────────────────────────────────────
   appRecommendedVideos: 'box:app:video:recommended',
+  appVideoList: 'box:app:video:list',
 };

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

@@ -212,6 +212,7 @@ export interface TsCacheKeyBuilder {
      */
     homeSection(channelId: string, section: VideoHomeSectionKey): string;
     recommended(): string;
+    list(): string;
   };
 
   /**
@@ -268,6 +269,7 @@ export function createTsCacheKeyBuilder(): TsCacheKeyBuilder {
       homeSection: (channelId, section) =>
         CacheKeys.appVideoHomeSectionKey(channelId, section),
       recommended: () => CacheKeys.appRecommendedVideos,
+      list: () => CacheKeys.appVideoList,
     },
     videoList: {
       homePage: (page) => CacheKeys.appHomeVideoPage(page),

+ 32 - 0
libs/core/src/cache/video/recommended/recommended-videos-cache.builder.ts

@@ -39,6 +39,7 @@ export interface RecommendedVideoItem {
 export class RecommendedVideosCacheBuilder extends BaseCacheBuilder {
   private readonly RECOMMENDED_COUNT = 99;
   private readonly CACHE_TTL = 3600; // 1 hour
+  private readonly LIST_BATCH_SIZE = 5000;
 
   constructor(redis: RedisService, mongoPrisma: MongoPrismaService) {
     super(redis, mongoPrisma, RecommendedVideosCacheBuilder.name);
@@ -74,6 +75,8 @@ export class RecommendedVideosCacheBuilder extends BaseCacheBuilder {
         this.CACHE_TTL,
       );
 
+      await this.buildVideoListAll();
+
       this.logger.log(
         `Recommended videos cache built: ${items.length} videos stored`,
       );
@@ -123,4 +126,33 @@ export class RecommendedVideosCacheBuilder extends BaseCacheBuilder {
   getCacheKey(): string {
     return tsCacheKeys.video.recommended();
   }
+
+  private async buildVideoListAll(): Promise<void> {
+    this.logger.log('Building video list cache from all completed videos...');
+    const items: RecommendedVideoItem[] = [];
+    let lastId: string | null = null;
+
+    while (true) {
+      const batch = await this.mongoPrisma.videoMedia.findMany({
+        where: {
+          status: 'Completed',
+          ...(lastId ? { id: { gt: lastId } } : {}),
+        },
+        orderBy: { id: 'asc' },
+        take: this.LIST_BATCH_SIZE,
+      });
+
+      if (batch.length === 0) {
+        break;
+      }
+
+      items.push(...batch.map((video) => this.mapVideoToItem(video)));
+      lastId = batch[batch.length - 1].id;
+    }
+
+    await this.redis.setJson(tsCacheKeys.video.list(), items, this.CACHE_TTL);
+    this.logger.log(
+      `Video list cache built from ${items.length} completed videos`,
+    );
+  }
 }