Prechádzať zdrojové kódy

feat: enhance video media management with new DTOs, service methods, and controller endpoints; update Prisma schema and documentation

Dave 4 mesiacov pred
rodič
commit
2b35c808f5

+ 4 - 1
apps/box-mgnt-api/src/mgnt-backend/feature/category/category.dto.ts

@@ -130,7 +130,10 @@ export class ListCategoryDto extends PageListDto {
   @IsMongoId()
   channelId?: string;
 
-  @ApiPropertyOptional({ enum: CommonStatus, description: '状态: 0=禁用, 1=启用' })
+  @ApiPropertyOptional({
+    enum: CommonStatus,
+    description: '状态: 0=禁用, 1=启用',
+  })
   @Type(() => Number)
   @IsOptional()
   @IsEnum(CommonStatus)

+ 73 - 22
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.controller.ts

@@ -1,44 +1,95 @@
+// apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.controller.ts
 import {
   Controller,
   Get,
-  Post,
-  Body,
-  Patch,
   Param,
-  Delete,
+  Query,
+  Patch,
+  Body,
+  Post,
+  UseInterceptors,
+  UploadedFile,
 } from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
 import { VideoMediaService } from './video-media.service';
-import { CreateVideoMediaDto, UpdateVideoMediaDto } from './video-media.dto';
+import {
+  VideoMediaListQueryDto,
+  UpdateVideoMediaManageDto,
+  UpdateVideoMediaStatusDto,
+  BatchUpdateVideoMediaStatusDto,
+} from './video-media.dto';
 
 @Controller('video-media')
 export class VideoMediaController {
-  constructor(private readonly videoMediasService: VideoMediaService) {}
+  constructor(private readonly videoMediaService: VideoMediaService) {}
 
-  @Post()
-  create(@Body() createVideoMediaDto: CreateVideoMediaDto) {
-    return this.videoMediasService.create(createVideoMediaDto);
+  /**
+   * 列表查询
+   * GET /video-media
+   */
+  @Post('list')
+  async findAll(@Query() query: VideoMediaListQueryDto) {
+    return this.videoMediaService.findAll(query);
   }
 
-  @Get()
-  findAll() {
-    return this.videoMediasService.findAll();
+  /**
+   * 详情(管理弹窗)
+   * GET /video-media/:id
+   */
+  @Get(':id')
+  async findOne(@Param('id') id: string) {
+    return this.videoMediaService.findOne(id);
   }
 
-  @Get(':id')
-  findOne(@Param('id') id: string) {
-    return this.videoMediasService.findOne(+id);
+  /**
+   * 管理弹窗保存(标题 / 分类 / 标签 / 上下架)
+   * PATCH /video-media/:id/manage
+   */
+  @Patch(':id/manage')
+  async updateManage(
+    @Param('id') id: string,
+    @Body() dto: UpdateVideoMediaManageDto,
+  ) {
+    return this.videoMediaService.updateManage(id, dto);
   }
 
-  @Patch(':id')
-  update(
+  /**
+   * 单个上/下架
+   * PATCH /video-media/:id/status
+   */
+  @Patch(':id/status')
+  async updateStatus(
     @Param('id') id: string,
-    @Body() updateVideoMediaDto: UpdateVideoMediaDto,
+    @Body() dto: UpdateVideoMediaStatusDto,
   ) {
-    return this.videoMediasService.update(+id, updateVideoMediaDto);
+    return this.videoMediaService.updateStatus(id, dto);
   }
 
-  @Delete(':id')
-  remove(@Param('id') id: string) {
-    return this.videoMediasService.remove(+id);
+  /**
+   * 批量上/下架
+   * POST /video-media/batch/status
+   */
+  @Post('batch/status')
+  async batchUpdateStatus(@Body() dto: BatchUpdateVideoMediaStatusDto) {
+    return this.videoMediaService.batchUpdateStatus(dto);
+  }
+
+  /**
+   * 封面上传:
+   * - 前端通过 multipart/form-data 上传文件
+   * - 这里示例使用 FileInterceptor;实际中你会上传到 S3,得到一个 URL / key
+   * POST /video-media/:id/cover
+   */
+  @Post(':id/cover')
+  @UseInterceptors(FileInterceptor('file'))
+  async updateCover(
+    @Param('id') id: string,
+    @UploadedFile() file: Express.Multer.File,
+  ) {
+    // TODO: 上传 file 到 S3,得到 coverImg URL / key
+    // const coverImg = await this.s3Service.uploadVideoCover(id, file);
+    // 暂时先用假值占位,避免编译报错
+    const coverImg = ''; // replace with real URL
+    return this.videoMediaService.updateCover(id, coverImg);
   }
 }

+ 236 - 8
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.dto.ts

@@ -1,14 +1,242 @@
-import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
-import { IsOptional, IsString, MaxLength } from 'class-validator';
+// video-media.dto.ts
 
-export class CreateVideoMediaDto {}
+import {
+  IsInt,
+  IsOptional,
+  IsString,
+  IsIn,
+  IsArray,
+  IsMongoId,
+  Min,
+  Max,
+  MaxLength,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+import { PageListDto } from '@box/common/dto/page-list.dto';
 
-export class UpdateVideoMediaDto extends PartialType(CreateVideoMediaDto) {}
-
-export class ListVideoMediaDto extends PartialType(CreateVideoMediaDto) {
-  @ApiPropertyOptional({ description: '模糊搜索', maxLength: 100 })
+export class VideoMediaListQueryDto extends PageListDto {
+  /**
+   * 搜索关键词:匹配标题、文件名等(具体逻辑在 service 里实现)
+   */
   @IsOptional()
   @IsString()
   @MaxLength(100)
-  search?: string;
+  keyword?: string;
+
+  /**
+   * 分类过滤:可选
+   */
+  @IsOptional()
+  @IsMongoId()
+  categoryId?: string;
+
+  /**
+   * 标签过滤(通常前端只会传一个 tagId)
+   */
+  @IsOptional()
+  @IsMongoId()
+  tagId?: string;
+
+  /**
+   * 上/下架状态过滤
+   * 0 = 下架, 1 = 上架
+   */
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @IsIn([0, 1])
+  listStatus?: number;
+}
+
+export class UpdateVideoMediaManageDto {
+  @IsOptional()
+  @IsString()
+  @MaxLength(200)
+  title?: string;
+
+  /**
+   * 分类 ID,可为空(取消分类)
+   */
+  @IsOptional()
+  @IsMongoId()
+  categoryId?: string | null;
+
+  /**
+   * 标签 ID 列表,最多 5 个
+   */
+  @IsOptional()
+  @IsArray()
+  @IsMongoId({ each: true })
+  tagIds?: string[];
+
+  /**
+   * 上/下架状态,也可以在这里一起保存(可选)
+   * 0 = 下架, 1 = 上架
+   */
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @IsIn([0, 1])
+  listStatus?: number;
+}
+
+export class UpdateVideoMediaStatusDto {
+  @Type(() => Number)
+  @IsInt()
+  @IsIn([0, 1])
+  listStatus!: number;
+}
+
+export class BatchUpdateVideoMediaStatusDto {
+  @IsArray()
+  @IsMongoId({ each: true })
+  ids!: string[];
+
+  @Type(() => Number)
+  @IsInt()
+  @IsIn([0, 1])
+  listStatus!: number;
+}
+
+export class UpdateVideoMediaCoverResponseDto {
+  @IsString()
+  id!: string;
+
+  @IsString()
+  coverImg!: string;
+
+  /**
+   * 新 editedAt 值
+   */
+  @IsString()
+  editedAt!: string;
+}
+
+export class VideoMediaListItemDto {
+  @IsString()
+  id!: string;
+
+  @IsString()
+  title!: string;
+
+  @IsString()
+  filename!: string;
+
+  /**
+   * 视频时长(秒)
+   */
+  @Type(() => Number)
+  @IsInt()
+  videoTime!: number;
+
+  /**
+   * 文件大小(业务上 BigInt 存储,DTO 建议用字符串避免精度问题)
+   */
+  @IsString()
+  size!: string;
+
+  /**
+   * 封面 URL / key
+   */
+  @IsString()
+  coverImg!: string;
+
+  /**
+   * 分类 ID,可空
+   */
+  @IsOptional()
+  @IsMongoId()
+  categoryId?: string | null;
+
+  /**
+   * 当前选中的标签 ID 列表(最多 5 个)
+   */
+  @IsArray()
+  @IsMongoId({ each: true })
+  tagIds!: string[];
+
+  /**
+   * 上/下架状态 0 = 下架, 1 = 上架
+   */
+  @Type(() => Number)
+  @IsInt()
+  @IsIn([0, 1])
+  listStatus!: number;
+
+  /**
+   * 本地编辑时间(BigInt epoch)— 字符串返回
+   */
+  @IsString()
+  editedAt!: string;
+}
+
+export class VideoMediaDetailDto {
+  @IsString()
+  id!: string;
+
+  // --- Provider fields (read-only for mgnt) ---
+
+  @IsString()
+  title!: string;
+
+  @IsString()
+  filename!: string;
+
+  @Type(() => Number)
+  @IsInt()
+  videoTime!: number;
+
+  @IsString()
+  size!: string; // from BigInt
+
+  @IsString()
+  coverImg!: string;
+
+  @IsString()
+  type!: string;
+
+  @Type(() => Number)
+  @IsInt()
+  formatType!: number;
+
+  @Type(() => Number)
+  @IsInt()
+  contentType!: number;
+
+  @IsString()
+  country!: string;
+
+  @IsString()
+  status!: string;
+
+  @IsString()
+  desc!: string;
+
+  // --- Local business fields ---
+
+  @IsOptional()
+  @IsMongoId()
+  categoryId?: string | null;
+
+  @IsArray()
+  @IsMongoId({ each: true })
+  tagIds!: string[];
+
+  @Type(() => Number)
+  @IsInt()
+  @IsIn([0, 1])
+  listStatus!: number;
+
+  @IsString()
+  editedAt!: string;
+
+  // --- Optional denormalized information for UI convenience ---
+
+  @IsOptional()
+  @IsString()
+  categoryName?: string | null;
+
+  @IsOptional()
+  @IsArray()
+  tags?: { id: string; name: string }[];
 }

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

@@ -1,29 +1,346 @@
 // apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.service.ts
-import { Injectable } from '@nestjs/common';
+import {
+  Injectable,
+  NotFoundException,
+  BadRequestException,
+} from '@nestjs/common';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
-import { CreateVideoMediaDto, UpdateVideoMediaDto } from './video-media.dto';
+import {
+  VideoMediaListQueryDto,
+  VideoMediaDetailDto,
+  UpdateVideoMediaManageDto,
+  UpdateVideoMediaStatusDto,
+  BatchUpdateVideoMediaStatusDto,
+} from './video-media.dto';
 
 @Injectable()
 export class VideoMediaService {
   constructor(private readonly prisma: MongoPrismaService) {}
 
-  create(createVideoMediaDto: CreateVideoMediaDto) {
-    return 'This action adds a new videoMedia';
+  async findAll(query: VideoMediaListQueryDto) {
+    const page = query.page ?? 1;
+    const pageSize = query.size ?? 20;
+    const skip = (page - 1) * pageSize;
+    const take = pageSize;
+
+    const where: any = {};
+
+    if (query.keyword) {
+      where.OR = [
+        { title: { contains: query.keyword, mode: 'insensitive' } },
+        { filename: { contains: query.keyword, mode: 'insensitive' } },
+      ];
+    }
+
+    if (query.categoryId) {
+      where.categoryId = query.categoryId;
+    }
+
+    if (query.tagId) {
+      where.tagIds = { has: query.tagId };
+    }
+
+    if (typeof query.listStatus === 'number') {
+      where.listStatus = query.listStatus;
+    }
+
+    const [total, rows] = await Promise.all([
+      this.prisma.videoMedia.count({ where }),
+      this.prisma.videoMedia.findMany({
+        where,
+        skip,
+        take,
+        orderBy: { addedTime: 'desc' },
+      }),
+    ]);
+
+    return {
+      total,
+      page,
+      pageSize,
+      items: rows.map((row) => ({
+        id: row.id,
+        title: row.title,
+        filename: row.filename,
+        videoTime: row.videoTime,
+        size: row.size?.toString?.() ?? '0',
+        coverImg: row.coverImg ?? '',
+        categoryId: row.categoryId ?? null,
+        tagIds: row.tagIds ?? [],
+        listStatus: row.listStatus ?? 0,
+        editedAt: row.editedAt?.toString?.() ?? '0',
+      })),
+    };
+  }
+
+  async findOne(id: string): Promise<VideoMediaDetailDto> {
+    const video = await this.prisma.videoMedia.findUnique({
+      where: { id },
+    });
+
+    if (!video) {
+      throw new NotFoundException('Video not found');
+    }
+
+    const [category, tags] = await Promise.all([
+      video.categoryId
+        ? this.prisma.category.findUnique({
+            where: { id: video.categoryId },
+          })
+        : null,
+      video.tagIds && video.tagIds.length
+        ? this.prisma.tag.findMany({
+            where: { id: { in: video.tagIds } },
+            orderBy: { seq: 'asc' },
+          })
+        : [],
+    ]);
+
+    return {
+      id: video.id,
+      title: video.title,
+      filename: video.filename,
+      videoTime: video.videoTime,
+      size: video.size?.toString?.() ?? '0',
+      coverImg: video.coverImg ?? '',
+      type: video.type,
+      formatType: video.formatType,
+      contentType: video.contentType,
+      country: video.country,
+      status: video.status,
+      desc: video.desc ?? '',
+      categoryId: video.categoryId ?? null,
+      tagIds: video.tagIds ?? [],
+      listStatus: video.listStatus ?? 0,
+      editedAt: video.editedAt?.toString?.() ?? '0',
+      categoryName: category?.name ?? null,
+      tags: tags.map((t) => ({ id: t.id, name: t.name })),
+    };
+  }
+
+  async updateManage(id: string, dto: UpdateVideoMediaManageDto) {
+    const video = await this.prisma.videoMedia.findUnique({
+      where: { id },
+    });
+
+    if (!video) {
+      throw new NotFoundException('Video not found');
+    }
+
+    const updateData: any = {};
+
+    if (typeof dto.title === 'string') {
+      updateData.title = dto.title.trim();
+    }
+
+    let categoryId: string | null | undefined = dto.categoryId;
+    const tagIds: string[] | undefined = dto.tagIds;
+
+    if (dto.categoryId === null) {
+      categoryId = null;
+    }
+
+    if (typeof categoryId !== 'undefined' || typeof tagIds !== 'undefined') {
+      const { finalCategoryId, finalTagIds, tagsFlat } =
+        await this.validateCategoryAndTags(categoryId, tagIds);
+
+      updateData.categoryId = finalCategoryId;
+      updateData.tagIds = finalTagIds;
+      updateData.tagsFlat = tagsFlat; // 👈 new
+    }
+
+    if (typeof dto.listStatus === 'number') {
+      if (dto.listStatus !== 0 && dto.listStatus !== 1) {
+        throw new BadRequestException('Invalid listStatus value');
+      }
+      updateData.listStatus = dto.listStatus;
+    }
+
+    updateData.editedAt = BigInt(Date.now());
+
+    await this.prisma.videoMedia.update({
+      where: { id },
+      data: updateData,
+    });
+
+    return this.findOne(id);
   }
 
-  findAll() {
-    return `This action returns all videoMedias`;
+  async updateStatus(id: string, dto: UpdateVideoMediaStatusDto) {
+    const video = await this.prisma.videoMedia.findUnique({
+      where: { id },
+    });
+
+    if (!video) {
+      throw new NotFoundException('Video not found');
+    }
+
+    if (dto.listStatus !== 0 && dto.listStatus !== 1) {
+      throw new BadRequestException('Invalid listStatus value');
+    }
+
+    const editedAt = BigInt(Date.now());
+
+    await this.prisma.videoMedia.update({
+      where: { id },
+      data: {
+        listStatus: dto.listStatus,
+        editedAt,
+      },
+    });
+
+    return {
+      id,
+      listStatus: dto.listStatus,
+      editedAt: editedAt.toString(),
+    };
   }
 
-  findOne(id: number) {
-    return `This action returns a #${id} videoMedia`;
+  async batchUpdateStatus(dto: BatchUpdateVideoMediaStatusDto) {
+    if (!dto.ids?.length) {
+      throw new BadRequestException('ids cannot be empty');
+    }
+
+    if (dto.listStatus !== 0 && dto.listStatus !== 1) {
+      throw new BadRequestException('Invalid listStatus value');
+    }
+
+    const editedAt = BigInt(Date.now());
+
+    const result = await this.prisma.videoMedia.updateMany({
+      where: { id: { in: dto.ids } },
+      data: {
+        listStatus: dto.listStatus,
+        editedAt,
+      },
+    });
+
+    return {
+      affected: result.count,
+      listStatus: dto.listStatus,
+      editedAt: editedAt.toString(),
+    };
   }
 
-  update(id: number, updateVideoMediaDto: UpdateVideoMediaDto) {
-    return `This action updates a #${id} videoMedia`;
+  /**
+   * 封面更新逻辑占位(S3 上传完成后更新 coverImg)
+   */
+  async updateCover(id: string, coverImg: string) {
+    const video = await this.prisma.videoMedia.findUnique({
+      where: { id },
+    });
+
+    if (!video) {
+      throw new NotFoundException('Video not found');
+    }
+
+    const editedAt = BigInt(Date.now());
+
+    await this.prisma.videoMedia.update({
+      where: { id },
+      data: {
+        coverImg,
+        editedAt,
+      },
+    });
+
+    return {
+      id,
+      coverImg,
+      editedAt: editedAt.toString(),
+    };
   }
 
-  remove(id: number) {
-    return `This action removes a #${id} videoMedia`;
+  private async validateCategoryAndTags(
+    categoryId: string | null | undefined,
+    tagIds: string[] | undefined,
+  ): Promise<{
+    finalCategoryId: string | null;
+    finalTagIds: string[];
+    tagsFlat: string;
+  }> {
+    let finalCategoryId: string | null =
+      typeof categoryId === 'undefined' ? (undefined as any) : categoryId;
+    let finalTagIds: string[] = [];
+    let tagsFlat = '';
+
+    // Normalize tagIds: remove duplicates
+    if (Array.isArray(tagIds)) {
+      const unique = [...new Set(tagIds)];
+      if (unique.length > 5) {
+        throw new BadRequestException('Tag count cannot exceed 5');
+      }
+      finalTagIds = unique;
+    }
+
+    // If tags are provided but categoryId is null/undefined -> error
+    if (finalTagIds.length > 0 && !finalCategoryId) {
+      throw new BadRequestException(
+        'Category is required when tags are provided.',
+      );
+    }
+
+    // Validate category if present
+    if (typeof finalCategoryId !== 'undefined' && finalCategoryId !== null) {
+      const category = await this.prisma.category.findUnique({
+        where: { id: finalCategoryId },
+      });
+
+      if (!category) {
+        throw new BadRequestException('Category not found');
+      }
+      if (category.status !== 1) {
+        throw new BadRequestException('Category is disabled');
+      }
+    }
+
+    if (finalTagIds.length > 0) {
+      const tags = await this.prisma.tag.findMany({
+        where: { id: { in: finalTagIds } },
+      });
+
+      if (tags.length !== finalTagIds.length) {
+        throw new BadRequestException('Some tags do not exist');
+      }
+
+      const distinctCategoryIds = [
+        ...new Set(tags.map((t) => t.categoryId.toString())),
+      ];
+
+      if (distinctCategoryIds.length > 1) {
+        throw new BadRequestException(
+          'All tags must belong to the same category',
+        );
+      }
+
+      const tagCategoryId = distinctCategoryIds[0];
+
+      if (finalCategoryId && tagCategoryId !== finalCategoryId) {
+        throw new BadRequestException(
+          'Tags do not belong to the specified category',
+        );
+      }
+
+      // If categoryId was not provided but tags exist, infer from tags
+      if (!finalCategoryId) {
+        finalCategoryId = tagCategoryId;
+      }
+
+      // Build tagsFlat: lowercased names joined by space
+      tagsFlat = tags
+        .map((t) => t.name.trim().toLowerCase())
+        .filter(Boolean)
+        .join(' ');
+    }
+
+    if (typeof finalCategoryId === 'undefined') {
+      finalCategoryId = null;
+    }
+
+    return {
+      finalCategoryId,
+      finalTagIds,
+      tagsFlat,
+    };
   }
 }

+ 19 - 0
box-mgnt-note.md

@@ -1,6 +1,22 @@
 # dave note for any changes here
 
 ```bash
+tree -L 8 -I 'node_modules|.git|dist'
+
+echo "20" > .nvmrc
+nvm install
+nvm use
+rm -rf node_modules
+rm -rf dist
+rm -rf ~/.pnpm-store
+pnpm install
+npm rebuild bcrypt
+find node_modules -name bcrypt_lib.node
+
+pnpm prisma:generate:mysql
+pnpm prisma:generate:mongo
+pnpm prisma:generate
+
 # Generate a new module
 nest g module mgnt-backend/feature/video-medias --project box-mgnt-api
 ```
@@ -9,6 +25,9 @@ nest g module mgnt-backend/feature/video-medias --project box-mgnt-api
 # Or generate a complete resource (module + controller + service + DTOs)
 nest g resource mgnt-backend/feature/video-medias --project box-mgnt-api
 
+## generate secret
+node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
+
 ```
 
 ```code

+ 1 - 0
libs/common/src/utils/image-lib.ts

@@ -1,3 +1,4 @@
+// box-nestjs-monorepo/libs/common/src/utils/image-lib.ts
 // You can extend this array with more headers if needed.
 export const ENCRYPTED_HEADERS: Uint8Array[] = [
   // Header from the Dart code: [0x88, 0xA8, 0x30, 0xCB, 0x10, 0x76]

+ 24 - 5
prisma/mongo/schema/video-media.prisma

@@ -76,12 +76,31 @@ model VideoMedia {
 
   // all above fields are from provider, keep original, avoid updating,
   // use below fields for local video media controls
-  categoryId    String?    @db.ObjectId     // 分类 ID
-  listStatus    Int       @default(0)       // 上/下架状态 0: 下架; 1: 上架
-  editedAt      BigInt    @default(0)       // 更新时间
+  categoryId    String?    @db.ObjectId      // 分类 ID (local)
+  tagIds        String[]   @default([]) @db.ObjectId  // 标签 IDs (local, max 5)
 
-  // Relations
-  category      Category  @relation(fields: [categoryId], references: [id])
+  /// Lowercased concatenation of tag names, for search
+  tagsFlat      String     @default("")
+
+  listStatus    Int        @default(0)       // 上/下架
+  editedAt      BigInt     @default(0)       // 本地编辑时间 (epoch ms)
+
+  // Relations (optional, just for Prisma)
+  // category      Category?  @relation(fields: [categoryId], references: [id])
 
   @@map("videoMedia")
+
+  // ===== Indexes =====
+
+  /// For fuzzy tag search + status filter (app search)
+  @@index([listStatus, tagsFlat], map: "idx_videoMedia_listStatus_tagsFlat")
+
+  /// For mgnt filtering by category
+  @@index([categoryId], map: "idx_videoMedia_categoryId")
+
+  /// For mgnt filtering by tagIds (multi-key index)
+  @@index([tagIds], map: "idx_videoMedia_tagIds")
+
+  /// For common sorting by addedTime (provider ordering)
+  @@index([addedTime], map: "idx_videoMedia_addedTime")
 }

+ 236 - 0
structures.txt

@@ -0,0 +1,236 @@
+.
+├── apps
+│   └── box-mgnt-api
+│       ├── src
+│       │   ├── app.config.ts
+│       │   ├── app.module.ts
+│       │   ├── config
+│       │   │   └── env.validation.ts
+│       │   ├── global.d.ts
+│       │   ├── main.ts
+│       │   └── mgnt-backend
+│       │       ├── core
+│       │       │   ├── auth
+│       │       │   │   ├── auth.constants.ts
+│       │       │   │   ├── auth.controller.ts
+│       │       │   │   ├── auth.dto.ts
+│       │       │   │   ├── auth.interface.ts
+│       │       │   │   ├── auth.module.ts
+│       │       │   │   ├── auth.service.ts
+│       │       │   │   ├── config
+│       │       │   │   │   └── jwt.config.ts
+│       │       │   │   ├── decorators
+│       │       │   │   │   └── public.decorator.ts
+│       │       │   │   ├── dto
+│       │       │   │   │   └── 2fa.dto.ts
+│       │       │   │   ├── guards
+│       │       │   │   │   ├── jwt-auth.guard.ts
+│       │       │   │   │   ├── local-auth.guard.ts
+│       │       │   │   │   ├── mfa-stage.guard.ts
+│       │       │   │   │   └── rbac.guard.ts
+│       │       │   │   ├── strategies
+│       │       │   │   │   ├── jwt.strategy.ts
+│       │       │   │   │   └── local.strategy.ts
+│       │       │   │   ├── totp.helper.ts
+│       │       │   │   └── twofa.service.ts
+│       │       │   ├── core.module.ts
+│       │       │   ├── logging
+│       │       │   │   ├── login-log
+│       │       │   │   │   ├── login-log.controller.ts
+│       │       │   │   │   ├── login-log.module.ts
+│       │       │   │   │   └── login-log.service.ts
+│       │       │   │   ├── operation-log
+│       │       │   │   │   ├── operation-log.controller.ts
+│       │       │   │   │   ├── operation-log.module.ts
+│       │       │   │   │   └── operation-log.service.ts
+│       │       │   │   └── quota-log
+│       │       │   │       ├── quota-log.controller.ts
+│       │       │   │       ├── quota-log.module.ts
+│       │       │   │       └── quota-log.service.ts
+│       │       │   ├── menu
+│       │       │   │   ├── menu.constants.ts
+│       │       │   │   ├── menu.controller.ts
+│       │       │   │   ├── menu.dto.ts
+│       │       │   │   ├── menu.interface.ts
+│       │       │   │   ├── menu.module.ts
+│       │       │   │   └── menu.service.ts
+│       │       │   ├── role
+│       │       │   │   ├── role.constants.ts
+│       │       │   │   ├── role.controller.ts
+│       │       │   │   ├── role.dto.ts
+│       │       │   │   ├── role.module.ts
+│       │       │   │   └── role.service.ts
+│       │       │   └── user
+│       │       │       ├── user.constants.ts
+│       │       │       ├── user.controller.ts
+│       │       │       ├── user.dto.ts
+│       │       │       ├── user.module.ts
+│       │       │       └── user.service.ts
+│       │       ├── feature
+│       │       │   ├── ads
+│       │       │   │   ├── ads.controller.ts
+│       │       │   │   ├── ads.dto.ts
+│       │       │   │   ├── ads.module.ts
+│       │       │   │   └── ads.service.ts
+│       │       │   ├── category
+│       │       │   │   ├── category.controller.ts
+│       │       │   │   ├── category.dto.ts
+│       │       │   │   ├── category.module.ts
+│       │       │   │   └── category.service.ts
+│       │       │   ├── channel
+│       │       │   │   ├── channel.controller.ts
+│       │       │   │   ├── channel.dto.ts
+│       │       │   │   ├── channel.module.ts
+│       │       │   │   └── channel.service.ts
+│       │       │   ├── common
+│       │       │   │   ├── mongo-id.dto.ts
+│       │       │   │   └── status.enum.ts
+│       │       │   ├── feature.module.ts
+│       │       │   ├── mgnt-http-service
+│       │       │   │   ├── mgnt-http-service.config.ts
+│       │       │   │   ├── mgnt-http-service.module.ts
+│       │       │   │   └── mgnt-http.service.ts
+│       │       │   ├── oss
+│       │       │   │   ├── oss.config.ts
+│       │       │   │   ├── oss.controller.ts
+│       │       │   │   ├── oss.module.ts
+│       │       │   │   └── oss.service.ts
+│       │       │   ├── s3
+│       │       │   │   ├── s3.config.ts
+│       │       │   │   ├── s3.controller.ts
+│       │       │   │   ├── s3.module.ts
+│       │       │   │   └── s3.service.ts
+│       │       │   ├── sync-videomedia
+│       │       │   │   ├── sync-videomedia.controller.ts
+│       │       │   │   ├── sync-videomedia.module.ts
+│       │       │   │   └── sync-videomedia.service.ts
+│       │       │   ├── system-params
+│       │       │   │   ├── system-param.dto.ts
+│       │       │   │   ├── system-params.controller.ts
+│       │       │   │   ├── system-params.module.ts
+│       │       │   │   └── system-params.service.ts
+│       │       │   ├── tag
+│       │       │   │   ├── tag.controller.ts
+│       │       │   │   ├── tag.dto.ts
+│       │       │   │   ├── tag.module.ts
+│       │       │   │   └── tag.service.ts
+│       │       │   └── video-media
+│       │       │       ├── video-media.controller.ts
+│       │       │       ├── video-media.dto.ts
+│       │       │       ├── video-media.module.ts
+│       │       │       └── video-media.service.ts
+│       │       └── mgnt-backend.module.ts
+│       └── tsconfig.json
+├── ARCHITECTURE_FLOW.md
+├── BEFORE_AFTER.md
+├── box-mgnt-note.md
+├── box-nestjs-monorepo-init.md
+├── DEPLOYMENT_CHECKLIST.md
+├── DEVELOPER_GUIDE.md
+├── IMPLEMENTATION_SUMMARY.md
+├── libs
+│   ├── common
+│   │   ├── package.json
+│   │   ├── src
+│   │   │   ├── common.module.ts
+│   │   │   ├── config
+│   │   │   │   └── pino.config.ts
+│   │   │   ├── crypto
+│   │   │   │   └── aes-gcm.ts
+│   │   │   ├── decorators
+│   │   │   │   ├── auth-user.decorator.ts
+│   │   │   │   └── operation-log.decorator.ts
+│   │   │   ├── dto
+│   │   │   │   ├── page-list.dto.ts
+│   │   │   │   └── page-list-response.dto.ts
+│   │   │   ├── filters
+│   │   │   │   ├── all-exceptions.filter.ts
+│   │   │   │   ├── http-exception.filter.ts
+│   │   │   │   └── index.ts
+│   │   │   ├── guards
+│   │   │   │   ├── index.ts
+│   │   │   │   ├── mfa.guard.ts
+│   │   │   │   └── rate-limit.guard.ts
+│   │   │   ├── interceptors
+│   │   │   │   ├── correlation.interceptor.ts
+│   │   │   │   ├── logging.interceptor.ts
+│   │   │   │   ├── operation-log.interceptor.ts
+│   │   │   │   └── response.interceptor.ts
+│   │   │   ├── interfaces
+│   │   │   │   ├── api-response.interface.ts
+│   │   │   │   ├── index.ts
+│   │   │   │   ├── operation-logger.interface.ts
+│   │   │   │   └── response.interface.ts
+│   │   │   ├── services
+│   │   │   │   └── exception.service.ts
+│   │   │   ├── types
+│   │   │   │   └── fastify.d.ts
+│   │   │   └── utils
+│   │   │       └── image-lib.ts
+│   │   └── tsconfig.json
+│   ├── core
+│   │   ├── package.json
+│   │   ├── src
+│   │   └── tsconfig.json
+│   └── db
+│       ├── package.json
+│       ├── src
+│       │   ├── prisma
+│       │   │   ├── mongo-prisma.service.ts
+│       │   │   ├── mysql-prisma.service.ts
+│       │   │   └── prisma.module.ts
+│       │   ├── shared.module.ts
+│       │   └── utils.service.ts
+│       └── tsconfig.json
+├── logs
+│   ├── error.log
+│   └── info.log
+├── mongo-db-seeds.md
+├── nest-cli.json
+├── package.json
+├── pnpm-lock.yaml
+├── pnpm-workspace.yaml
+├── prisma
+│   ├── mongo
+│   │   └── schema
+│   │       ├── ads-module.prisma
+│   │       ├── ads.prisma
+│   │       ├── category.prisma
+│   │       ├── channel.prisma
+│   │       ├── home.prisma
+│   │       ├── main.prisma
+│   │       ├── system-param.prisma
+│   │       ├── tag.prisma
+│   │       └── video-media.prisma
+│   └── mysql
+│       ├── migrations
+│       │   ├── 20251121082348_init_db
+│       │   │   └── migration.sql
+│       │   └── migration_lock.toml
+│       ├── schema
+│       │   ├── api-permission.prisma
+│       │   ├── login-log.prisma
+│       │   ├── main.prisma
+│       │   ├── main.prisma.md
+│       │   ├── menu.prisma
+│       │   ├── operation-log.prisma
+│       │   ├── quota-log.prisma
+│       │   ├── role-api-permission.prisma
+│       │   ├── role-menu.prisma
+│       │   ├── role.prisma
+│       │   ├── seeds
+│       │   │   ├── menu-seeds.ts
+│       │   │   ├── seed-menu.ts
+│       │   │   ├── SEED_REVIEW.md
+│       │   │   └── seed-user.ts
+│       │   ├── user.prisma
+│       │   └── user-role.prisma
+│       └── seed.ts
+├── REFACTOR_README.md
+├── REFACTOR_SUMMARY.md
+├── structures.txt
+├── tsconfig.base.json
+├── tsconfig.json
+└── tsconfig.seed.json
+
+60 directories, 174 files