Explorar el Código

feat(category): update category update endpoint and simplify DTO validation
feat(tag): modify categoryId validation and update tag service logic
refactor(tag): adjust Tag model and remove unnecessary indexes

Dave hace 1 mes
padre
commit
22f06a01ab

+ 3 - 6
apps/box-mgnt-api/src/mgnt-backend/feature/category/category.controller.ts

@@ -62,14 +62,11 @@ export class CategoryController {
     return this.service.create(dto);
   }
 
-  @Put(':id')
+  @Post('update')
   @ApiOperation({ summary: 'Update category' })
   @ApiResponse({ status: 200, type: CategoryDto })
-  update(@Param() { id }: MongoIdParamDto, @Body() dto: UpdateCategoryDto) {
-    if (dto.id && dto.id !== id) {
-      throw new BadRequestException('ID in body must match ID in path');
-    }
-    return this.service.update({ ...dto, id });
+  update(@Body() dto: UpdateCategoryDto) {
+    return this.service.update(dto);
   }
 
   @Delete(':id')

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

@@ -76,15 +76,15 @@ export class CreateCategoryDto {
   @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
   name: string;
 
-  @ApiPropertyOptional({
-    description: '副标题',
-    example: '暑期档精选',
-  })
-  @IsOptional()
-  @IsString()
-  @MaxLength(200)
-  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
-  subtitle?: string;
+  // @ApiPropertyOptional({
+  //   description: '副标题',
+  //   example: '暑期档精选',
+  // })
+  // @IsOptional()
+  // @IsString()
+  // @MaxLength(200)
+  // @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  // subtitle?: string;
 
   // @ApiProperty({
   //   description: '渠道ID (Mongo ObjectId)',
@@ -100,15 +100,15 @@ export class CreateCategoryDto {
   @Min(0)
   seq?: number;
 
-  @ApiPropertyOptional({
-    enum: CommonStatus,
-    description: '状态: 0=禁用, 1=启用',
-    example: CommonStatus.enabled,
-  })
-  @Type(() => Number)
-  @IsOptional()
-  @IsEnum(CommonStatus)
-  status?: CommonStatus;
+  // @ApiPropertyOptional({
+  //   enum: CommonStatus,
+  //   description: '状态: 0=禁用, 1=启用',
+  //   example: CommonStatus.enabled,
+  // })
+  // @Type(() => Number)
+  // @IsOptional()
+  // @IsEnum(CommonStatus)
+  // status?: CommonStatus;
 }
 
 export class UpdateCategoryDto extends CreateCategoryDto {

+ 37 - 8
apps/box-mgnt-api/src/mgnt-backend/feature/category/category.service.ts

@@ -68,9 +68,9 @@ export class CategoryService {
     const category = await this.mongoPrismaService.category.create({
       data: {
         name: dto.name,
-        subtitle: dto.subtitle?.trim() ?? null,
+        // subtitle: dto.subtitle?.trim() ?? null,
         seq: dto.seq ?? 0,
-        status: dto.status ?? CommonStatus.enabled,
+        status: CommonStatus.enabled,
         createAt: now,
         updateAt: now,
       },
@@ -118,15 +118,15 @@ export class CategoryService {
     // Build data object carefully to avoid unintended field changes
     const data: any = {
       name: dto.name,
-      subtitle: dto.subtitle?.trim() ?? null,
+      // subtitle: dto.subtitle?.trim() ?? null,
       seq: dto.seq ?? 0,
       updateAt: now,
     };
 
     // Only update status if explicitly provided to avoid silently re-enabling
-    if (dto.status !== undefined) {
-      data.status = dto.status;
-    }
+    // if (dto.status !== undefined) {
+    //   data.status = dto.status;
+    // }
 
     try {
       const category = await this.mongoPrismaService.category.update({
@@ -271,12 +271,41 @@ export class CategoryService {
     }
   }
 
-  async refreshTagsMetadata(categoryId: string): Promise<void> {
-    const tags = await this.mongoPrismaService.tag.findMany({
+  async refreshTagsMetadata(categoryId?: string | null): Promise<void> {
+    if (!categoryId) return;
+
+    // 1. Get tagIds bound to this category
+    const categoryTags = await this.mongoPrismaService.categoryTag.findMany({
       where: { categoryId },
+      orderBy: { tagId: 'asc' }, // stable order, real order comes from Tag.seq
+      select: { tagId: true },
+    });
+
+    if (!categoryTags.length) {
+      // No tags under this category → clear derived metadata
+      await this.mongoPrismaService.category.update({
+        where: { id: categoryId },
+        data: {
+          tags: [],
+          tagNames: [],
+        },
+      });
+      return;
+    }
+
+    const tagIds = categoryTags.map((ct) => ct.tagId);
+
+    // 2. Load tags (authoritative source)
+    const tags = await this.mongoPrismaService.tag.findMany({
+      where: {
+        id: { in: tagIds },
+        status: CommonStatus.enabled,
+      },
       orderBy: { seq: 'asc' },
       select: { id: true, name: true },
     });
+
+    // 3. Rebuild derived payload
     await this.rebuildCategoryTagPayload(categoryId, tags);
   }
 

+ 194 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/provider-video-sync/provider-video-sync.service.ts

@@ -118,6 +118,13 @@ type UpsertOutcome =
   | { ok: true }
   | { ok: false; error: { id?: string; error: string } };
 
+type UpsertTagsResult = {
+  unique: number;
+  upserted: number;
+  skipped: number;
+  errors: Array<{ name: string; error: string }>;
+};
+
 @Injectable()
 export class ProviderVideoSyncService {
   private readonly logger = new Logger(ProviderVideoSyncService.name);
@@ -248,6 +255,14 @@ export class ProviderVideoSyncService {
 
         const normalized = rawList.map((item) => this.normalizeItem(item));
 
+        const hasSecondTags = normalized.some(
+          (v) => Array.isArray(v.secondTags) && v.secondTags.length > 0,
+        );
+
+        if (hasSecondTags) {
+          await this.upsertSecondTagsFromVideos_NoUniqueName(normalized);
+        }
+
         // update maxUpdatedAtSeen for cursor advance (incremental correctness)
         for (const n of normalized) {
           const d = n.updatedAt as Date;
@@ -614,4 +629,183 @@ export class ProviderVideoSyncService {
     const x = Number.isFinite(n) ? Math.trunc(n) : min;
     return Math.max(min, Math.min(max, x));
   }
+
+  /**
+   * Extract secondTags (string[]) from normalized video records and upsert into Tag collection.
+   * - Dedup in-memory per call for performance
+   * - Trims whitespace, filters empty
+   * - Option B performance-first: upsert without pre-check
+   */
+  // private async upsertSecondTagsFromVideos(
+  //   normalizedVideos: Array<{ secondTags?: string[] }>,
+  // ): Promise<UpsertTagsResult> {
+  //   // 1) Collect + normalize
+  //   const set = new Set<string>();
+
+  //   for (const v of normalizedVideos) {
+  //     const tags = v.secondTags ?? [];
+  //     for (const t of tags) {
+  //       if (typeof t !== 'string') continue;
+  //       const name = t.trim();
+  //       if (!name) continue;
+  //       set.add(name);
+  //     }
+  //   }
+
+  //   const names = Array.from(set);
+  //   if (!names.length) {
+  //     return { unique: 0, upserted: 0, skipped: 0, errors: [] };
+  //   }
+
+  //   // 2) Upsert in chunks (avoid massive Promise.all)
+  //   const CHUNK = 200;
+  //   let upserted = 0;
+  //   let skipped = 0;
+  //   const errors: Array<{ name: string; error: string }> = [];
+
+  //   for (let i = 0; i < names.length; i += CHUNK) {
+  //     const batch = names.slice(i, i + CHUNK);
+
+  //     // eslint-disable-next-line no-await-in-loop
+  //     const outcomes = await Promise.all(
+  //       batch.map(async (name) => {
+  //         try {
+  //           // 🔁 Adjust `where/create/update` if your Tag schema differs
+  //           await this.mongo.tag.upsert({
+  //             where: { name },
+  //             create: {
+  //               name,
+  //               // If Tag requires createdAt/updatedAt ints (seconds), uncomment:
+  //               // createdAt: Math.floor(Date.now() / 1000),
+  //               // updatedAt: Math.floor(Date.now() / 1000),
+  //             },
+  //             update: {
+  //               // keep it minimal; optionally touch updatedAt
+  //               // updatedAt: Math.floor(Date.now() / 1000),
+  //             },
+  //           });
+  //           return { ok: true as const };
+  //         } catch (e: any) {
+  //           return {
+  //             ok: false as const,
+  //             error: e?.message ?? 'Tag upsert failed',
+  //           };
+  //         }
+  //       }),
+  //     );
+
+  //     for (let j = 0; j < outcomes.length; j += 1) {
+  //       const o = outcomes[j];
+  //       if (o.ok) upserted += 1;
+  //       else {
+  //         skipped += 1;
+  //         errors.push({ name: batch[j], error: o.error });
+  //       }
+  //     }
+  //   }
+
+  //   if (errors.length) {
+  //     this.logger.warn(
+  //       `[upsertSecondTagsFromVideos] tag upsert errors=${errors.length}, sample=${JSON.stringify(
+  //         errors.slice(0, 3),
+  //       )}`,
+  //     );
+  //   } else {
+  //     this.logger.log(
+  //       `[upsertSecondTagsFromVideos] Upserted tags=${upserted} (unique=${names.length})`,
+  //     );
+  //   }
+
+  //   return {
+  //     unique: names.length,
+  //     upserted,
+  //     skipped,
+  //     errors,
+  //   };
+  // }
+
+  private async upsertSecondTagsFromVideos_NoUniqueName(
+    normalizedVideos: Array<{ secondTags?: string[] }>,
+  ): Promise<UpsertTagsResult> {
+    const set = new Set<string>();
+
+    for (const v of normalizedVideos) {
+      const tags = v.secondTags ?? [];
+      for (const t of tags) {
+        if (typeof t !== 'string') continue;
+        const name = t.trim();
+        if (!name) continue;
+        set.add(name);
+      }
+    }
+
+    const names = Array.from(set);
+    if (!names.length)
+      return { unique: 0, upserted: 0, skipped: 0, errors: [] };
+
+    // Concurrency limit to reduce race collisions and DB pressure
+    const CONCURRENCY = 20;
+    let idx = 0;
+
+    let upserted = 0;
+    let skipped = 0;
+    const errors: Array<{ name: string; error: string }> = [];
+
+    const worker = async () => {
+      while (true) {
+        const current = idx;
+        idx += 1;
+        if (current >= names.length) return;
+
+        const name = names[current];
+
+        try {
+          // 1) check existence by name (NOT unique)
+          const exists = await this.mongo.tag.findFirst({
+            where: { name },
+            select: { id: true },
+          });
+
+          if (exists?.id) {
+            // already exists
+            continue;
+          }
+
+          // 2) create if not exists
+          await this.mongo.tag.create({
+            data: {
+              name,
+              // If your Tag schema requires seconds fields:
+              // createdAt: Math.floor(Date.now() / 1000),
+              // updatedAt: Math.floor(Date.now() / 1000),
+            },
+          });
+
+          upserted += 1;
+        } catch (e: any) {
+          // If another worker created it after our check, create may fail (duplicate on some index)
+          // We treat that as skipped (safe).
+          const msg = e?.message ?? 'Tag create failed';
+          skipped += 1;
+          errors.push({ name, error: msg });
+        }
+      }
+    };
+
+    await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
+
+    if (errors.length) {
+      this.logger.warn(
+        `[upsertSecondTagsFromVideos] errors=${errors.length}, sample=${JSON.stringify(
+          errors.slice(0, 3),
+        )}`,
+      );
+    } else {
+      this.logger.log(
+        `[upsertSecondTagsFromVideos] unique=${names.length} created=${upserted}`,
+      );
+    }
+
+    return { unique: names.length, upserted, skipped, errors };
+  }
 }

+ 3 - 4
apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.dto.ts

@@ -30,7 +30,7 @@ export class TagDto {
     description: '分类ID (Mongo ObjectId)',
     example: '6650a0c28e4ff3f4c0c00111',
   })
-  @IsMongoId()
+  @IsString()
   categoryId: string;
 
   @ApiProperty({ description: '排序 (越小越靠前)', example: 0 })
@@ -66,11 +66,11 @@ export class CreateTagDto {
   @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
   name: string;
 
-  @ApiProperty({
+  @ApiPropertyOptional({
     description: '分类ID (Mongo ObjectId)',
     example: '6650a0c28e4ff3f4c0c00111',
   })
-  @IsMongoId()
+  @IsOptional()
   categoryId: string;
 
   @ApiPropertyOptional({ description: '排序 (默认0)', example: 0 })
@@ -107,7 +107,6 @@ export class ListTagDto extends PageListDto {
 
   @ApiPropertyOptional({ description: '分类ID (ObjectId)' })
   @IsOptional()
-  @IsMongoId()
   categoryId?: string;
 
   @ApiPropertyOptional({

+ 65 - 38
apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.service.ts

@@ -46,25 +46,42 @@ export class TagService {
    * Ensure category exists.
    * NOTE: Categories are no longer tied to channels, so we only validate category existence.
    */
-  private async assertCategoryExists(categoryId: string): Promise<void> {
-    const category = await this.mongoPrismaService.category.findUnique({
+  async assertCategoryExists(categoryId?: string | null): Promise<void> {
+    if (!categoryId) {
+      // "All / no category" is valid
+      return;
+    }
+
+    // if categoryId == 'ALL', treat as no category
+    if (categoryId.toUpperCase() === 'ALL') {
+      return;
+    }
+
+    const exists = await this.mongoPrismaService.category.findUnique({
       where: { id: categoryId },
       select: { id: true },
     });
 
-    if (!category) {
-      throw new NotFoundException('Category not found');
+    if (!exists) {
+      throw new BadRequestException(
+        `Category with id "${categoryId}" does not exist`,
+      );
     }
   }
 
   async create(dto: CreateTagDto) {
-    // Validate category exists (channelId no longer required)
-    await this.assertCategoryExists(dto.categoryId);
+    const categoryId =
+      dto.categoryId.toUpperCase() === 'ALL' ? null : dto.categoryId;
+
+    // Validate category exists (only if provided)
+    if (categoryId) {
+      await this.assertCategoryExists(categoryId);
+    }
 
-    // Check for duplicate tag name within the same category
+    // Check duplicate tag name within the same category scope
     const existingTag = await this.mongoPrismaService.tag.findFirst({
       where: {
-        categoryId: dto.categoryId,
+        categoryId,
         name: dto.name,
       },
     });
@@ -80,7 +97,7 @@ export class TagService {
     const tag = await this.mongoPrismaService.tag.create({
       data: {
         name: dto.name,
-        categoryId: dto.categoryId,
+        categoryId,
         seq: dto.seq ?? 0,
         status: dto.status ?? CommonStatus.enabled,
         createAt: now,
@@ -88,18 +105,19 @@ export class TagService {
       },
     });
 
-    await this.categoryService.refreshTagsMetadata(tag.categoryId);
-    await this.scheduleCategoryCaches(tag.categoryId);
+    // Category-related cache refresh (only if categoryId exists)
+    if (categoryId) {
+      await this.categoryService.refreshTagsMetadata(categoryId);
+      await this.scheduleCategoryCaches(categoryId);
 
-    await this.categoryService.refreshTagsMetadata(tag.categoryId);
-    await this.scheduleCategoryCaches(tag.categoryId);
+      await this.cacheSyncService.scheduleAction({
+        entityType: CacheEntityType.TAG,
+        operation: CacheOperation.REFRESH,
+        payload: { categoryId },
+      });
+    }
 
-    // Schedule cache sync actions
-    await this.cacheSyncService.scheduleAction({
-      entityType: CacheEntityType.TAG,
-      operation: CacheOperation.REFRESH,
-      payload: { categoryId: tag.categoryId },
-    });
+    // Global tag cache refresh
     await this.cacheSyncService.scheduleAction({
       entityType: CacheEntityType.TAG,
       operation: CacheOperation.REFRESH_ALL,
@@ -109,10 +127,14 @@ export class TagService {
   }
 
   async update(dto: UpdateTagDto) {
-    // Validate category exists (channelId no longer required)
-    await this.assertCategoryExists(dto.categoryId);
+    const categoryId =
+      dto.categoryId.toUpperCase() === 'ALL' ? null : dto.categoryId;
+
+    // Validate category exists (only if provided)
+    if (categoryId) {
+      await this.assertCategoryExists(categoryId);
+    }
 
-    // Load existing tag to capture old categoryId in case it changed
     const existingTag = await this.mongoPrismaService.tag.findUnique({
       where: { id: dto.id },
     });
@@ -121,10 +143,10 @@ export class TagService {
       throw new NotFoundException('Tag not found');
     }
 
-    // Check for duplicate tag name within the same category (excluding current tag)
+    // Check duplicate tag name within same category scope
     const duplicateTag = await this.mongoPrismaService.tag.findFirst({
       where: {
-        categoryId: dto.categoryId,
+        categoryId,
         name: dto.name,
         id: { not: dto.id },
       },
@@ -136,19 +158,16 @@ export class TagService {
       );
     }
 
-    const oldCategoryId = existingTag.categoryId;
+    const oldCategoryId = existingTag.categoryId ?? null;
     const now = this.now();
 
-    // Build update data carefully to avoid accidentally changing fields
     const data: any = {
       name: dto.name,
-      categoryId: dto.categoryId,
+      categoryId,
       seq: dto.seq ?? 0,
       updateAt: now,
     };
 
-    // Only update `status` if it is explicitly provided.
-    // This avoids silently re-enabling disabled tags.
     if (dto.status !== undefined) {
       data.status = dto.status;
     }
@@ -158,23 +177,31 @@ export class TagService {
       data,
     });
 
-    if (oldCategoryId !== dto.categoryId) {
-      await this.categoryService.ensureTagChildren(oldCategoryId);
+    // If category changed, refresh old category caches
+    if (oldCategoryId && oldCategoryId !== categoryId) {
+      await this.categoryService.refreshTagsMetadata(oldCategoryId);
       await this.scheduleCategoryCaches(oldCategoryId);
+
       await this.cacheSyncService.scheduleAction({
         entityType: CacheEntityType.TAG,
         operation: CacheOperation.REFRESH,
         payload: { categoryId: oldCategoryId },
       });
     }
-    await this.categoryService.refreshTagsMetadata(dto.categoryId);
-    await this.scheduleCategoryCaches(dto.categoryId);
 
-    await this.cacheSyncService.scheduleAction({
-      entityType: CacheEntityType.TAG,
-      operation: CacheOperation.REFRESH,
-      payload: { categoryId: dto.categoryId },
-    });
+    // Refresh new category caches
+    if (categoryId) {
+      await this.categoryService.refreshTagsMetadata(categoryId);
+      await this.scheduleCategoryCaches(categoryId);
+
+      await this.cacheSyncService.scheduleAction({
+        entityType: CacheEntityType.TAG,
+        operation: CacheOperation.REFRESH,
+        payload: { categoryId },
+      });
+    }
+
+    // Global tag cache refresh
     await this.cacheSyncService.scheduleAction({
       entityType: CacheEntityType.TAG,
       operation: CacheOperation.REFRESH_ALL,

+ 2 - 3
prisma/mongo/schema/tag.prisma

@@ -1,6 +1,6 @@
 model Tag {
   id         String   @id @map("_id") @default(auto()) @db.ObjectId
-  name       String                               // 标签名称
+  name       String   /// @unique                     // 标签名称
 
   // Legacy field: DB is "catergoryId"; tag can exist without Category now
   categoryId String?  @map("catergoryId") @db.ObjectId
@@ -12,9 +12,8 @@ model Tag {
   createAt   BigInt   @default(0)                 // 创建时间 (秒)
   updateAt   BigInt   @default(0)                 // 更新时间 (秒)
 
-  @@index([name])
   @@index([status])
   @@index([seq])
-  @@index([categoryId])
+
   @@map("tag")
 }