Przeglądaj źródła

feat: add importExcelTags method to VideoMediaService for importing video tags from Excel; implement importExcelTags endpoint in VideoMediaController

Dave 3 tygodni temu
rodzic
commit
34ccbe5614

+ 35 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.controller.ts

@@ -11,6 +11,8 @@ import {
   Req,
   Res,
   BadRequestException,
+  UploadedFile,
+  UseInterceptors,
 } from '@nestjs/common';
 import type { FastifyReply, FastifyRequest } from 'fastify';
 import {
@@ -24,6 +26,8 @@ import {
   ApiNotFoundResponse,
   ApiBadRequestResponse,
 } from '@nestjs/swagger';
+import { FileInterceptor } from '@nestjs/platform-express';
+import * as multer from 'multer';
 import { VideoMediaService } from './video-media.service';
 import {
   VideoMediaListQueryDto,
@@ -266,6 +270,37 @@ export class VideoMediaController {
   }
 
   /**
+   * 导入 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')
+  @UseInterceptors(FileInterceptor('file', { storage: multer.memoryStorage() }))
+  async importExcelTags(@UploadedFile() file: Express.Multer.File) {
+    if (!file) {
+      throw new BadRequestException('No file uploaded');
+    }
+
+    return this.videoMediaService.importExcelTags(file);
+  }
+
+  /**
    * 导出所有视频媒体为 Excel
    * GET /video-media/export/excel
    */

+ 196 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.service.ts

@@ -8,6 +8,8 @@ import {
 import type { MultipartFile } from '@fastify/multipart';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import ExcelJS from 'exceljs';
+import * as XLSX from 'xlsx';
+import type { Express } from 'express';
 import { CacheSyncService } from '../../../cache-sync/cache-sync.service';
 import { CacheEntityType } from '../../../cache-sync/cache-sync.types';
 import { MediaManagerService } from '@box/core/media-manager/media-manager.service';
@@ -59,6 +61,47 @@ export class VideoMediaService {
     return String(id);
   }
 
+  private parseCommaTags(raw: string): string[] {
+    if (typeof raw !== 'string') return [];
+    const trimmed = raw.trim();
+    if (!trimmed) return [];
+
+    const seen = new Set<string>();
+    const tags: string[] = [];
+
+    for (const part of trimmed.split(',')) {
+      const name = part.trim();
+      if (!name || seen.has(name)) continue;
+      seen.add(name);
+      tags.push(name);
+    }
+
+    return tags;
+  }
+
+  private mergeAppendUnique(existing: string[], incoming: string[]): string[] {
+    const result: string[] = [];
+    const seen = new Set<string>();
+
+    for (const item of existing ?? []) {
+      if (typeof item !== 'string' || seen.has(item)) continue;
+      seen.add(item);
+      result.push(item);
+    }
+
+    for (const item of incoming ?? []) {
+      if (typeof item !== 'string' || seen.has(item)) continue;
+      seen.add(item);
+      result.push(item);
+    }
+
+    return result;
+  }
+
+  private isObjectIdString(value: string): boolean {
+    return typeof value === 'string' && /^[a-fA-F0-9]{24}$/.test(value);
+  }
+
   // helper to generate next vid
   private async generateNextVid(): Promise<number> {
     const last = await this.prisma.videoMedia.findFirst({
@@ -259,6 +302,159 @@ export class VideoMediaService {
     return Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
   }
 
+  async importExcelTags(file: Express.Multer.File) {
+    if (!file?.buffer) {
+      throw new BadRequestException('No file uploaded');
+    }
+
+    const workbook = XLSX.read(file.buffer, { type: 'buffer' });
+    const sheetName = workbook.SheetNames[0];
+    const sheet = sheetName ? workbook.Sheets[sheetName] : undefined;
+
+    if (!sheet) {
+      throw new BadRequestException('No sheet found in Excel file');
+    }
+
+    const rows = XLSX.utils.sheet_to_json(sheet, {
+      header: 1,
+      defval: '',
+    }) as unknown as any[][];
+
+    const errors: Array<{ rowNumber: number; idRaw: unknown; reason: string }> =
+      [];
+    const candidates: Array<{
+      rowNumber: number;
+      idRaw: unknown;
+      id: string;
+      tags: string[];
+    }> = [];
+
+    const uniqueTagNames = new Set<string>();
+    let candidateRows = 0;
+    let skippedInvalidId = 0;
+    let skippedEmptyTags = 0;
+
+    for (let i = 1; i < rows.length; i += 1) {
+      const row = rows[i] ?? [];
+      const rowNumber = i + 1;
+      const idRaw = row[0];
+      const tagsCell = row[7];
+      const tagsRaw =
+        typeof tagsCell === 'string'
+          ? tagsCell
+          : tagsCell == null
+            ? ''
+            : String(tagsCell);
+
+      const tags = this.parseCommaTags(tagsRaw);
+
+      if (tags.length === 0) {
+        skippedEmptyTags += 1;
+        continue;
+      }
+
+      const id = this.normalizeMongoIdToString(idRaw).trim();
+      if (!this.isObjectIdString(id)) {
+        skippedInvalidId += 1;
+        errors.push({ rowNumber, idRaw, reason: 'invalid id' });
+        continue;
+      }
+
+      candidateRows += 1;
+
+      for (const tag of tags) {
+        uniqueTagNames.add(tag);
+      }
+
+      candidates.push({
+        rowNumber,
+        idRaw,
+        id,
+        tags,
+      });
+    }
+
+    const tagDocs =
+      uniqueTagNames.size > 0
+        ? await this.prisma.tag.findMany({
+            where: { name: { in: Array.from(uniqueTagNames) } },
+            select: { id: true, name: true },
+          })
+        : [];
+
+    const tagMap = new Map(tagDocs.map((tag) => [tag.name, tag]));
+
+    let updated = 0;
+
+    for (const row of candidates) {
+      try {
+        const existing = await this.prisma.videoMedia.findUnique({
+          where: { id: row.id },
+          select: { secondTags: true },
+        });
+
+        if (!existing) {
+          errors.push({
+            rowNumber: row.rowNumber,
+            idRaw: row.idRaw,
+            reason: 'video not found',
+          });
+          continue;
+        }
+
+        const mergedSecondTags = this.mergeAppendUnique(
+          existing.secondTags ?? [],
+          row.tags,
+        );
+
+        const vTags = row.tags
+          .map((name) => tagMap.get(name))
+          .filter(Boolean)
+          .map((tag) => ({
+            tagId: tag!.id,
+            tagName: tag!.name,
+          }));
+
+        const editedAt = BigInt(Math.floor(Date.now() / 1000));
+        const updatedAt = new Date();
+        const data: Record<string, any> = {
+          tags: { set: row.tags },
+          secondTags: { set: mergedSecondTags },
+          editedAt,
+          updatedAt,
+        };
+
+        if (vTags.length > 0) {
+          data.vTags = { set: vTags };
+        }
+
+        await this.prisma.videoMedia.update({
+          where: { id: row.id },
+          data,
+        });
+
+        updated += 1;
+      } catch (error: any) {
+        errors.push({
+          rowNumber: row.rowNumber,
+          idRaw: row.idRaw,
+          reason: error?.message ?? String(error),
+        });
+      }
+    }
+
+    return {
+      totalRows: rows.length,
+      candidateRows,
+      updated,
+      skippedInvalidId,
+      skippedEmptyTags,
+      tagLookupTotalUnique: uniqueTagNames.size,
+      tagLookupFound: tagDocs.length,
+      errors,
+    };
+  }
+
   private buildVideoListBaseFilter(
     query: VideoMediaListQueryDto,
   ): Record<string, any> {