Explorar o código

feat: add video module with controller, service, and DTO; integrate with Prisma and Redis caching

Dave hai 4 meses
pai
achega
8218d214ce

+ 1 - 1
.nvmrc

@@ -1 +1 @@
-20
+22

+ 0 - 8
apps/box-app-api/nest-cli.json

@@ -1,8 +0,0 @@
-{
-  "box-app-api": {
-    "type": "application",
-    "root": "apps/box-app-api",
-    "entryFile": "main",
-    "sourceRoot": "apps/box-app-api/src"
-  }
-}

+ 0 - 0
apps/box-app-api/project.json


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

@@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config';
 import { RedisCacheModule } from './redis/redis-cache.module';
 import { HealthModule } from './health/health.module';
 import { PrismaMongoModule } from './prisma/prisma-mongo.module';
+import { VideoModule } from './feature/video/video.module';
 
 @Module({
   imports: [
@@ -22,6 +23,7 @@ import { PrismaMongoModule } from './prisma/prisma-mongo.module';
 
     // Simple health endpoint
     HealthModule,
+    VideoModule,
   ],
 })
 export class AppModule {}

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

@@ -0,0 +1,62 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+
+export class VideoMediaDto {
+  @ApiProperty({
+    description: 'Video ID (Mongo ObjectId)',
+    example: '6650a9e5f9c3f12a8b000001',
+  })
+  id: string;
+
+  @ApiProperty({
+    description: 'Video title',
+    example: 'Hot Trending Short Video',
+  })
+  title: string;
+
+  @ApiPropertyOptional({
+    description: 'Video description',
+  })
+  description?: string | null;
+
+  @ApiPropertyOptional({
+    description: 'Video CDN URL',
+    example: 'https://cdn.example.com/videos/abc123.m3u8',
+  })
+  videoCdn?: string | null;
+
+  @ApiPropertyOptional({
+    description: 'Cover image CDN URL',
+    example: 'https://cdn.example.com/covers/abc123.jpg',
+  })
+  coverCdn?: string | null;
+
+  @ApiPropertyOptional({
+    description: 'Tags as an array of tag names',
+    example: ['funny', 'hot', '2025'],
+  })
+  tags?: string[];
+
+  @ApiPropertyOptional({
+    description: 'Flattened tag names (comma-separated or space-separated)',
+    example: 'funny, hot, 2025',
+  })
+  tagsFlat?: string | null;
+
+  @ApiPropertyOptional({
+    description: 'Duration in seconds',
+    example: 120,
+  })
+  duration?: number | null;
+
+  @ApiProperty({
+    description: 'Created time in milliseconds since epoch',
+    example: 1732594800000,
+  })
+  createdAt: number;
+
+  @ApiProperty({
+    description: 'Updated time in milliseconds since epoch',
+    example: 1732598400000,
+  })
+  updatedAt: number;
+}

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

@@ -0,0 +1,84 @@
+import { Controller, Get, Query } from '@nestjs/common';
+import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
+import { VideoService } from './video.service';
+import { VideoMediaDto } from './dto/video-media.dto';
+
+@ApiTags('Video')
+@Controller('video')
+export class VideoController {
+  constructor(private readonly videoService: VideoService) {}
+
+  @Get('list')
+  @ApiOperation({
+    summary: 'Get video list (homepage / filtered)',
+    description:
+      'Returns a compact list of videos for the app. Supports optional filters by tag and keyword.',
+  })
+  @ApiQuery({
+    name: 'limit',
+    required: false,
+    description: 'Max number of videos to return (default 20, max 100).',
+  })
+  @ApiQuery({
+    name: 'tag',
+    required: false,
+    description:
+      'Filter by tag name (matches videos whose tags contain this value).',
+  })
+  @ApiQuery({
+    name: 'kw',
+    required: false,
+    description: 'Keyword search in title or tagsFlat (case-insensitive).',
+  })
+  async getVideoList(
+    @Query('limit') limit?: string,
+    @Query('tag') tag?: string,
+    @Query('kw') kw?: string,
+  ): Promise<{ items: VideoMediaDto[]; count: number }> {
+    const parsedLimit = Number.parseInt(limit ?? '20', 10);
+
+    const items = await this.videoService.findHomepageList({
+      limit: parsedLimit,
+      tag,
+      kw,
+    });
+
+    return {
+      items,
+      count: items.length,
+    };
+  }
+
+  @Get('recommend')
+  @ApiOperation({
+    summary: '"You might also like" videos',
+    description:
+      'Returns videos that share similar tags or tag text with the given video. Fallbacks to a generic list if no strong matches.',
+  })
+  @ApiQuery({
+    name: 'videoId',
+    required: true,
+    description: 'The current video ID (Mongo ObjectId).',
+  })
+  @ApiQuery({
+    name: 'limit',
+    required: false,
+    description: 'Max number of recommended videos (default 10, max 100).',
+  })
+  async getRecommendations(
+    @Query('videoId') videoId: string,
+    @Query('limit') limit?: string,
+  ): Promise<{ items: VideoMediaDto[]; count: number }> {
+    const parsedLimit = Number.parseInt(limit ?? '10', 10);
+
+    const items = await this.videoService.findRecommendations(
+      videoId,
+      parsedLimit,
+    );
+
+    return {
+      items,
+      count: items.length,
+    };
+  }
+}

+ 10 - 0
apps/box-app-api/src/feature/video/video.module.ts

@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { VideoController } from './video.controller';
+import { VideoService } from './video.service';
+
+@Module({
+  controllers: [VideoController],
+  providers: [VideoService],
+  exports: [VideoService],
+})
+export class VideoModule {}

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

@@ -0,0 +1,266 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
+import { VideoMediaDto } from './dto/video-media.dto';
+import type { Cache } from 'cache-manager';
+import { CACHE_MANAGER } from '@nestjs/cache-manager';
+
+const VIDEO_LIST_CACHE_TTL_SECONDS = 30; // list cache
+const VIDEO_RECOMMEND_CACHE_TTL_SECONDS = 60; // recommendations cache
+
+type VideoListOptions = {
+  limit?: number;
+  tag?: string;
+  kw?: string;
+};
+
+@Injectable()
+export class VideoService {
+  constructor(
+    private readonly prisma: PrismaMongoService,
+    @Inject(CACHE_MANAGER) private readonly cache: Cache,
+  ) {}
+
+  /**
+   * Homepage / general list:
+   * - Non-deleted videos
+   * - Optional filters: tag, kw (searches title + tagsFlat)
+   * - Ordered by sortOrder (or createdAt if you change it)
+   * - Limited by `limit` (default 20)
+   * - Cached in Redis
+   */
+  async findHomepageList(
+    options: VideoListOptions = {},
+  ): Promise<VideoMediaDto[]> {
+    const safeLimit = this.normalizeLimit(options.limit);
+    const tag = (options.tag ?? '').trim();
+    const kw = (options.kw ?? '').trim();
+
+    const cacheKey = this.buildListCacheKey({ limit: safeLimit, tag, kw });
+
+    const cached = await this.cache.get<VideoMediaDto[]>(cacheKey);
+    if (cached && Array.isArray(cached)) {
+      return cached;
+    }
+
+    const where: any = {
+      // adjust to your schema: e.g. status: 'ONLINE'
+      // isDeleted: false,
+    };
+
+    // Filter by tag (array field)
+    if (tag) {
+      // Assuming `tags` is String[]
+      where.tags = {
+        has: tag,
+      };
+    }
+
+    // Keyword search: title OR tagsFlat
+    const orConditions: any[] = [];
+    if (kw) {
+      orConditions.push(
+        {
+          title: {
+            contains: kw,
+            mode: 'insensitive',
+          },
+        },
+        {
+          tagsFlat: {
+            contains: kw,
+            mode: 'insensitive',
+          },
+        },
+      );
+    }
+    if (orConditions.length > 0) {
+      where.OR = orConditions;
+    }
+
+    const rows = await this.prisma.videoMedia.findMany({
+      where,
+      orderBy: {
+        // adjust if your schema uses `sort` / `order` / `createdAt`
+        editedAt: 'asc',
+      },
+      take: safeLimit,
+    });
+
+    const items = rows.map((row) => this.toDto(row));
+
+    // Store in Redis
+    await this.cache.set(cacheKey, items, VIDEO_LIST_CACHE_TTL_SECONDS);
+
+    return items;
+  }
+
+  /**
+   * "You might also like" recommendations:
+   * - Based on tags of current video
+   * - Excludes the current video
+   * - Fallback: homepage list without filter
+   */
+  async findRecommendations(
+    videoId: string,
+    limit = 10,
+  ): Promise<VideoMediaDto[]> {
+    const safeLimit = this.normalizeLimit(limit);
+    const trimmedId = videoId.trim();
+
+    if (!trimmedId) {
+      // fallback: no id provided
+      return this.findHomepageList({ limit: safeLimit });
+    }
+
+    const cacheKey = this.buildRecommendCacheKey(trimmedId, safeLimit);
+    const cached = await this.cache.get<VideoMediaDto[]>(cacheKey);
+    if (cached && Array.isArray(cached)) {
+      return cached;
+    }
+
+    // 1) Get current video
+    const current = await this.prisma.videoMedia.findUnique({
+      where: {
+        id: trimmedId,
+      },
+    });
+
+    if (!current) {
+      // if video not found, fallback to generic list
+      const fallback = await this.findHomepageList({ limit: safeLimit });
+      await this.cache.set(
+        cacheKey,
+        fallback,
+        VIDEO_RECOMMEND_CACHE_TTL_SECONDS,
+      );
+      return fallback;
+    }
+
+    const tags: string[] = Array.isArray(current.tags) ? current.tags : [];
+    const tagsFlat: string | null = current.tagsFlat ?? null;
+
+    const where: any = {
+      id: {
+        not: trimmedId,
+      },
+    };
+
+    const orConditions: any[] = [];
+
+    if (tags.length > 0) {
+      // share ANY tags
+      orConditions.push({
+        tags: {
+          hasSome: tags,
+        },
+      });
+    }
+
+    if (tagsFlat) {
+      // textual similarity in tagsFlat
+      orConditions.push({
+        tagsFlat: {
+          contains: tagsFlat,
+          mode: 'insensitive',
+        },
+      });
+    }
+
+    if (orConditions.length > 0) {
+      where.OR = orConditions;
+    }
+
+    const rows = await this.prisma.videoMedia.findMany({
+      where,
+      orderBy: {
+        editedAt: 'desc',
+      },
+      take: safeLimit,
+    });
+
+    let items = rows.map((row) => this.toDto(row));
+
+    // If no strong recommendations, fallback to generic list (but still exclude current id)
+    if (items.length === 0) {
+      const fallback = await this.prisma.videoMedia.findMany({
+        where: {
+          // listStatus: 1,
+          id: {
+            not: trimmedId,
+          },
+        },
+        orderBy: {
+          editedAt: 'desc',
+        },
+        take: safeLimit,
+      });
+      items = fallback.map((row) => this.toDto(row));
+    }
+
+    await this.cache.set(cacheKey, items, VIDEO_RECOMMEND_CACHE_TTL_SECONDS);
+
+    return items;
+  }
+
+  // ------------------------
+  // Helpers
+  // ------------------------
+
+  private normalizeLimit(limit?: number): number {
+    if (!Number.isFinite(limit as number)) return 20;
+    const n = Number(limit);
+    if (n <= 0) return 20;
+    if (n > 100) return 100;
+    return n;
+  }
+
+  private buildListCacheKey(input: {
+    limit: number;
+    tag?: string;
+    kw?: string;
+  }): string {
+    const parts = [
+      'video:list',
+      `limit=${input.limit}`,
+      input.tag ? `tag=${input.tag}` : 'tag=_',
+      input.kw ? `kw=${input.kw}` : 'kw=_',
+    ];
+    return parts.join('|');
+  }
+
+  private buildRecommendCacheKey(videoId: string, limit: number): string {
+    return ['video:recommend', `id=${videoId}`, `limit=${limit}`].join('|');
+  }
+
+  private toDto(row: any): VideoMediaDto {
+    return {
+      id: row.id?.toString?.() ?? row.id,
+      title: row.title,
+      description: row.description ?? null,
+      videoCdn: row.videoCdn ?? row.cdnUrl ?? null,
+      coverCdn: row.coverCdn ?? row.coverUrl ?? null,
+      tags: Array.isArray(row.tags) ? row.tags : [],
+      tagsFlat: row.tagsFlat ?? null,
+      duration: row.duration ?? null,
+      createdAt: this.toMillis(row.createdAt),
+      updatedAt: this.toMillis(row.updatedAt),
+    };
+  }
+
+  /**
+   * Convert DB timestamp (BigInt | number | Date | null) to milliseconds number.
+   * DB layer can use BigInt; here we flatten to number for JSON.
+   */
+  private toMillis(value: unknown): number {
+    if (typeof value === 'bigint') {
+      return Number(value);
+    }
+    if (typeof value === 'number') {
+      return value;
+    }
+    if (value instanceof Date) {
+      return value.getTime();
+    }
+    return 0;
+  }
+}

+ 4 - 30
apps/box-app-api/src/prisma/prisma-mongo.service.ts

@@ -1,32 +1,6 @@
-import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
-
-// NOTE:
-// Replace this import with your real generated Mongo client.
-// Example (depending on your prisma mongo generator):
-// import { PrismaClient as MongoPrismaClient } from '@prisma/client-mongo';
-
-class MongoPrismaClientMock {
-  // Temporary mock client so the app can boot without the real client.
-  // Replace this with the actual Prisma client usage.
-  async $connect(): Promise<void> {
-    return;
-  }
-
-  async $disconnect(): Promise<void> {
-    return;
-  }
-}
+import { Injectable } from '@nestjs/common';
+// 👇 Reuse the shared Mongo Prisma service from libs/db
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 
 @Injectable()
-export class PrismaMongoService
-  extends MongoPrismaClientMock
-  implements OnModuleInit, OnModuleDestroy
-{
-  async onModuleInit(): Promise<void> {
-    await this.$connect();
-  }
-
-  async onModuleDestroy(): Promise<void> {
-    await this.$disconnect();
-  }
-}
+export class PrismaMongoService extends MongoPrismaService {}

+ 1 - 6
apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.controller.ts

@@ -8,12 +8,7 @@ import {
   Post,
   Put,
 } from '@nestjs/common';
-import {
-  ApiBody,
-  ApiOperation,
-  ApiResponse,
-  ApiTags,
-} from '@nestjs/swagger';
+import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
 import {
   ChannelDto,
   CreateChannelDto,

+ 21 - 12
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.service.ts

@@ -1,4 +1,3 @@
-// apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.service.ts
 import {
   Injectable,
   NotFoundException,
@@ -69,6 +68,8 @@ export class VideoMediaService {
         tagIds: row.tagIds ?? [],
         listStatus: row.listStatus ?? 0,
         editedAt: row.editedAt?.toString?.() ?? '0',
+        // NOTE: We keep list DTO backward compatible.
+        // If you later want to show tag names in list, we can add e.g. `tagsFlat` or `tagNames` here.
       })),
     };
   }
@@ -114,6 +115,7 @@ export class VideoMediaService {
       listStatus: video.listStatus ?? 0,
       editedAt: video.editedAt?.toString?.() ?? '0',
       categoryName: category?.name ?? null,
+      // Existing DTO: tags as {id, name}[]
       tags: tags.map((t) => ({ id: t.id, name: t.name })),
     };
   }
@@ -141,12 +143,13 @@ export class VideoMediaService {
     }
 
     if (typeof categoryId !== 'undefined' || typeof tagIds !== 'undefined') {
-      const { finalCategoryId, finalTagIds, tagsFlat } =
+      const { finalCategoryId, finalTagIds, tags, tagsFlat } =
         await this.validateCategoryAndTags(categoryId, tagIds);
 
       updateData.categoryId = finalCategoryId;
       updateData.tagIds = finalTagIds;
-      updateData.tagsFlat = tagsFlat; // 👈 new
+      updateData.tags = tags; // NEW: store denormalised tag names (lowercased)
+      updateData.tagsFlat = tagsFlat; // existing: text for search
     }
 
     if (typeof dto.listStatus === 'number') {
@@ -257,11 +260,13 @@ export class VideoMediaService {
   ): Promise<{
     finalCategoryId: string | null;
     finalTagIds: string[];
-    tagsFlat: string;
+    tags: string[]; // NEW: denormalised tag names (lowercased)
+    tagsFlat: string; // NEW: concatenated names for search
   }> {
     let finalCategoryId: string | null =
       typeof categoryId === 'undefined' ? (undefined as any) : categoryId;
     let finalTagIds: string[] = [];
+    let tags: string[] = []; // NEW
     let tagsFlat = '';
 
     // Normalize tagIds: remove duplicates
@@ -295,16 +300,16 @@ export class VideoMediaService {
     }
 
     if (finalTagIds.length > 0) {
-      const tags = await this.prisma.tag.findMany({
+      const tagEntities = await this.prisma.tag.findMany({
         where: { id: { in: finalTagIds } },
       });
 
-      if (tags.length !== finalTagIds.length) {
+      if (tagEntities.length !== finalTagIds.length) {
         throw new BadRequestException('Some tags do not exist');
       }
 
       const distinctCategoryIds = [
-        ...new Set(tags.map((t) => t.categoryId.toString())),
+        ...new Set(tagEntities.map((t) => t.categoryId.toString())),
       ];
 
       if (distinctCategoryIds.length > 1) {
@@ -326,11 +331,14 @@ export class VideoMediaService {
         finalCategoryId = tagCategoryId;
       }
 
-      // Build tagsFlat: lowercased names joined by space
-      tagsFlat = tags
-        .map((t) => t.name.trim().toLowerCase())
-        .filter(Boolean)
-        .join(' ');
+      // Build tags & tagsFlat: lowercased names
+      const tagNames = tagEntities
+        .map((t) => t.name?.trim())
+        .filter(Boolean) as string[];
+
+      tags = tagNames.map((name) => name.toLowerCase()); // NEW
+
+      tagsFlat = tags.join(' '); // e.g. "funny hot 2025"
     }
 
     if (typeof finalCategoryId === 'undefined') {
@@ -340,6 +348,7 @@ export class VideoMediaService {
     return {
       finalCategoryId,
       finalTagIds,
+      tags,
       tagsFlat,
     };
   }

+ 1 - 5
nest-cli.json

@@ -1,10 +1,6 @@
 {
   "collection": "@nestjs/schematics",
-  "sourceRoot": "apps/box-mgnt-api/src",
-  "entryFile": "main",
-  "compilerOptions": {
-    "tsConfigPath": "apps/box-mgnt-api/tsconfig.json"
-  },
+  "monorepo": true,
   "projects": {
     "box-mgnt-api": {
       "type": "application",

+ 3 - 0
prisma/mongo/schema/video-media.prisma

@@ -79,6 +79,9 @@ model VideoMedia {
   categoryId    String?    @db.ObjectId      // 分类 ID (local)
   tagIds        String[]   @default([]) @db.ObjectId  // 标签 IDs (local, max 5)
 
+  /// Denormalised tag names for fast read (store lowercased or display names)
+  tags          String[] @default([])
+  
   /// Lowercased concatenation of tag names, for search
   tagsFlat      String     @default("")