// apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.controller.ts import { Controller, Get, Param, Query, Patch, Body, Post, Delete, Req, Res, BadRequestException, } from '@nestjs/common'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { ApiTags, ApiOperation, ApiParam, ApiConsumes, ApiBody, ApiResponse, ApiOkResponse, ApiNotFoundResponse, ApiBadRequestResponse, } from '@nestjs/swagger'; import { VideoMediaService } from './video-media.service'; import { VideoMediaListQueryDto, UpdateVideoMediaTagsDto, UpdateVideoMediaStatusDto, BatchUpdateVideoMediaStatusDto, VideoMediaListItemDto, VideoMediaDetailDto, UpdateVideoMediaCoverResponseDto, } from './video-media.dto'; @ApiTags('视频管理 (Video Media Management)') @Controller('video-media') export class VideoMediaController { constructor(private readonly videoMediaService: VideoMediaService) {} /** * 列表查询 * POST /video-media/list */ @ApiOperation({ summary: '获取视频媒体列表', description: '分页查询视频列表,支持关键词搜索和上下架状态过滤', }) @ApiOkResponse({ description: '返回视频媒体分页列表', schema: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/VideoMediaListItemDto' }, }, total: { type: 'number', example: 100 }, page: { type: 'number', example: 1 }, pageSize: { type: 'number', example: 20 }, }, }, }) @ApiBadRequestResponse({ description: '请求参数验证失败', }) @Post('list') async findAll(@Body() dto: VideoMediaListQueryDto) { return this.videoMediaService.findAll(dto); } /** * 详情(管理弹窗) * GET /video-media/:id */ @ApiOperation({ summary: '获取视频媒体详情', description: '获取单个视频媒体的完整详细信息,包括基础属性、管理信息和反范式化数据', }) @ApiParam({ name: 'id', type: String, description: '视频媒体 MongoDB ID', example: '507f1f77bcf86cd799439011', }) @ApiOkResponse({ description: '返回视频媒体详情', type: VideoMediaDetailDto, }) @ApiNotFoundResponse({ description: '视频媒体不存在', }) @Get(':id') async findOne(@Param('id') id: string) { return this.videoMediaService.findOne(id); } /** * 管理弹窗保存(标题 / 分类 / 标签 / 上下架) * PATCH /video-media/:id/manage */ @ApiOperation({ summary: '更新视频媒体管理信息', description: '更新视频的标题、分类、标签、上下架状态等管理级别信息', }) @ApiBody({ type: UpdateVideoMediaTagsDto, description: '更新的管理信息', }) @Post('update-video-tags') async updateManage(@Body() dto: UpdateVideoMediaTagsDto) { return this.videoMediaService.updateVideoTags(dto); } /** * 单个上/下架 * PATCH /video-media/:id/status */ @ApiOperation({ summary: '更新视频媒体上下架状态', description: '对单个视频进行上架(1)或下架(0)操作', }) @ApiParam({ name: 'id', type: String, description: '视频媒体 MongoDB ID', example: '507f1f77bcf86cd799439011', }) @ApiBody({ type: UpdateVideoMediaStatusDto, description: '上下架状态信息', }) @ApiOkResponse({ description: '状态更新成功', type: VideoMediaDetailDto, }) @ApiNotFoundResponse({ description: '视频媒体不存在', }) @ApiBadRequestResponse({ description: '请求参数验证失败', }) @Patch(':id/status') async updateStatus( @Param('id') id: string, @Body() dto: UpdateVideoMediaStatusDto, ) { return this.videoMediaService.updateStatus(id, dto); } /** * 批量上/下架 * POST /video-media/batch/status */ @ApiOperation({ summary: '批量更新视频媒体上下架状态', description: '对多个视频进行批量上架或下架操作', }) @ApiBody({ type: BatchUpdateVideoMediaStatusDto, description: '批量更新信息,包含 ID 列表和目标状态', }) @ApiOkResponse({ description: '批量更新成功', schema: { type: 'object', properties: { success: { type: 'number', example: 10 }, failed: { type: 'number', example: 0 }, }, }, }) @ApiBadRequestResponse({ description: '请求参数验证失败', }) @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 */ @ApiOperation({ summary: '上传视频封面', description: '为指定视频上传或更新自定义封面图片,支持上传至 S3 存储', }) @ApiParam({ name: 'id', type: String, description: '视频媒体 MongoDB ID', example: '507f1f77bcf86cd799439011', }) @ApiConsumes('multipart/form-data') @ApiBody({ description: '封面图片文件上传', schema: { type: 'object', properties: { file: { type: 'string', format: 'binary', description: '图片文件(支持 JPG、PNG 等常见格式)', }, }, required: ['file'], }, }) @ApiOkResponse({ description: '封面上传成功', type: UpdateVideoMediaCoverResponseDto, }) @ApiNotFoundResponse({ description: '视频媒体不存在', }) @ApiBadRequestResponse({ description: '文件格式或大小不符合要求', }) @Post(':id/cover') async updateCover(@Param('id') id: string, @Req() req: FastifyRequest) { const reqAny = req as any; const bodyFile = reqAny.body?.file; let mpFile = Array.isArray(bodyFile) ? bodyFile[0] : bodyFile; if (!mpFile && reqAny.isMultipart?.()) { mpFile = await reqAny.file(); } if (!mpFile) { throw new BadRequestException('No file uploaded'); } return this.videoMediaService.updateCover(id, mpFile); } // TODO: 删除视频媒体 @ApiOperation({ summary: '删除视频媒体', description: '根据 ID 删除指定的视频媒体', }) @ApiParam({ name: 'id', type: String, description: '视频媒体 MongoDB ID', example: '507f1f77bcf86cd799439011', }) @ApiOkResponse({ description: '删除成功', schema: { type: 'object', properties: { id: { type: 'string', example: '507f1f77bcf86cd799439011' }, }, }, }) @ApiNotFoundResponse({ description: '视频媒体不存在', }) @Delete(':id') async delete(@Param('id') id: string) { return this.videoMediaService.delete(id); } /** * 导入 Excel 标签 * POST /video-media/import/excel-tags */ @ApiOperation({ summary: '导入视频标签', description: '从 Excel 文件导入视频标签并更新视频媒体', }) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' }, }, required: ['file'], }, }) @Post('import/excel-tags') async importExcelTags(@Req() req: FastifyRequest) { const getBuffer = async (file: any): Promise => { if (!file) return undefined; if (Buffer.isBuffer(file)) return file; if (file instanceof Uint8Array) return Buffer.from(file); const candidate = file.buffer ?? file.data ?? file.value ?? file._buf ?? undefined; if (Buffer.isBuffer(candidate)) return candidate; if (candidate instanceof Uint8Array) return Buffer.from(candidate); if (typeof candidate === 'string') return Buffer.from(candidate); if (typeof file.toBuffer === 'function') { const buf = await file.toBuffer(); if (Buffer.isBuffer(buf)) return buf; } if (file.file) { const chunks: Buffer[] = []; for await (const chunk of file.file) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } if (chunks.length > 0) { return Buffer.concat(chunks); } } return undefined; }; // fastify multipart const reqAny = req as any; const bodyFile = reqAny.body?.file; let mpFile = Array.isArray(bodyFile) ? bodyFile[0] : bodyFile; if (!mpFile && reqAny.isMultipart?.()) { mpFile = await reqAny.file(); } if (!mpFile) { throw new BadRequestException('No file uploaded'); } const buf = await getBuffer(mpFile); if (!buf?.length) { throw new BadRequestException('Empty file'); } return this.videoMediaService.importExcelTags(buf); } /** * 导出所有视频媒体为 Excel * GET /video-media/export/excel */ @Get('export/excel') async exportExcel(@Res() res: FastifyReply) { const buffer = await this.videoMediaService.exportExcel(); res .header( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ) .header('Content-Disposition', 'attachment; filename="video-media.xlsx"') .send(buffer); } }