Browse Source

feat: implement homepage and sys-params modules with controllers and services; enhance Swagger documentation

Dave 2 months ago
parent
commit
7845dbb5a3

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

@@ -6,6 +6,8 @@ import { HealthModule } from './health/health.module';
 import { PrismaMongoModule } from './prisma/prisma-mongo.module';
 import { VideoModule } from './feature/video/video.module';
 import { AdModule } from './feature/ads/ad.module';
+import { HomepageModule } from './feature/homepage/homepage.module';
+import { SysParamsModule } from './feature/sys-params/sys-params.module';
 import { RedisModule } from '@box/db/redis/redis.module';
 
 @Module({
@@ -40,6 +42,8 @@ import { RedisModule } from '@box/db/redis/redis.module';
     HealthModule,
     VideoModule,
     AdModule,
+    HomepageModule,
+    SysParamsModule,
   ],
 })
 export class AppModule {}

+ 8 - 0
apps/box-app-api/src/feature/ads/ad.controller.ts

@@ -1,9 +1,11 @@
 // apps/box-app-api/src/feature/ads/ad.controller.ts
 import { Controller, Get, Logger, Query } 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';
 
+@ApiTags('广告')
 @Controller('ads')
 export class AdController {
   private readonly logger = new Logger(AdController.name);
@@ -20,6 +22,12 @@ export class AdController {
    * Your global response interceptor will wrap this into the standard envelope.
    */
   @Get('placement')
+  @ApiOperation({
+    summary: '按位置获取广告',
+    description:
+      '根据场景(scene)、插槽(slot)、广告类型(adType)获取可用广告。数据来源对齐 Prisma Mongo Ads 模型。示例:/ads/placement?scene=home&slot=top&adType=BANNER',
+  })
+  @ApiResponse({ status: 200, description: '成功返回广告或null', type: AdDto })
   async getAdForPlacement(
     @Query() query: GetAdPlacementQueryDto,
   ): Promise<AdDto | null> {

+ 16 - 1
apps/box-app-api/src/feature/ads/dto/ad.dto.ts

@@ -1,13 +1,28 @@
 // apps/box-app-api/src/feature/ads/dto/ad.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
 
-export interface AdDto {
+export class AdDto {
+  @ApiProperty({ description: '广告ID(来源:Mongo Ads.id)' })
   id: string;
+
+  @ApiProperty({ description: '广告类型(Ads.adType)' })
   adType: string;
 
+  @ApiProperty({ description: '广告标题(Ads.title)' })
   title: string;
+
+  @ApiProperty({ description: '广告主/来源(Ads.advertiser)' })
   advertiser: string;
 
+  @ApiProperty({ required: false, description: '富文本或描述(Ads.content)' })
   content?: string;
+
+  @ApiProperty({
+    required: false,
+    description: '封面图CDN地址(Ads.coverImg)',
+  })
   coverImg?: string;
+
+  @ApiProperty({ required: false, description: '跳转链接(Ads.targetUrl)' })
   targetUrl?: string;
 }

+ 15 - 0
apps/box-app-api/src/feature/ads/dto/get-ad-placement.dto.ts

@@ -1,17 +1,32 @@
 // apps/box-app-api/src/feature/ads/dto/get-ad-placement.dto.ts
 import { IsOptional, IsString } from 'class-validator';
+import { ApiProperty } from '@nestjs/swagger';
 
 export class GetAdPlacementQueryDto {
+  @ApiProperty({
+    description: "场景标识,如 'home' | 'detail' | 'player' | 'global'",
+  })
   @IsString()
   scene!: string; // e.g. 'home' | 'detail' | 'player' | 'global'
 
+  @ApiProperty({
+    description: "插槽位置,如 'top' | 'carousel' | 'popup' | 'preroll' 等",
+  })
   @IsString()
   slot!: string; // e.g. 'top' | 'carousel' | 'popup' | 'preroll' | ...
 
+  @ApiProperty({
+    description:
+      "广告类型,如 'BANNER' | 'CAROUSEL' | 'POPUP_IMAGE' 等(来源:Ads.adType)",
+  })
   @IsString()
   adType!: string; // e.g. 'BANNER' | 'CAROUSEL' | 'POPUP_IMAGE' | ...
 
   @IsOptional()
   @IsString()
+  @ApiProperty({
+    required: false,
+    description: '尝试获取次数上限(字符串),后端会解析为数字,默认3',
+  })
   maxTries?: string; // keep as string in query, we’ll parse to number
 }

+ 204 - 0
apps/box-app-api/src/feature/homepage/dto/homepage.dto.ts

@@ -0,0 +1,204 @@
+// apps/box-app-api/src/feature/homepage/dto/homepage.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+import { CategoryType } from '../homepage.constants';
+
+/**
+ * Ad DTO for homepage (simplified from full AdDto)
+ */
+export class HomeAdDto {
+  @ApiProperty({ description: '广告ID(来源:Mongo Ads.id)' })
+  id: string;
+
+  @ApiProperty({ description: '广告类型(Ads.adType)' })
+  adType: string;
+
+  @ApiProperty({ description: '广告标题(Ads.title)' })
+  title: string;
+
+  @ApiProperty({ description: '广告主/来源(Ads.advertiser)' })
+  advertiser: string;
+
+  @ApiProperty({ required: false, description: '富文本或描述(Ads.content)' })
+  content?: string;
+
+  @ApiProperty({
+    required: false,
+    description: '封面图CDN地址(Ads.coverImg)',
+  })
+  coverImg?: string;
+
+  @ApiProperty({ required: false, description: '跳转链接(Ads.targetUrl)' })
+  targetUrl?: string;
+}
+
+/**
+ * Announcement/Notice for marquee display
+ */
+export class AnnouncementDto {
+  @ApiProperty({ description: '公告ID(来源:Mongo SystemParam.id)' })
+  id: string;
+
+  @ApiProperty({ description: '公告内容文本(SystemParam.value)' })
+  content: string;
+
+  @ApiProperty({ description: '展示顺序(由后端计算)' })
+  seq: number;
+}
+
+/**
+ * Video category for tabs
+ */
+export class CategoryDto {
+  @ApiProperty({ description: '分类ID(来源:Mongo Category.id)' })
+  id: string;
+
+  @ApiProperty({ description: '分类名称(Category.name)' })
+  name: string;
+
+  @ApiProperty({ description: '分类类型:推荐(recommended)/普通(regular)' })
+  type: CategoryType;
+
+  @ApiProperty({ description: '是否默认选中' })
+  isDefault: boolean;
+
+  @ApiProperty({
+    required: false,
+    description: '排序权重(Category.seq,可选)',
+  })
+  seq?: number;
+}
+
+/**
+ * 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: '封面图CDN地址(Video.coverCdn)',
+  })
+  coverCdn?: string;
+
+  @ApiProperty({ required: false, description: '视频时长(秒)' })
+  duration?: number;
+
+  @ApiProperty({ required: false, description: '标签列表(Video.tags)' })
+  tags?: string[];
+}
+
+/**
+ * Waterfall ads group
+ */
+export class WaterfallAdsDto {
+  @ApiProperty({
+    type: [HomeAdDto],
+    description: '瀑布流-图标广告(来源:Ads)',
+  })
+  icons: HomeAdDto[];
+
+  @ApiProperty({
+    type: [HomeAdDto],
+    description: '瀑布流-文字广告(来源:Ads)',
+  })
+  texts: HomeAdDto[];
+
+  @ApiProperty({
+    type: [HomeAdDto],
+    description: '瀑布流-视频广告(来源:Ads)',
+  })
+  videos: HomeAdDto[];
+}
+
+/**
+ * Popup ads group (multi-step flow)
+ */
+export class PopupAdsDto {
+  @ApiProperty({ type: [HomeAdDto], description: '最多6个随机图标广告' })
+  icons: HomeAdDto[];
+
+  @ApiProperty({ type: [HomeAdDto], description: '随机顺序的图片广告' })
+  images: HomeAdDto[];
+
+  @ApiProperty({ type: [HomeAdDto], description: '按顺序展示的官方广告' })
+  official: HomeAdDto[];
+}
+
+/**
+ * Floating ads group
+ */
+export class FloatingAdsDto {
+  @ApiProperty({ type: [HomeAdDto], description: '底部浮动广告' })
+  bottom: HomeAdDto[];
+
+  @ApiProperty({ type: [HomeAdDto], description: '边缘浮动广告' })
+  edge: HomeAdDto[];
+}
+
+/**
+ * All ads for homepage
+ */
+export class HomeAdsDto {
+  @ApiProperty({
+    type: [HomeAdDto],
+    description: '首页轮播广告(随机顺序)',
+  })
+  carousel: HomeAdDto[];
+
+  @ApiProperty({
+    type: HomeAdDto,
+    nullable: true,
+    description: '单个横幅广告',
+  })
+  banner: HomeAdDto | null;
+
+  @ApiProperty({ type: WaterfallAdsDto, description: '瀑布流广告分组' })
+  waterfall: WaterfallAdsDto;
+
+  @ApiProperty({ type: PopupAdsDto, description: '弹窗广告分组' })
+  popup: PopupAdsDto;
+
+  @ApiProperty({ type: FloatingAdsDto, description: '浮动广告分组' })
+  floating: FloatingAdsDto;
+}
+
+/**
+ * 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;
+}

+ 47 - 0
apps/box-app-api/src/feature/homepage/homepage.constants.ts

@@ -0,0 +1,47 @@
+// Centralized constants & enums for homepage feature.
+// Exported for potential cross-service or frontend reference.
+
+export enum AdOrder {
+  RANDOM = 'random',
+  SEQUENTIAL = 'sequential',
+}
+
+export enum SystemParamSide {
+  CLIENT = 'client',
+}
+
+// Keyword fragment used to identify announcement/notice SystemParam entries.
+export const ANNOUNCEMENT_KEYWORD = 'notice';
+
+export enum CategoryType {
+  RECOMMENDED = 'recommended',
+  REGULAR = 'regular',
+}
+
+export const RECOMMENDED_CATEGORY_ID = 'recommended';
+export const RECOMMENDED_CATEGORY_NAME = '最新推荐';
+
+// Ad slot mapping values consumed by frontend layout logic.
+export enum AdSlot {
+  STARTUP = 'startup',
+  CAROUSEL = 'carousel',
+  MIDDLE = 'middle',
+  WATERFALL = 'waterfall',
+  FLOATING = 'floating',
+  EDGE = 'edge',
+  TOP = 'top',
+  PREROLL = 'preroll',
+  PAUSE_OVERLAY = 'pause_overlay',
+  UNKNOWN = 'unknown',
+}
+
+// Group exported for convenience if needed.
+export const HOMEPAGE_CONSTANTS = {
+  AdOrder,
+  SystemParamSide,
+  ANNOUNCEMENT_KEYWORD,
+  CategoryType,
+  RECOMMENDED_CATEGORY_ID,
+  RECOMMENDED_CATEGORY_NAME,
+  AdSlot,
+};

+ 26 - 0
apps/box-app-api/src/feature/homepage/homepage.controller.ts

@@ -0,0 +1,26 @@
+// apps/box-app-api/src/feature/homepage/homepage.controller.ts
+import { Controller, Get } from '@nestjs/common';
+import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { HomepageService } from './homepage.service';
+import { HomepageDto } from './dto/homepage.dto';
+
+@ApiTags('首页')
+@Controller('homepage')
+export class HomepageController {
+  constructor(private readonly homepageService: HomepageService) {}
+
+  @Get()
+  @ApiOperation({
+    summary: '获取首页聚合数据',
+    description:
+      '单次请求返回首页所需全部数据:包含广告(轮播、横幅、瀑布流、弹窗、浮标)、公告、分类,以及推荐视频。数据来源对齐 Prisma Mongo 模型(Ads、Category、SystemParam、Video)。',
+  })
+  @ApiResponse({
+    status: 200,
+    description: '成功返回首页数据结构',
+    type: HomepageDto,
+  })
+  async getHomepage(): Promise<HomepageDto> {
+    return this.homepageService.getHomepageData();
+  }
+}

+ 13 - 0
apps/box-app-api/src/feature/homepage/homepage.module.ts

@@ -0,0 +1,13 @@
+// apps/box-app-api/src/feature/homepage/homepage.module.ts
+import { Module } from '@nestjs/common';
+import { PrismaMongoModule } from '../../prisma/prisma-mongo.module';
+import { HomepageController } from './homepage.controller';
+import { HomepageService } from './homepage.service';
+
+@Module({
+  imports: [PrismaMongoModule],
+  controllers: [HomepageController],
+  providers: [HomepageService],
+  exports: [HomepageService],
+})
+export class HomepageModule {}

+ 356 - 0
apps/box-app-api/src/feature/homepage/homepage.service.ts

@@ -0,0 +1,356 @@
+// apps/box-app-api/src/feature/homepage/homepage.service.ts
+import { Injectable, Logger } from '@nestjs/common';
+import { RedisService } from '@box/db/redis/redis.service';
+import { CacheKeys } from '@box/common/cache/cache-keys';
+import { AdType } from '@prisma/mongo/client';
+import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
+import {
+  HomepageDto,
+  HomeAdsDto,
+  HomeAdDto,
+  AnnouncementDto,
+  CategoryDto,
+  VideoItemDto,
+  WaterfallAdsDto,
+  PopupAdsDto,
+  FloatingAdsDto,
+  RecommendedVideosDto,
+} from './dto/homepage.dto';
+import {
+  AdOrder,
+  SystemParamSide,
+  ANNOUNCEMENT_KEYWORD,
+  CategoryType,
+  RECOMMENDED_CATEGORY_ID,
+  RECOMMENDED_CATEGORY_NAME,
+  AdSlot,
+} from './homepage.constants';
+
+interface AdPoolEntry {
+  id: string;
+  weight: number;
+}
+
+interface CachedAd {
+  id: string;
+  advertiser?: string;
+  title?: string;
+  adsContent?: string | null;
+  adsCoverImg?: string | null;
+  adsUrl?: string | null;
+  adType?: string | null;
+}
+
+@Injectable()
+export class HomepageService {
+  private readonly logger = new Logger(HomepageService.name);
+
+  constructor(
+    private readonly redis: RedisService,
+    private readonly prisma: PrismaMongoService,
+  ) {}
+
+  /**
+   * Get complete homepage data in single API call
+   */
+  async getHomepageData(): Promise<HomepageDto> {
+    const [ads, announcements, categories, videos] = await Promise.all([
+      this.getHomeAds(),
+      this.getAnnouncements(),
+      this.getCategories(),
+      this.getRecommendedVideos(),
+    ]);
+
+    return {
+      ads,
+      announcements,
+      categories,
+      videos,
+    };
+  }
+
+  /**
+   * Fetch all ads for homepage
+   */
+  private async getHomeAds(): Promise<HomeAdsDto> {
+    const [carousel, banner, waterfall, popup, floating] = await Promise.all([
+      this.getAdsByType(AdType.CAROUSEL, AdOrder.RANDOM),
+      this.getSingleAd(AdType.BANNER),
+      this.getWaterfallAds(),
+      this.getPopupAds(),
+      this.getFloatingAds(),
+    ]);
+
+    return {
+      carousel,
+      banner,
+      waterfall,
+      popup,
+      floating,
+    };
+  }
+
+  /**
+   * Get waterfall ads (icons, texts, videos)
+   */
+  private async getWaterfallAds(): Promise<WaterfallAdsDto> {
+    const [icons, texts, videos] = await Promise.all([
+      this.getAdsByType(AdType.WATERFALL_ICON, AdOrder.RANDOM),
+      this.getAdsByType(AdType.WATERFALL_TEXT, AdOrder.RANDOM),
+      this.getAdsByType(AdType.WATERFALL_VIDEO, AdOrder.RANDOM),
+    ]);
+
+    return { icons, texts, videos };
+  }
+
+  /**
+   * Get popup ads (multi-step flow)
+   */
+  private async getPopupAds(): Promise<PopupAdsDto> {
+    const [allIcons, images, official] = await Promise.all([
+      this.getAdsByType(AdType.POPUP_ICON, AdOrder.RANDOM),
+      this.getAdsByType(AdType.POPUP_IMAGE, AdOrder.RANDOM),
+      this.getAdsByType(AdType.POPUP_OFFICIAL, AdOrder.SEQUENTIAL),
+    ]);
+
+    // Limit icons to max 6
+    const icons = allIcons.slice(0, 6);
+
+    return { icons, images, official };
+  }
+
+  /**
+   * Get floating ads (bottom & edge)
+   */
+  private async getFloatingAds(): Promise<FloatingAdsDto> {
+    const [bottom, edge] = await Promise.all([
+      this.getAdsByType(AdType.FLOATING_BOTTOM, AdOrder.RANDOM),
+      this.getAdsByType(AdType.FLOATING_EDGE, AdOrder.RANDOM),
+    ]);
+
+    return { bottom, edge };
+  }
+
+  /**
+   * Generic method to fetch ads by type from pool
+   */
+  private async getAdsByType(
+    adType: AdType,
+    order: AdOrder,
+  ): Promise<HomeAdDto[]> {
+    // Get pool key - all homepage ads use 'home' scene
+    const poolKey = CacheKeys.appAdPool(
+      'home',
+      this.getSlotForType(adType),
+      adType,
+    );
+
+    const pool = (await this.redis.getJson<AdPoolEntry[]>(poolKey)) ?? [];
+
+    if (!pool || pool.length === 0) {
+      return [];
+    }
+
+    // Shuffle if random order
+    const sortedPool = order === AdOrder.RANDOM ? this.shuffle(pool) : pool;
+
+    // Fetch all ads in parallel
+    const adPromises = sortedPool.map((entry) => this.fetchAdDetails(entry.id));
+    const ads = await Promise.all(adPromises);
+
+    // Filter out nulls and map to DTO
+    return ads.filter((ad): ad is HomeAdDto => ad !== null);
+  }
+
+  /**
+   * Get single ad (e.g., banner)
+   */
+  private async getSingleAd(adType: AdType): Promise<HomeAdDto | null> {
+    const ads = await this.getAdsByType(adType, AdOrder.RANDOM);
+    return ads.length > 0 ? ads[0] : null;
+  }
+
+  /**
+   * Fetch ad details from per-ad cache
+   */
+  private async fetchAdDetails(adId: string): Promise<HomeAdDto | null> {
+    const cacheKey = CacheKeys.appAdById(adId);
+    const cached = await this.redis.getJson<CachedAd>(cacheKey);
+
+    if (!cached) {
+      return null;
+    }
+
+    return {
+      id: cached.id,
+      adType: cached.adType ?? 'UNKNOWN',
+      title: cached.title ?? '',
+      advertiser: cached.advertiser ?? '',
+      content: cached.adsContent ?? undefined,
+      coverImg: cached.adsCoverImg ?? undefined,
+      targetUrl: cached.adsUrl ?? undefined,
+    };
+  }
+
+  /**
+   * Get slot name for ad type (maps to ADTYPE_POOLS config)
+   */
+  private getSlotForType(adType: AdType): string {
+    const slotMap: Record<AdType, AdSlot> = {
+      [AdType.STARTUP]: AdSlot.STARTUP,
+      [AdType.CAROUSEL]: AdSlot.CAROUSEL,
+      [AdType.POPUP_ICON]: AdSlot.MIDDLE,
+      [AdType.POPUP_IMAGE]: AdSlot.MIDDLE,
+      [AdType.POPUP_OFFICIAL]: AdSlot.MIDDLE,
+      [AdType.WATERFALL_ICON]: AdSlot.WATERFALL,
+      [AdType.WATERFALL_TEXT]: AdSlot.WATERFALL,
+      [AdType.WATERFALL_VIDEO]: AdSlot.WATERFALL,
+      [AdType.FLOATING_BOTTOM]: AdSlot.FLOATING,
+      [AdType.FLOATING_EDGE]: AdSlot.EDGE,
+      [AdType.BANNER]: AdSlot.TOP,
+      [AdType.PREROLL]: AdSlot.PREROLL,
+      [AdType.PAUSE]: AdSlot.PAUSE_OVERLAY,
+    };
+
+    return slotMap[adType] ?? AdSlot.UNKNOWN;
+  }
+
+  /**
+   * Get announcements (marquee notices)
+   * Using SystemParam with side='client' and specific naming convention
+   */
+  private async getAnnouncements(): Promise<AnnouncementDto[]> {
+    try {
+      // Fetch client-side params that could be announcements
+      // Convention: params with name starting with 'announcement_' or 'notice_'
+      const params = await this.prisma.systemParam.findMany({
+        where: {
+          side: SystemParamSide.CLIENT,
+          name: {
+            contains: ANNOUNCEMENT_KEYWORD,
+          },
+        },
+        orderBy: {
+          id: 'asc', // sequential order by ID
+        },
+      });
+
+      return params.map((p, idx) => ({
+        id: p.id.toString(),
+        content: p.value ?? '', // Use 'value' field as content
+        seq: idx,
+      }));
+    } catch (error) {
+      this.logger.warn(
+        'Error fetching announcements from SystemParam, returning empty',
+      );
+      return [];
+    }
+  }
+
+  /**
+   * Get video categories
+   */
+  private async getCategories(): Promise<CategoryDto[]> {
+    try {
+      const categories = await this.prisma.category.findMany({
+        where: {
+          status: 1, // active only
+        },
+        orderBy: {
+          seq: 'asc',
+        },
+      });
+
+      // Shuffle regular categories (keep recommended first)
+      const recommended: CategoryDto = {
+        id: RECOMMENDED_CATEGORY_ID,
+        name: RECOMMENDED_CATEGORY_NAME,
+        type: CategoryType.RECOMMENDED,
+        isDefault: true,
+        seq: 0,
+      };
+
+      const regular = this.shuffle(
+        categories.map((c, idx) => ({
+          id: c.id,
+          name: c.name,
+          type: CategoryType.REGULAR,
+          isDefault: false,
+          seq: idx + 1,
+        })),
+      );
+
+      return [recommended, ...regular];
+    } catch (error) {
+      this.logger.warn(
+        'Category collection not found or error fetching categories',
+      );
+      return [
+        {
+          id: RECOMMENDED_CATEGORY_ID,
+          name: RECOMMENDED_CATEGORY_NAME,
+          type: CategoryType.RECOMMENDED,
+          isDefault: true,
+          seq: 0,
+        },
+      ];
+    }
+  }
+
+  /**
+   * Get recommended videos (7 random videos for homepage)
+   */
+  private async getRecommendedVideos(): Promise<RecommendedVideosDto> {
+    try {
+      // Get 7 random videos
+      // MongoDB aggregation for random sampling
+      const videos = await this.prisma.videoMedia.aggregateRaw({
+        pipeline: [
+          // { $match: { status: 1 } }, // if you have status field
+          { $sample: { size: 7 } },
+        ],
+      });
+
+      const items = (Array.isArray(videos) ? videos : []).map((v: any) =>
+        this.mapVideoToDto(v),
+      );
+
+      return {
+        items,
+        total: items.length,
+      };
+    } catch (error) {
+      this.logger.warn('Error fetching recommended videos, returning empty');
+      return {
+        items: [],
+        total: 0,
+      };
+    }
+  }
+
+  /**
+   * Map raw video to DTO
+   */
+  private mapVideoToDto(video: any): VideoItemDto {
+    return {
+      id: video._id?.toString() ?? video.id,
+      title: video.title ?? '',
+      coverCdn: video.coverCdn ?? video.coverUrl ?? undefined,
+      duration: video.duration ?? undefined,
+      tags: Array.isArray(video.tags) ? video.tags : [],
+    };
+  }
+
+  /**
+   * Fisher-Yates shuffle for random ordering
+   */
+  private shuffle<T>(array: T[]): T[] {
+    const shuffled = [...array];
+    for (let i = shuffled.length - 1; i > 0; i--) {
+      const j = Math.floor(Math.random() * (i + 1));
+      [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+    }
+    return shuffled;
+  }
+}

+ 36 - 0
apps/box-app-api/src/feature/sys-params/sys-params.controller.ts

@@ -0,0 +1,36 @@
+import { Controller, Get, Query } from '@nestjs/common';
+import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { SysParamsService } from './sys-params.service';
+
+@ApiTags('系统参数配置')
+@Controller('sys-params')
+export class SysParamsController {
+  constructor(private readonly service: SysParamsService) {}
+
+  @Get('constants')
+  @ApiOperation({
+    summary: '获取前端所需常量与枚举',
+    description:
+      '返回预定义分组的常量与枚举:homepage / ads / video / system。包含广告排序、系统参数侧、分类类型、广告槽位、推荐分类ID与名称等。',
+  })
+  @ApiResponse({ status: 200, description: '成功返回常量对象' })
+  @ApiQuery({
+    name: 'group',
+    required: false,
+    description: '常量分组(默认homepage)',
+  })
+  getConstants(@Query('group') group?: string) {
+    return this.service.getConstantsFiltered(group);
+  }
+
+  @Get('ad-types')
+  @ApiOperation({
+    summary: '获取广告类型枚举(来自AdsModule集合)',
+    description:
+      '读取Mongo集合AdsModule,返回可用的广告类型、展示名称、简介与排序。',
+  })
+  @ApiResponse({ status: 200, description: '成功返回广告类型列表' })
+  async getAdTypes() {
+    return this.service.getAdTypes();
+  }
+}

+ 12 - 0
apps/box-app-api/src/feature/sys-params/sys-params.module.ts

@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { SysParamsService } from './sys-params.service';
+import { SysParamsController } from './sys-params.controller';
+import { PrismaMongoModule } from '../../prisma/prisma-mongo.module';
+
+@Module({
+  imports: [PrismaMongoModule],
+  controllers: [SysParamsController],
+  providers: [SysParamsService],
+  exports: [SysParamsService],
+})
+export class SysParamsModule {}

+ 83 - 0
apps/box-app-api/src/feature/sys-params/sys-params.service.ts

@@ -0,0 +1,83 @@
+import { Injectable } from '@nestjs/common';
+import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
+import {
+  AdOrder,
+  SystemParamSide,
+  ANNOUNCEMENT_KEYWORD,
+  CategoryType,
+  RECOMMENDED_CATEGORY_ID,
+  RECOMMENDED_CATEGORY_NAME,
+  AdSlot,
+  HOMEPAGE_CONSTANTS,
+} from '../homepage/homepage.constants';
+
+@Injectable()
+export class SysParamsService {
+  constructor(private readonly prisma: PrismaMongoService) {}
+
+  getHomepageConstants() {
+    return {
+      enums: {
+        AdOrder,
+        SystemParamSide,
+        CategoryType,
+        AdSlot,
+      },
+      values: {
+        ANNOUNCEMENT_KEYWORD,
+        RECOMMENDED_CATEGORY_ID,
+        RECOMMENDED_CATEGORY_NAME,
+      },
+      group: HOMEPAGE_CONSTANTS,
+    };
+  }
+
+  async getAdTypes() {
+    const modules = await this.prisma.adsModule.findMany({
+      orderBy: { seq: 'asc' },
+    });
+
+    return modules.map((m) => ({
+      adType: m.adType,
+      name: m.adsModule,
+      desc: m.moduleDesc ?? null,
+      seq: m.seq,
+    }));
+  }
+
+  async getConstantsFiltered(group?: string) {
+    // Provide multiple predefined groups
+    const homepage = this.getHomepageConstants();
+
+    const ads = {
+      enums: {
+        AdOrder,
+        AdSlot,
+      },
+      values: {},
+      // Include dynamic AdTypes list from DB for convenience
+      asyncData: async () => ({ adTypes: await this.getAdTypes() }),
+    };
+
+    const video = {
+      enums: {
+        CategoryType,
+      },
+      values: {
+        RECOMMENDED_CATEGORY_ID,
+        RECOMMENDED_CATEGORY_NAME,
+      },
+    };
+
+    const system = {
+      enums: { SystemParamSide },
+      values: { ANNOUNCEMENT_KEYWORD },
+    };
+
+    if (!group || group === 'homepage') return homepage;
+    if (group === 'ads') return ads;
+    if (group === 'video') return video;
+    if (group === 'system') return system;
+    return {};
+  }
+}

+ 10 - 10
apps/box-app-api/src/feature/video/dto/video-media.dto.ts

@@ -2,60 +2,60 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
 
 export class VideoMediaDto {
   @ApiProperty({
-    description: 'Video ID (Mongo ObjectId)',
+    description: '视频ID(Mongo ObjectId)',
     example: '6650a9e5f9c3f12a8b000001',
   })
   id: string;
 
   @ApiProperty({
-    description: 'Video title',
+    description: '视频标题(Video.title)',
     example: 'Hot Trending Short Video',
   })
   title: string;
 
   @ApiPropertyOptional({
-    description: 'Video description',
+    description: '视频描述(Video.description,可选)',
   })
   description?: string | null;
 
   @ApiPropertyOptional({
-    description: 'Video CDN URL',
+    description: '视频CDN地址(Video.videoCdn,可选)',
     example: 'https://cdn.example.com/videos/abc123.m3u8',
   })
   videoCdn?: string | null;
 
   @ApiPropertyOptional({
-    description: 'Cover image CDN URL',
+    description: '封面图CDN地址(Video.coverCdn,可选)',
     example: 'https://cdn.example.com/covers/abc123.jpg',
   })
   coverCdn?: string | null;
 
   @ApiPropertyOptional({
-    description: 'Tags as an array of tag names',
+    description: '标签名称数组(Video.tags)',
     example: ['funny', 'hot', '2025'],
   })
   tags?: string[];
 
   @ApiPropertyOptional({
-    description: 'Flattened tag names (comma-separated or space-separated)',
+    description: '标签文本(逗号或空格分隔的合并文本)',
     example: 'funny, hot, 2025',
   })
   tagsFlat?: string | null;
 
   @ApiPropertyOptional({
-    description: 'Duration in seconds',
+    description: '时长(秒)',
     example: 120,
   })
   duration?: number | null;
 
   @ApiProperty({
-    description: 'Created time in milliseconds since epoch',
+    description: '创建时间戳(毫秒)',
     example: 1732594800000,
   })
   createdAt: number;
 
   @ApiProperty({
-    description: 'Updated time in milliseconds since epoch',
+    description: '更新时间戳(毫秒)',
     example: 1732598400000,
   })
   updatedAt: number;

+ 23 - 12
apps/box-app-api/src/feature/video/video.controller.ts

@@ -1,34 +1,39 @@
 import { Controller, Get, Query } from '@nestjs/common';
-import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
+import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
 import { VideoService } from './video.service';
 import { VideoMediaDto } from './dto/video-media.dto';
 
-@ApiTags('Video')
+@ApiTags('视频')
 @Controller('video')
 export class VideoController {
   constructor(private readonly videoService: VideoService) {}
 
   @Get('list')
   @ApiOperation({
-    summary: 'Get video list (homepage / filtered)',
+    summary: '获取视频列表(首页/筛选)',
     description:
-      'Returns a compact list of videos for the app. Supports optional filters by tag and keyword.',
+      '返回应用所需的视频精简列表,支持按标签与关键字筛选。数据来源对齐 Prisma Mongo Video 模型。',
   })
   @ApiQuery({
     name: 'limit',
     required: false,
-    description: 'Max number of videos to return (default 20, max 100).',
+    description: '返回数量上限(默认20,最大100)',
   })
   @ApiQuery({
     name: 'tag',
     required: false,
-    description:
-      'Filter by tag name (matches videos whose tags contain this value).',
+    description: '按标签名称筛选(匹配包含该标签的视频)',
   })
   @ApiQuery({
     name: 'kw',
     required: false,
-    description: 'Keyword search in title or tagsFlat (case-insensitive).',
+    description: '标题或标签文本关键字搜索(不区分大小写)',
+  })
+  @ApiResponse({
+    status: 200,
+    description: '成功返回视频列表',
+    type: VideoMediaDto,
+    isArray: true,
   })
   async getVideoList(
     @Query('limit') limit?: string,
@@ -51,19 +56,25 @@ export class VideoController {
 
   @Get('recommend')
   @ApiOperation({
-    summary: '"You might also like" videos',
+    summary: '猜你喜欢推荐视频',
     description:
-      'Returns videos that share similar tags or tag text with the given video. Fallbacks to a generic list if no strong matches.',
+      '返回与指定视频在标签或标签文本上相似的内容;若无强相关则回退通用列表。数据来源对齐 Prisma Mongo Video 模型。',
   })
   @ApiQuery({
     name: 'videoId',
     required: true,
-    description: 'The current video ID (Mongo ObjectId).',
+    description: '当前视频ID(Mongo ObjectId)',
   })
   @ApiQuery({
     name: 'limit',
     required: false,
-    description: 'Max number of recommended videos (default 10, max 100).',
+    description: '推荐数量上限(默认10,最大100)',
+  })
+  @ApiResponse({
+    status: 200,
+    description: '成功返回推荐视频列表',
+    type: VideoMediaDto,
+    isArray: true,
   })
   async getRecommendations(
     @Query('videoId') videoId: string,

+ 7 - 0
apps/box-app-api/src/health/health.controller.ts

@@ -1,8 +1,15 @@
 import { Controller, Get } from '@nestjs/common';
+import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
 
+@ApiTags('健康检查')
 @Controller('health')
 export class HealthController {
   @Get()
+  @ApiOperation({
+    summary: '健康检查',
+    description: '返回服务健康状态与时间戳,用于监控与负载均衡探针。',
+  })
+  @ApiResponse({ status: 200, description: '服务正常' })
   getHealth() {
     // For DB backed checks, later you can also ping Mongo / Redis here.
     return {

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

@@ -3,6 +3,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import helmet from 'helmet';
 import compression from 'compression';
+import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
 
 import { AppModule } from './app.module';
 
@@ -59,11 +60,29 @@ async function bootstrap() {
     }),
   );
 
+  // Setup Swagger (OpenAPI)
+  const swaggerConfig = new DocumentBuilder()
+    .setTitle('盒子应用接口文档')
+    .setDescription(
+      'box-app-api 的公开接口文档,面向前端应用。包含广告、视频、首页等模块。',
+    )
+    .setVersion('1.0.0')
+    .build();
+  const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
+  SwaggerModule.setup('api-docs', app, swaggerDocument, {
+    jsonDocumentUrl: 'api-docs/json',
+    swaggerOptions: {
+      persistAuthorization: true,
+      docExpansion: 'none',
+    },
+  });
+
   await app.listen(port, host);
 
   const url = `http://${host}:${port}`;
 
   logger.log(`🚀 box-app-api listening on ${url} (global prefix: /api/v1)`);
+  logger.log(`📖 Swagger 文档: ${url}/api-docs`);
 }
 
 bootstrap().catch((error) => {