Procházet zdrojové kódy

feat(homepage): refactor homepage DTOs and remove unused VideoItemDto
feat(video): add getRecommendedVideos endpoint and implement latest videos retrieval by category

Dave před 2 měsíci
rodič
revize
d4bc0512b0

+ 22 - 95
apps/box-app-api/src/feature/homepage/dto/homepage.dto.ts

@@ -69,68 +69,6 @@ export class CategoryDto {
 }
 
 /**
- * Video item for lists
- */
-export class VideoItemDto {
-  @ApiProperty({ description: '视频ID(来源:Mongo Video.id)' })
-  id: string;
-
-  @ApiProperty({ description: '视频标题(Video.title)' })
-  title: string;
-
-  @ApiProperty({ required: false, description: '封面图(coverImg)' })
-  coverImg?: string;
-
-  @ApiProperty({ required: false, description: '新封面图(coverImgNew)' })
-  coverImgNew?: string;
-
-  @ApiProperty({ required: false, description: '视频时长(秒)(videoTime)' })
-  videoTime?: number;
-
-  @ApiProperty({ required: false, description: '发布信息(publish)' })
-  publish?: string;
-
-  @ApiProperty({
-    required: false,
-    description: '二级标签列表(secondTags)',
-  })
-  secondTags?: string[];
-
-  @ApiProperty({ required: false, description: '更新时间(updatedAt)' })
-  updatedAt?: Date;
-
-  @ApiProperty({ required: false, description: '文件名(filename)' })
-  filename?: string;
-
-  @ApiProperty({ required: false, description: '字段名(fieldNameFs)' })
-  fieldNameFs?: string;
-
-  @ApiProperty({ required: false, description: '宽度(width)' })
-  width?: number;
-
-  @ApiProperty({ required: false, description: '高度(height)' })
-  height?: number;
-
-  @ApiProperty({
-    required: false,
-    description: '标签名称列表(tags,denormalized)',
-  })
-  tags?: string[];
-
-  @ApiProperty({ required: false, description: '预文件名(preFileName)' })
-  preFileName?: string;
-
-  @ApiProperty({
-    required: false,
-    description: '演员列表(actors)',
-  })
-  actors?: string[];
-
-  @ApiProperty({ required: false, description: '文件大小(size,BigInt)' })
-  size?: string;
-}
-
-/**
  * Waterfall ads group
  */
 export class WaterfallAdsDto {
@@ -206,38 +144,27 @@ export class HomeAdsDto {
 }
 
 /**
- * Recommended videos section (7 videos: 1 hero + 6 grid)
- */
-export class RecommendedVideosDto {
-  @ApiProperty({ type: [VideoItemDto], description: '全部7条视频数据' })
-  items: VideoItemDto[];
-
-  @ApiProperty({ description: '总数(固定为7)' })
-  total: number;
-}
-
-/**
  * Complete homepage response
  */
-export class HomepageDto {
-  @ApiProperty({ type: HomeAdsDto, description: '首页广告聚合数据' })
-  ads: HomeAdsDto;
-
-  @ApiProperty({
-    type: [AnnouncementDto],
-    description: '公告列表(来源:SystemParam)',
-  })
-  announcements: AnnouncementDto[];
-
-  @ApiProperty({
-    type: [CategoryDto],
-    description: '视频分类列表(来源:Category)',
-  })
-  categories: CategoryDto[];
-
-  @ApiProperty({
-    type: RecommendedVideosDto,
-    description: '推荐视频数据(来源:Video)',
-  })
-  videos: RecommendedVideosDto;
-}
+// export class HomepageDto {
+//   @ApiProperty({ type: HomeAdsDto, description: '首页广告聚合数据' })
+//   ads: HomeAdsDto;
+
+//   @ApiProperty({
+//     type: [AnnouncementDto],
+//     description: '公告列表(来源:SystemParam)',
+//   })
+//   announcements: AnnouncementDto[];
+
+//   @ApiProperty({
+//     type: [CategoryDto],
+//     description: '视频分类列表(来源:Category)',
+//   })
+//   categories: CategoryDto[];
+
+//   @ApiProperty({
+//     type: RecommendedVideosDto,
+//     description: '推荐视频数据(来源:Video)',
+//   })
+//   videos: RecommendedVideosDto;
+// }

+ 16 - 3
apps/box-app-api/src/feature/homepage/homepage.controller.ts

@@ -2,7 +2,8 @@
 import { Controller, Get } from '@nestjs/common';
 import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
 import { HomepageService } from './homepage.service';
-import { HomepageDto } from './dto/homepage.dto';
+import { HomeAdsDto, AnnouncementDto, CategoryDto } from './dto/homepage.dto';
+import { RecommendedVideosDto } from '../video/dto';
 
 @ApiTags('首页')
 @Controller('homepage')
@@ -18,9 +19,21 @@ export class HomepageController {
   @ApiResponse({
     status: 200,
     description: '成功返回首页数据结构',
-    type: HomepageDto,
+    schema: {
+      example: {
+        ads: {},
+        announcements: [],
+        categories: [],
+        videos: {},
+      },
+    },
   })
-  async getHomepage(): Promise<HomepageDto> {
+  async getHomepage(): Promise<{
+    ads: HomeAdsDto;
+    announcements: AnnouncementDto[];
+    categories: CategoryDto[];
+    videos: RecommendedVideosDto;
+  }> {
     return this.homepageService.getHomepageData();
   }
 }

+ 11 - 9
apps/box-app-api/src/feature/homepage/homepage.service.ts

@@ -6,17 +6,15 @@ import { AdType } from '@prisma/mongo/client';
 import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
 import { VideoService } from '../video/video.service';
 import {
-  HomepageDto,
   HomeAdsDto,
   HomeAdDto,
   AnnouncementDto,
   CategoryDto,
-  VideoItemDto,
   WaterfallAdsDto,
   PopupAdsDto,
   FloatingAdsDto,
-  RecommendedVideosDto,
 } from './dto/homepage.dto';
+import { RecommendedVideosDto } from '../video/dto';
 import {
   AdOrder,
   SystemParamSide,
@@ -55,14 +53,18 @@ export class HomepageService {
   /**
    * Get complete homepage data in single API call
    */
-  async getHomepageData(): Promise<HomepageDto> {
+  async getHomepageData(): Promise<{
+    ads: HomeAdsDto;
+    announcements: AnnouncementDto[];
+    categories: CategoryDto[];
+    videos: RecommendedVideosDto;
+  }> {
     const [ads, announcements, categories, videos] = await Promise.all([
       this.getHomeAds(),
       this.getAnnouncements(),
       this.getCategories(),
-      this.getRecommendedVideos(),
+      this.videoService.getRecommendedVideos(),
     ]);
-
     return {
       ads,
       announcements,
@@ -255,9 +257,9 @@ export class HomepageService {
   /**
    * Get recommended videos (delegated to VideoService)
    */
-  private async getRecommendedVideos(): Promise<RecommendedVideosDto> {
-    return this.videoService.getRecommendedVideos();
-  }
+  // private async getRecommendedVideos(): Promise<RecommendedVideosDto> {
+  //   return this.videoService.getRecommendedVideos();
+  // }
 
   /**
    * Fisher-Yates shuffle for random ordering

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

@@ -1,3 +1,5 @@
+import { ApiProperty } from '@nestjs/swagger';
+
 export {
   VideoCategoryDto,
   VideoTagDto,
@@ -17,3 +19,76 @@ export {
 } from './video-list-response.dto';
 export { VideoSearchByTagRequestDto } from './video-search-by-tag-request.dto';
 export { VideoClickDto } from './video-click.dto';
+
+/**
+ * Video item for lists
+ */
+export class VideoItemDto {
+  @ApiProperty({ description: '视频ID(来源:Mongo Video.id)' })
+  id: string;
+
+  @ApiProperty({ description: '视频标题(Video.title)' })
+  title: string;
+
+  @ApiProperty({ required: false, description: '封面图(coverImg)' })
+  coverImg?: string;
+
+  @ApiProperty({ required: false, description: '新封面图(coverImgNew)' })
+  coverImgNew?: string;
+
+  @ApiProperty({ required: false, description: '视频时长(秒)(videoTime)' })
+  videoTime?: number;
+
+  @ApiProperty({ required: false, description: '发布信息(publish)' })
+  publish?: string;
+
+  @ApiProperty({
+    required: false,
+    description: '二级标签列表(secondTags)',
+  })
+  secondTags?: string[];
+
+  @ApiProperty({ required: false, description: '更新时间(updatedAt)' })
+  updatedAt?: Date;
+
+  @ApiProperty({ required: false, description: '文件名(filename)' })
+  filename?: string;
+
+  @ApiProperty({ required: false, description: '字段名(fieldNameFs)' })
+  fieldNameFs?: string;
+
+  @ApiProperty({ required: false, description: '宽度(width)' })
+  width?: number;
+
+  @ApiProperty({ required: false, description: '高度(height)' })
+  height?: number;
+
+  @ApiProperty({
+    required: false,
+    description: '标签名称列表(tags,denormalized)',
+  })
+  tags?: string[];
+
+  @ApiProperty({ required: false, description: '预文件名(preFileName)' })
+  preFileName?: string;
+
+  @ApiProperty({
+    required: false,
+    description: '演员列表(actors)',
+  })
+  actors?: string[];
+
+  @ApiProperty({ required: false, description: '文件大小(size,BigInt)' })
+  size?: string;
+}
+
+/**
+ * Recommended videos section (7 videos: 1 hero + 6 grid)
+ */
+export class RecommendedVideosDto {
+  @ApiProperty({ type: [VideoItemDto], description: '全部7条视频数据' })
+  items: VideoItemDto[];
+
+  @ApiProperty({ description: '总数(固定为7)' })
+  total: number;
+}

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

@@ -28,6 +28,7 @@ import {
   VideoListResponseDto,
   VideoSearchByTagRequestDto,
   VideoClickDto,
+  RecommendedVideosDto,
 } from './dto';
 import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
 
@@ -47,6 +48,48 @@ export class VideoController {
   constructor(private readonly videoService: VideoService) {}
 
   /**
+   * GET /api/v1/video/recommended
+   *
+   * Get recommended videos from Redis.
+   */
+  @Get('recommended')
+  @ApiOperation({
+    summary: '获取推荐视频列表',
+    description: '从 Redis 获取推荐视频列表。',
+  })
+  @ApiResponse({
+    status: 200,
+    description: '推荐视频列表',
+    type: RecommendedVideosDto,
+  })
+  async getRecommendedVideos(): Promise<RecommendedVideosDto> {
+    return this.videoService.getRecommendedVideos();
+  }
+
+  /**
+   * GET /api/v1/video/category/:channelId/:categoryId/latest
+   *
+   * Get latest videos for a category from Redis.
+   */
+  @Get('category/:channelId/:categoryId/latest')
+  @ApiOperation({
+    summary: '获取分类最新视频',
+    description: '从 Redis 获取指定频道和分类的最新视频列表。',
+  })
+  @ApiResponse({
+    status: 200,
+    description: '最新视频列表',
+    type: VideoDetailDto,
+    isArray: true,
+  })
+  async getLatestVideosByCategory(
+    @Param('channelId') channelId: string,
+    @Param('categoryId') categoryId: string,
+  ): Promise<VideoDetailDto[]> {
+    return this.videoService.getLatestVideosByCategory(channelId, categoryId);
+  }
+
+  /**
    * Get categories for a channel from Redis cache.
    */
   // @Get('categories/:channelId')

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

@@ -16,6 +16,8 @@ import {
   VideoListResponseDto,
   VideoSearchByTagRequestDto,
   VideoClickDto,
+  RecommendedVideosDto,
+  VideoItemDto,
 } from './dto';
 import {
   RabbitmqPublisherService,
@@ -24,15 +26,11 @@ import {
 import { randomUUID } from 'crypto';
 import { nowEpochMsBigInt } from '@box/common/time/time.util';
 import {
-  CategoryDto,
-  VideoItemDto,
-  RecommendedVideosDto,
-} from '../homepage/dto/homepage.dto';
-import {
   CategoryType,
   RECOMMENDED_CATEGORY_ID,
   RECOMMENDED_CATEGORY_NAME,
 } from '../homepage/homepage.constants';
+import { CategoryDto } from '../homepage/dto/homepage.dto';
 
 /**
  * VideoService provides read-only access to video data from Redis cache.
@@ -44,6 +42,50 @@ import {
  */
 @Injectable()
 export class VideoService {
+  /**
+   * Get latest videos for a category from Redis (for controller endpoint).
+   * Uses key: box:app:video:list:category:{channelId}:{categoryId}:latest
+   */
+  async getLatestVideosByCategory(
+    channelId: string,
+    categoryId: string,
+  ): Promise<VideoDetailDto[]> {
+    try {
+      // Compose Redis key for latest videos
+      const key = `box:app:video:list:category:${channelId}:${categoryId}:latest`;
+      // Get video IDs from Redis (LIST)
+      const videoIds: string[] = await this.redis.lrange(key, 0, -1);
+      if (!videoIds || videoIds.length === 0) {
+        return [];
+      }
+      // Fetch video details from MongoDB, preserving order
+      const videos = await this.mongoPrisma.videoMedia.findMany({
+        where: { id: { in: videoIds } },
+      });
+      const videoMap = new Map(videos.map((v) => [v.id, v]));
+      return videoIds
+        .map((id) => {
+          const video = videoMap.get(id);
+          if (!video) return null;
+          return {
+            id: video.id,
+            title: video.title ?? '',
+            categoryIds: video.categoryIds,
+            tagIds: video.tagIds,
+            listStatus: video.listStatus,
+            editedAt: video.editedAt?.toString() ?? '',
+            updatedAt: video.updatedAt?.toISOString() ?? '',
+          } as VideoDetailDto;
+        })
+        .filter((v): v is VideoDetailDto => v !== null);
+    } catch (err) {
+      this.logger.error(
+        `Error fetching latest videos for channelId=${channelId}, categoryId=${categoryId}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return [];
+    }
+  }
   private readonly logger = new Logger(VideoService.name);
   private readonly cacheHelper: VideoCacheHelper;