Ver código fonte

Merge branch 'master' into wudi_dev

FC_DAN\c9837 3 meses atrás
pai
commit
44c58571db

+ 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);
   }
 

+ 53 - 6
apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.service.ts

@@ -41,6 +41,34 @@ export class ChannelService {
     return typeof value === 'string' ? value.trim() : value;
   }
 
+  private async resolveCategoriesByIds(ids: string[]) {
+    if (!ids.length) return [];
+
+    const categories = await this.mongoPrismaService.category.findMany({
+      where: { id: { in: ids } },
+      select: { id: true, name: true },
+    });
+
+    return categories.map((c) => ({
+      id: c.id,
+      name: c.name,
+    }));
+  }
+
+  private async resolveTagsByIds(ids: string[]) {
+    if (!ids.length) return [];
+
+    const tags = await this.mongoPrismaService.tag.findMany({
+      where: { id: { in: ids } },
+      select: { id: true, name: true },
+    });
+
+    return tags.map((t) => ({
+      id: t.id,
+      name: t.name,
+    }));
+  }
+
   async create(dto: CreateChannelDto) {
     // Check for duplicate channel name
     const existingChannel = await this.mongoPrismaService.channel.findFirst({
@@ -67,6 +95,14 @@ export class ChannelService {
     const isDefault = (await this.mongoPrismaService.channel.count()) === 0;
 
     const now = this.now();
+    const categoryIds = dto.categories?.map((c) => c.id) ?? [];
+    const tagIds = dto.tags?.map((t) => t.id) ?? [];
+
+    const [categories, tags] = await Promise.all([
+      this.resolveCategoriesByIds(categoryIds),
+      this.resolveTagsByIds(tagIds),
+    ]);
+    const tagNames = tags.map((t) => t.name.trim());
 
     const channel = await this.mongoPrismaService.channel.create({
       data: {
@@ -79,9 +115,9 @@ export class ChannelService {
         clientNotice: this.trimOptional(dto.clientNotice) ?? null,
         remark: this.trimOptional(dto.remark) ?? null,
         isDefault,
-        categories: (dto.categories as any) || null,
-        tags: (dto.tags as any) || null,
-        tagNames: dto.tagNames || [],
+        categories: categories.length ? categories : null,
+        tags: tags.length ? tags : null,
+        tagNames: tagNames,
         createAt: now,
         updateAt: now,
       },
@@ -129,6 +165,14 @@ export class ChannelService {
     }
 
     const now = this.now();
+    const categoryIds = dto.categories?.map((c) => c.id) ?? [];
+    const tagIds = dto.tags?.map((t) => t.id) ?? [];
+
+    const [categories, tags] = await Promise.all([
+      this.resolveCategoriesByIds(categoryIds),
+      this.resolveTagsByIds(tagIds),
+    ]);
+    const tagNames = tags.map((t) => t.name.trim());
 
     try {
       const channel = await this.mongoPrismaService.channel.update({
@@ -142,9 +186,12 @@ export class ChannelService {
           clientName: this.trimOptional(dto.clientName) ?? null,
           clientNotice: this.trimOptional(dto.clientNotice) ?? null,
           remark: this.trimOptional(dto.remark) ?? null,
-          categories: (dto.categories as any) || null,
-          tags: (dto.tags as any) || null,
-          tagNames: dto.tagNames || [],
+
+          // 🔑 resolved by backend
+          categories: categories.length ? categories : null,
+          tags: tags.length ? tags : null,
+
+          tagNames: tagNames,
           updateAt: now,
         },
       });

+ 172 - 133
apps/box-mgnt-api/src/mgnt-backend/feature/provider-video-sync/provider-video-sync.controller.ts

@@ -1,155 +1,194 @@
 // provider-video-sync.controller.ts
-import { Controller, Post, Get, Query, Logger } from '@nestjs/common';
-import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
-import { ProviderVideoSyncService } from './provider-video-sync.service';
+import { Body, Controller, Get, Post, Query } from '@nestjs/common';
+import {
+  ApiBody,
+  ApiOperation,
+  ApiQuery,
+  ApiResponse,
+  ApiTags,
+} from '@nestjs/swagger';
+import {
+  IsBoolean,
+  IsInt,
+  IsObject,
+  IsOptional,
+  IsString,
+  Max,
+  Min,
+  ValidateNested,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+import {
+  ProviderVideoSyncOptions,
+  ProviderVideoSyncResult,
+  ProviderVideoSyncService,
+} from './provider-video-sync.service';
+
+class ProviderVideoSyncParamDto {
+  @IsOptional()
+  @IsString()
+  status?: string;
+
+  /**
+   * ISO string; provider filters "updated after"
+   * Example: "2025-12-18T21:19:09.227Z"
+   */
+  @IsOptional()
+  @IsString()
+  updatedAt?: string;
+
+  /**
+   * Allow extra provider param keys without strict validation.
+   * If you want to lock this down later, replace with explicit fields.
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  [k: string]: any;
+}
+
+export class ProviderVideoSyncRunDto {
+  @IsOptional()
+  @IsString()
+  providerCode?: string;
+
+  @IsOptional()
+  @IsBoolean()
+  fullSync?: boolean;
+
+  @IsOptional()
+  @IsBoolean()
+  resetState?: boolean;
+
+  /**
+   * Optional override. In normal usage:
+   * - fullSync: resumes from SyncState cursor
+   * - incremental: pageNum is forced to 1
+   */
+  @IsOptional()
+  @IsInt()
+  @Min(1)
+  pageNum?: number;
+
+  /**
+   * Default is handled by service; capped at 500 by service.
+   */
+  @IsOptional()
+  @IsInt()
+  @Min(1)
+  @Max(500)
+  pageSize?: number;
+
+  @IsOptional()
+  @IsObject()
+  @ValidateNested()
+  @Type(() => ProviderVideoSyncParamDto)
+  param?: ProviderVideoSyncParamDto;
+}
 
 @ApiTags('Provider Video Sync')
 @Controller('provider-video-sync')
 export class ProviderVideoSyncController {
-  private readonly logger = new Logger(ProviderVideoSyncController.name);
-
-  constructor(
-    private readonly providerVideoSyncService: ProviderVideoSyncService,
-  ) {}
+  constructor(private readonly service: ProviderVideoSyncService) {}
 
   @Post('run')
   @ApiOperation({
-    summary: 'Trigger provider video sync',
-    description:
-      'Triggers a sync of video media data from configured providers. Currently processes a single page of data.',
+    summary: 'Trigger provider video sync (full sync or incremental)',
+    description: [
+      'POST body supports optional parameters: providerCode, fullSync, resetState, pageNum, pageSize, param.',
+      'Incremental sync: send param.updatedAt (ISO string).',
+      'Full sync: set fullSync=true; service ignores param.updatedAt and resumes by pageNum cursor.',
+    ].join('\n'),
   })
-  @ApiQuery({
-    name: 'providerCode',
-    required: false,
-    type: 'string',
-    description: 'Filter by specific provider code (e.g., RVAKC)',
+  @ApiBody({ type: ProviderVideoSyncRunDto })
+  @ApiResponse({ status: 200, type: Object })
+  async run(
+    @Body() dto: ProviderVideoSyncRunDto,
+  ): Promise<ProviderVideoSyncResult> {
+    const options: ProviderVideoSyncOptions = {
+      providerCode: dto.providerCode,
+      fullSync: dto.fullSync,
+      resetState: dto.resetState,
+      pageNum: dto.pageNum,
+      pageSize: dto.pageSize,
+      param: dto.param,
+    };
+
+    return this.service.syncFromProvider(options);
+  }
+
+  @Get('last-summary')
+  @ApiOperation({
+    summary: 'Get last sync summary from memory (this process only)',
   })
-  @ApiQuery({
-    name: 'page',
-    required: false,
-    type: 'number',
-    description: 'Page number (default: 1)',
+  @ApiResponse({ status: 200, type: Object })
+  getLastSummary(): ProviderVideoSyncResult | null {
+    return this.service.getLastSyncSummary();
+  }
+
+  @Post('run-incremental')
+  @ApiOperation({
+    summary: 'Trigger incremental sync quickly (query params)',
+    description:
+      'Convenience endpoint. Use /run for full control. Incremental uses param.updatedAt.',
   })
   @ApiQuery({
-    name: 'pageSize',
+    name: 'updatedAt',
     required: false,
-    type: 'number',
-    description: 'Number of records per page (default: 100)',
+    description: 'ISO string updatedAt cursor',
   })
-  @ApiResponse({
-    status: 200,
-    description: 'Provider video sync triggered successfully',
-    schema: {
-      type: 'object',
-      properties: {
-        imported: {
-          type: 'number',
-          description: 'Total number of videos processed',
-          example: 50,
-        },
-        created: {
-          type: 'number',
-          description: 'Number of new ProviderVideoSync records created',
-          example: 10,
-        },
-        updated: {
-          type: 'number',
-          description: 'Number of existing ProviderVideoSync records updated',
-          example: 35,
-        },
-        skipped: {
-          type: 'number',
-          description: 'Number of records skipped due to errors',
-          example: 5,
-        },
-        errors: {
-          type: 'array',
-          description: 'Array of errors encountered (if any)',
-          items: {
-            type: 'object',
-            properties: {
-              id: { type: 'string', example: '6650a9e5f9c3f12a8b000001' },
-              error: {
-                type: 'string',
-                example: 'Sync failed: provider timeout',
-              },
-            },
-          },
-        },
-      },
-    },
-  })
-  async runSync(
-    @Query('providerCode') providerCode?: string,
-    @Query('page') page?: string,
+  @ApiQuery({ name: 'pageSize', required: false, description: '1..500' })
+  @ApiQuery({ name: 'providerCode', required: false })
+  @ApiQuery({ name: 'resetState', required: false, description: 'true/false' })
+  async runIncremental(
+    @Query('updatedAt') updatedAt?: string,
     @Query('pageSize') pageSize?: string,
-  ) {
-    this.logger.log('[runSync] Trigger received');
-    this.logger.debug('[runSync] Query params:', {
-      providerCode,
-      page,
-      pageSize,
-    });
-
-    const result = await this.providerVideoSyncService.syncFromProvider({
-      providerCode,
-      page: page ? parseInt(page, 10) : 1,
-      pageSize: pageSize ? parseInt(pageSize, 10) : 100,
-    });
-
-    this.logger.log('[runSync] Sync completed', result);
-    return result;
+    @Query('providerCode') providerCode?: string,
+    @Query('resetState') resetState?: string,
+  ): Promise<ProviderVideoSyncResult> {
+    const options: ProviderVideoSyncOptions = {
+      providerCode: providerCode || undefined,
+      fullSync: false,
+      resetState: resetState === 'true',
+      pageSize: pageSize ? Number(pageSize) : undefined,
+      param: {
+        status: 'Completed',
+        updatedAt: updatedAt || undefined,
+      },
+    };
+
+    return this.service.syncFromProvider(options);
   }
 
-  @Get('status')
+  @Post('run-full')
   @ApiOperation({
-    summary: 'Get last provider video sync status',
-    description: 'Returns a summary of the last provider video sync operation.',
+    summary: 'Trigger full sync quickly (query params)',
+    description:
+      'Convenience endpoint. Full sync ignores updatedAt filter and resumes by stored pageNum cursor.',
   })
-  @ApiResponse({
-    status: 200,
-    description: 'Last sync status retrieved successfully',
-    schema: {
-      type: 'object',
-      nullable: true,
-      properties: {
-        imported: {
-          type: 'number',
-          description: 'Total number of videos processed',
-          example: 50,
-        },
-        created: {
-          type: 'number',
-          description: 'Number of new ProviderVideoSync records created',
-          example: 10,
-        },
-        updated: {
-          type: 'number',
-          description: 'Number of existing ProviderVideoSync records updated',
-          example: 35,
-        },
-        skipped: {
-          type: 'number',
-          description: 'Number of records skipped due to errors',
-          example: 5,
-        },
-        errors: {
-          type: 'array',
-          description: 'Array of errors encountered (if any)',
-          items: {
-            type: 'object',
-            properties: {
-              id: { type: 'string' },
-              error: { type: 'string' },
-            },
-          },
-        },
-      },
-    },
+  @ApiQuery({ name: 'pageSize', required: false, description: '1..500' })
+  @ApiQuery({ name: 'providerCode', required: false })
+  @ApiQuery({ name: 'resetState', required: false, description: 'true/false' })
+  @ApiQuery({
+    name: 'pageNum',
+    required: false,
+    description: 'override start pageNum (resetState recommended)',
   })
-  async getStatus() {
-    this.logger.log('[getStatus] Status requested');
-    const status = this.providerVideoSyncService.getLastSyncSummary();
-    return status || { message: 'No sync has been performed yet' };
+  async runFull(
+    @Query('pageSize') pageSize?: string,
+    @Query('providerCode') providerCode?: string,
+    @Query('resetState') resetState?: string,
+    @Query('pageNum') pageNum?: string,
+  ): Promise<ProviderVideoSyncResult> {
+    const options: ProviderVideoSyncOptions = {
+      providerCode: providerCode || undefined,
+      fullSync: true,
+      resetState: resetState === 'true',
+      pageSize: pageSize ? Number(pageSize) : undefined,
+      pageNum: pageNum ? Number(pageNum) : undefined,
+      param: {
+        status: 'Completed',
+      },
+    };
+
+    return this.service.syncFromProvider(options);
   }
 }

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

@@ -3,18 +3,55 @@ import { Injectable, Logger } from '@nestjs/common';
 import { HttpService } from '@nestjs/axios';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import { firstValueFrom } from 'rxjs';
+import { EntityType } from '@prisma/mongo/client';
 
 export interface ProviderVideoSyncOptions {
   providerCode?: string;
-  page?: number;
+
+  /**
+   * Optional override. In normal usage, we resume from SyncState cursor:
+   * - fullSync: pageNum resumes
+   * - incremental: always pageNum=1
+   */
+  pageNum?: number;
+
+  /**
+   * Default 200, hard-capped to 500.
+   */
   pageSize?: number;
+
+  /**
+   * Provider search param.
+   * - status: required in your business rule ("Completed")
+   * - updatedAt: ISO string filter "updated after"
+   */
+  param?: {
+    status?: string;
+    updatedAt?: string;
+    [k: string]: any;
+  };
+
+  /**
+   * fullSync:
+   * - true: no param.updatedAt; resume using stored pageNum
+   * - false: use param.updatedAt (cursor); pageNum forced to 1
+   */
+  fullSync?: boolean;
+
+  /**
+   * If true: ignore stored cursor and start fresh.
+   * - fullSync: pageNum from options.pageNum or 1
+   * - incremental: updatedAtCursor from options.param.updatedAt (if provided)
+   */
+  resetState?: boolean;
+
   [key: string]: any;
 }
 
 export interface ProviderVideoSyncResult {
   imported: number;
-  created: number;
-  updated: number;
+  created: number; // Option B: always 0
+  updated: number; // Option B: equals successful upserts
   skipped: number;
   errors?: Array<{ id?: string; error: string }>;
 }
@@ -71,6 +108,23 @@ interface RawProviderVideo {
   infoTsName?: string;
 }
 
+type SyncCursor = {
+  pageNum: number;
+  pageSize: number;
+  updatedAtCursor?: string;
+};
+
+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);
@@ -80,218 +134,287 @@ export class ProviderVideoSyncService {
   private readonly PROVIDER_API_URL =
     'https://vm.rvakc.xyz/api/web/mediafile/search';
 
+  private readonly DEFAULT_PROVIDER_CODE = 'RVAKC';
+  private readonly MAX_PAGE_SIZE = 500;
+  private readonly DEFAULT_PAGE_SIZE = 200;
+  private readonly BATCH_SIZE = 100;
+
   constructor(
     private readonly mongo: MongoPrismaService,
     private readonly httpService: HttpService,
   ) {}
 
-  /**
-   * Sync video media from provider(s).
-   * Fetches data from external provider API, normalizes it, and upserts to MongoDB.
-   *
-   * @param options Configuration options (providerCode, page, pageSize, etc.)
-   * @returns Sync result with counts and errors
-   */
   async syncFromProvider(
     options: ProviderVideoSyncOptions = {},
   ): Promise<ProviderVideoSyncResult> {
-    this.logger.log('[syncFromProvider] Starting provider video sync');
-    this.logger.debug('[syncFromProvider] Options:', options);
+    const providerCode = options.providerCode ?? this.DEFAULT_PROVIDER_CODE;
 
-    const { providerCode = 'RVAKC', page = 1, pageSize = 100 } = options;
+    const requestedPageSize = options.pageSize ?? this.DEFAULT_PAGE_SIZE;
+    const pageSize = this.clampInt(requestedPageSize, 1, this.MAX_PAGE_SIZE);
 
-    try {
-      // Fetch data from provider API
-      this.logger.log(
-        `[syncFromProvider] Fetching from provider API: ${this.PROVIDER_API_URL}`,
-      );
+    const fullSync = options.fullSync ?? false;
+    const resetState = options.resetState ?? false;
 
-      let rawList: RawProviderVideo[] = [];
+    const paramStatus = options.param?.status ?? 'Completed';
+    const optionUpdatedAt = options.param?.updatedAt;
 
-      try {
-        const response = await firstValueFrom(
-          this.httpService.get(this.PROVIDER_API_URL, {
-            params: {
-              page,
-              pageSize,
-              providerCode,
-            },
-            timeout: 30000,
-          }),
-        );
+    // Only one entity exists in your enum now
+    const entity = EntityType.VIDEO;
 
-        rawList = this.extractList(response.data);
-        this.logger.log(
-          `[syncFromProvider] Extracted ${rawList.length} items from provider API`,
-        );
-      } catch (error: any) {
-        this.logger.error(
-          `[syncFromProvider] Provider API call failed: ${error.message}`,
-        );
-        const result: ProviderVideoSyncResult = {
-          imported: 0,
-          created: 0,
-          updated: 0,
-          skipped: 0,
-          errors: [{ error: `Provider API error: ${error.message}` }],
-        };
-        this.lastSyncSummary = result;
-        return result;
-      }
+    this.logger.log(
+      `[syncFromProvider] Start provider=${providerCode} entity=${entity} fullSync=${fullSync} pageSize=${pageSize} resetState=${resetState}`,
+    );
 
-      if (!rawList.length) {
-        this.logger.log('[syncFromProvider] No videos from provider to sync');
-        const result: ProviderVideoSyncResult = {
-          imported: 0,
-          created: 0,
-          updated: 0,
-          skipped: 0,
-        };
-        this.lastSyncSummary = result;
-        return result;
-      }
+    // Load cursor from SyncState (or fresh if resetState)
+    const { cursor: initialCursor } = await this.loadCursor({
+      entity,
+      pageSize,
+      resetState,
+      overridePageNum: options.pageNum,
+      optionUpdatedAt,
+      fullSync,
+    });
+
+    // Counters (Option B: created always 0, updated counts successful upserts)
+    let imported = 0;
+    let updated = 0;
+    let skipped = 0;
+    const created = 0;
+    const errors: Array<{ id?: string; error: string }> = [];
+
+    // Track max updatedAt seen (for incremental cursor advancement)
+    let maxUpdatedAtSeen: Date | null = null;
+
+    // Full sync resumes with pageNum; incremental always starts at 1
+    let pageNum = fullSync ? initialCursor.pageNum : 1;
+
+    // Keep a working cursor that we will persist as we go
+    const cursor: SyncCursor = {
+      pageNum,
+      pageSize: initialCursor.pageSize,
+      updatedAtCursor: initialCursor.updatedAtCursor,
+    };
 
-      // Normalize items
-      let normalized: any[] = [];
+    try {
+      while (true) {
+        const body = this.buildProviderBody({
+          pageNum,
+          pageSize: cursor.pageSize,
+          status: paramStatus,
+          // fullSync: no updatedAt filter
+          updatedAt: fullSync
+            ? undefined
+            : (cursor.updatedAtCursor ?? optionUpdatedAt),
+          extraParam: options.param,
+        });
 
-      try {
-        normalized = rawList.map((item) => this.normalizeItem(item));
         this.logger.log(
-          `[syncFromProvider] Ready to import ${normalized.length} records`,
+          `[syncFromProvider] POST pageNum=${pageNum} pageSize=${cursor.pageSize} status=${paramStatus} updatedAt=${fullSync ? '(none)' : (body.param.updatedAt ?? '(none)')}`,
         );
-        this.logger.debug(
-          '[syncFromProvider] First record sample:',
-          normalized[0],
-        );
-      } catch (error: any) {
-        this.logger.error(
-          `[syncFromProvider] Normalization failed: ${error.message}`,
-        );
-        const result: ProviderVideoSyncResult = {
-          imported: rawList.length,
-          created: 0,
-          updated: 0,
-          skipped: rawList.length,
-          errors: [{ error: `Normalization error: ${error.message}` }],
-        };
-        this.lastSyncSummary = result;
-        return result;
-      }
 
-      // Batch processing - try to create each record individually and catch duplicate errors
-      const BATCH_SIZE = 100;
-      let created = 0;
-      let updated = 0;
-      let skipped = 0;
-      const errors: Array<{ id?: string; error: string }> = [];
-
-      for (let i = 0; i < normalized.length; i += BATCH_SIZE) {
-        const batch = normalized.slice(i, i + BATCH_SIZE);
-        this.logger.debug(
-          `[syncFromProvider] Processing batch ${i / BATCH_SIZE + 1}, size: ${batch.length}`,
+        const rawList = await this.fetchPage(body);
+        if (!rawList.length) {
+          this.logger.log(
+            `[syncFromProvider] No more records (pageNum=${pageNum}). Stop.`,
+          );
+
+          // On completion:
+          // - fullSync: reset pageNum to 1 and set lastFullSyncAt
+          // - incremental: advance updatedAtCursor to maxUpdatedAtSeen, keep pageNum=1
+          const fullSyncCompleted = fullSync;
+
+          if (fullSync) {
+            cursor.pageNum = 1;
+            // Optional visibility: store last seen updatedAt as updatedAtCursor too
+            if (maxUpdatedAtSeen)
+              cursor.updatedAtCursor = maxUpdatedAtSeen.toISOString();
+          } else {
+            cursor.pageNum = 1;
+            if (maxUpdatedAtSeen)
+              cursor.updatedAtCursor = maxUpdatedAtSeen.toISOString();
+          }
+
+          await this.saveCursor({
+            entity,
+            cursor,
+            fullSyncCompleted,
+          });
+
+          const result: ProviderVideoSyncResult = {
+            imported,
+            created,
+            updated,
+            skipped,
+            errors: errors.length ? errors.slice(0, 10) : undefined,
+          };
+          this.lastSyncSummary = result;
+          return result;
+        }
+
+        imported += rawList.length;
+
+        const normalized = rawList.map((item) => this.normalizeItem(item));
+
+        const hasSecondTags = normalized.some(
+          (v) => Array.isArray(v.secondTags) && v.secondTags.length > 0,
         );
 
-        // eslint-disable-next-line no-await-in-loop
-        await Promise.all(
-          batch.map(async (record) => {
-            try {
-              // Try to create the record
-              await this.mongo.videoMedia.create({ data: record });
-              // eslint-disable-next-line no-plusplus
-              created++;
-              this.logger.debug(
-                `[syncFromProvider] Created record: ${record.id}`,
-              );
-            } catch (error: any) {
-              this.logger.debug(
-                `[syncFromProvider] Create failed for ${record.id}: ${error.code} ${error.message?.substring(0, 100)}`,
-              );
-              // If duplicate key error (code 11000), try to update
-              if (
-                error.code === 11000 ||
-                error.message?.includes('duplicate')
-              ) {
-                try {
-                  const { id, ...updateData } = record;
-                  await this.mongo.videoMedia.update({
-                    where: { id },
-                    data: updateData,
-                  });
-                  // eslint-disable-next-line no-plusplus
-                  updated++;
-                  this.logger.debug(`[syncFromProvider] Updated record: ${id}`);
-                } catch (updateError: any) {
-                  this.logger.error(
-                    `[syncFromProvider] Update failed for ${record.id}: ${updateError.message}`,
-                  );
-                  // eslint-disable-next-line no-plusplus
-                  skipped++;
-                  errors.push({ id: record.id, error: updateError.message });
-                }
-              } else {
-                this.logger.error(
-                  `[syncFromProvider] Skipped ${record.id}: ${error.message}`,
-                );
-                // eslint-disable-next-line no-plusplus
-                skipped++;
-                errors.push({ id: record.id, error: error.message });
-              }
-            }
-          }),
+        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;
+          if (!maxUpdatedAtSeen || d.getTime() > maxUpdatedAtSeen.getTime()) {
+            maxUpdatedAtSeen = d;
+          }
+        }
+
+        // Upsert in batches (Option B)
+        for (let i = 0; i < normalized.length; i += this.BATCH_SIZE) {
+          const batch = normalized.slice(i, i + this.BATCH_SIZE);
+
+          // eslint-disable-next-line no-await-in-loop
+          const outcomes = await Promise.all(
+            batch.map((r) => this.upsertOne(r)),
+          );
+
+          const okCount = outcomes.filter((o) => o.ok).length;
+          const fail = outcomes.filter((o) => !o.ok) as Array<
+            Extract<UpsertOutcome, { ok: false }>
+          >;
+
+          updated += okCount;
+          skipped += fail.length;
+          for (const f of fail) errors.push(f.error);
+        }
+
+        // Persist progress so we can resume on crash
+        if (fullSync) {
+          cursor.pageNum = pageNum + 1;
+          // Optional: keep moving max updatedAt for visibility
+          if (maxUpdatedAtSeen)
+            cursor.updatedAtCursor = maxUpdatedAtSeen.toISOString();
+        } else {
+          // incremental resumes by updatedAtCursor, so keep pageNum=1
+          cursor.pageNum = 1;
+          if (maxUpdatedAtSeen)
+            cursor.updatedAtCursor = maxUpdatedAtSeen.toISOString();
+        }
+
+        await this.saveCursor({
+          entity,
+          cursor,
+          fullSyncCompleted: false,
+        });
+
+        pageNum += 1;
+      }
+    } catch (e: any) {
+      this.logger.error(
+        `[syncFromProvider] Unexpected error: ${e?.message ?? e}`,
+      );
+
+      // Best-effort cursor persistence
+      try {
+        if (!fullSync && maxUpdatedAtSeen) {
+          cursor.pageNum = 1;
+          cursor.updatedAtCursor = maxUpdatedAtSeen.toISOString();
+        }
+        await this.saveCursor({
+          entity,
+          cursor,
+          fullSyncCompleted: false,
+        });
+      } catch (saveErr: any) {
+        this.logger.error(
+          `[syncFromProvider] Failed to persist cursor after error: ${saveErr?.message ?? saveErr}`,
         );
       }
 
       const result: ProviderVideoSyncResult = {
-        imported: normalized.length,
+        imported,
         created,
         updated,
         skipped,
-        errors: errors.length > 0 ? errors.slice(0, 10) : undefined,
-      };
-
-      this.logger.log(
-        `[syncFromProvider] Sync complete: ${created} created, ${updated} updated, ${skipped} skipped`,
-      );
-      if (errors.length > 0) {
-        this.logger.log('[syncFromProvider] Errors:', errors.slice(0, 5));
-      }
-
-      this.lastSyncSummary = result;
-      return result;
-    } catch (error: any) {
-      this.logger.error(
-        `[syncFromProvider] Unexpected error: ${error.message}`,
-      );
-      const result: ProviderVideoSyncResult = {
-        imported: 0,
-        created: 0,
-        updated: 0,
-        skipped: 0,
-        errors: [{ error: error.message || 'Unexpected error' }],
+        errors: [
+          ...(errors.length ? errors.slice(0, 9) : []),
+          { error: e?.message ?? 'Unexpected error' },
+        ],
       };
       this.lastSyncSummary = result;
       return result;
     }
   }
 
-  /**
-   * Get the last sync summary.
-   * Returns null if no sync has been performed yet.
-   */
   getLastSyncSummary(): ProviderVideoSyncResult | null {
     return this.lastSyncSummary;
   }
 
-  /**
-   * Extracts the list of items from different possible API response shapes.
-   * Supports: { list: [...] }, { data: { list: [...] } }, or direct array
-   */
+  private buildProviderBody(args: {
+    pageNum: number;
+    pageSize: number;
+    status: string;
+    updatedAt?: string;
+    extraParam?: ProviderVideoSyncOptions['param'];
+  }) {
+    // Provider contract:
+    // {
+    //   pageNum: 1,
+    //   pageSize: 200,
+    //   param: { status: "Completed", updatedAt: "ISO" }
+    // }
+    const param: Record<string, any> = {
+      status: args.status,
+    };
+
+    // Keep only if present (incremental)
+    if (args.updatedAt) param.updatedAt = args.updatedAt;
+
+    // Merge any extraParam fields, but status/updatedAt above remain authoritative
+    if (args.extraParam && typeof args.extraParam === 'object') {
+      for (const [k, v] of Object.entries(args.extraParam)) {
+        if (k === 'status' || k === 'updatedAt') continue;
+        param[k] = v;
+      }
+    }
+
+    return {
+      pageNum: args.pageNum,
+      pageSize: args.pageSize,
+      param,
+    };
+  }
+
+  private async fetchPage(body: {
+    pageNum: number;
+    pageSize: number;
+    param: Record<string, any>;
+  }): Promise<RawProviderVideo[]> {
+    try {
+      const response = await firstValueFrom(
+        this.httpService.post(this.PROVIDER_API_URL, body, {
+          headers: { 'Content-Type': 'application/json' },
+          timeout: 30000,
+        }),
+      );
+
+      const list = this.extractList(response.data);
+      this.logger.log(`[fetchPage] Received ${list.length} items`);
+      return list;
+    } catch (error: any) {
+      this.logger.error(
+        `[fetchPage] Provider API call failed: ${error?.message ?? error}`,
+      );
+      throw new Error(`Provider API error: ${error?.message ?? 'unknown'}`);
+    }
+  }
+
   private extractList(apiResponse: unknown): RawProviderVideo[] {
     const data = apiResponse as any;
 
-    if (Array.isArray(data)) {
-      return data as RawProviderVideo[];
-    }
+    if (Array.isArray(data)) return data as RawProviderVideo[];
 
     if (data?.data?.list && Array.isArray(data.data.list)) {
       return data.data.list as RawProviderVideo[];
@@ -301,21 +424,18 @@ export class ProviderVideoSyncService {
       return data.list as RawProviderVideo[];
     }
 
+    if (data?.data?.records && Array.isArray(data.data.records)) {
+      return data.data.records as RawProviderVideo[];
+    }
+
     this.logger.warn(
       '[extractList] Unexpected API response structure, defaulting to empty list',
     );
     return [];
   }
 
-  /**
-   * Maps RawProviderVideo to Prisma videoMedia create/update input.
-   * Applies defaults and type conversions to match the Prisma model.
-   */
   private normalizeItem(item: RawProviderVideo) {
-    // Basic validation
-    if (!item.id) {
-      throw new Error('Each item must have an id');
-    }
+    if (!item.id) throw new Error('Each item must have an id');
     if (!item.addedTime || !item.createdAt || !item.updatedAt) {
       throw new Error(`Item ${item.id} is missing required datetime fields`);
     }
@@ -333,7 +453,7 @@ export class ProviderVideoSyncService {
     }
 
     return {
-      id: item.id, // String mapped to Mongo ObjectId via @db.ObjectId
+      id: item.id, // confirmed Mongo ObjectId string
 
       srcId: item.srcId ?? 0,
       title: item.title ?? '',
@@ -353,30 +473,21 @@ export class ProviderVideoSyncService {
       country: item.country ?? '',
       firstTag: item.firstTag ?? '',
 
-      // null → []
       secondTags: item.secondTags ?? [],
-
-      // null → ""
       mediaSet: item.mediaSet ?? '',
-
       preFileName: item.preFileName ?? '',
 
       status: item.status ?? '',
       desc: item.desc ?? '',
 
-      // number → BigInt
       size: BigInt(item.size ?? 0),
 
       bango: item.bango ?? '',
-
-      // null → []
       actors: item.actors ?? [],
-
       studio: item.studio ?? '',
 
       addedTime,
       appids: item.appids ?? [],
-
       japanNames: item.japanNames ?? [],
 
       filename: item.filename ?? '',
@@ -410,4 +521,330 @@ export class ProviderVideoSyncService {
       updatedAt,
     };
   }
+
+  private async upsertOne(record: any): Promise<UpsertOutcome> {
+    const id = record?.id as string | undefined;
+    if (!id) return { ok: false, error: { error: 'Missing id' } };
+
+    try {
+      const { id: _, ...updateData } = record;
+
+      await this.mongo.videoMedia.upsert({
+        where: { id },
+        create: record,
+        update: updateData,
+      });
+
+      return { ok: true };
+    } catch (e: any) {
+      return {
+        ok: false,
+        error: { id, error: e?.message ?? 'Upsert failed' },
+      };
+    }
+  }
+
+  private async loadCursor(args: {
+    entity: EntityType;
+    pageSize: number;
+    resetState: boolean;
+    overridePageNum?: number;
+    optionUpdatedAt?: string;
+    fullSync: boolean;
+  }): Promise<{ cursor: SyncCursor; hasState: boolean }> {
+    const nowSec = Math.floor(Date.now() / 1000);
+
+    if (args.resetState) {
+      return {
+        cursor: {
+          pageNum: args.overridePageNum ?? 1,
+          pageSize: args.pageSize,
+          updatedAtCursor: args.fullSync ? undefined : args.optionUpdatedAt,
+        },
+        hasState: false,
+      };
+    }
+
+    const state = await this.mongo.syncState.upsert({
+      where: { entity: args.entity },
+      update: {
+        updatedAt: nowSec,
+      },
+      create: {
+        entity: args.entity,
+        referId: null,
+        lastRunAt: null,
+        lastFullSyncAt: null,
+        createdAt: nowSec,
+        updatedAt: nowSec,
+      },
+    });
+
+    const parsed = this.safeParseCursor(state.referId);
+
+    const cursor: SyncCursor = {
+      pageNum: args.overridePageNum ?? parsed?.pageNum ?? 1,
+      pageSize: args.pageSize,
+      updatedAtCursor: args.fullSync
+        ? undefined
+        : (parsed?.updatedAtCursor ?? args.optionUpdatedAt),
+    };
+
+    return { cursor, hasState: Boolean(state.referId) };
+  }
+
+  private async saveCursor(args: {
+    entity: EntityType;
+    cursor: SyncCursor;
+    fullSyncCompleted: boolean;
+  }) {
+    const now = new Date();
+    const nowSec = Math.floor(Date.now() / 1000);
+
+    await this.mongo.syncState.update({
+      where: { entity: args.entity },
+      data: {
+        referId: JSON.stringify(args.cursor),
+        lastRunAt: now,
+        lastFullSyncAt: args.fullSyncCompleted ? now : undefined,
+        updatedAt: nowSec,
+      },
+    });
+  }
+
+  private async saveCheckpoint(args: {
+    entity: EntityType;
+    nextUpdatedAtCursor?: string; // ONLY when batch completed
+    fullSyncCompleted: boolean;
+  }) {
+    const now = new Date();
+    const nowSec = Math.floor(Date.now() / 1000);
+
+    // Build referId safely (do not overwrite blindly)
+    const state = await this.mongo.syncState.findUnique({
+      where: { entity: args.entity },
+      select: { referId: true },
+    });
+
+    let persisted: { updatedAtCursor?: string } = {};
+    if (state?.referId) {
+      try {
+        persisted = JSON.parse(state.referId);
+      } catch {
+        persisted = {};
+      }
+    }
+
+    // Only commit updatedAtCursor when explicitly provided
+    if (args.nextUpdatedAtCursor) {
+      persisted.updatedAtCursor = args.nextUpdatedAtCursor;
+    }
+
+    await this.mongo.syncState.update({
+      where: { entity: args.entity },
+      data: {
+        referId: JSON.stringify(persisted),
+        lastRunAt: now,
+        lastFullSyncAt: args.fullSyncCompleted ? now : undefined,
+        updatedAt: nowSec,
+      },
+    });
+  }
+
+  private safeParseCursor(
+    raw: string | null | undefined,
+  ): Partial<SyncCursor> | null {
+    if (!raw) return null;
+    try {
+      const parsed = JSON.parse(raw) as any;
+      if (!parsed || typeof parsed !== 'object') return null;
+      return parsed as Partial<SyncCursor>;
+    } catch {
+      return null;
+    }
+  }
+
+  private clampInt(n: number, min: number, max: number): number {
+    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 };
+  }
 }

+ 14 - 1
apps/box-mgnt-api/src/mgnt-backend/feature/s3/s3.service.ts

@@ -63,6 +63,7 @@ export class S3Service {
     const key = `${folder}/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
 
     const maxSize = this.resolveMaxSize(contentType);
+    this.logger.log(`maxSize for upload: ${maxSize} bytes`);
 
     const { url, fields } = await createPresignedPost(this.s3, {
       Bucket: this.bucket,
@@ -81,13 +82,25 @@ export class S3Service {
   }
 
   private resolveMaxSize(contentType: string): number {
+    this.logger.log(`Resolving max size for content type: ${contentType}`);
+    const BASE64_OVERHEAD_RATIO = 4 / 3; // ≈1.333
+    const SAFETY_MARGIN = 1.05; // extra 5% for headers / future changes
+
     const fromEnvMB = (env: string, fallbackMB: number) => {
       const val = Number(process.env[env]);
       return (isNaN(val) ? fallbackMB : val) * 1024 * 1024;
     };
 
+    // ✅ your image payload is base64 text
+    if (contentType === 'text/plain') {
+      const rawMb = Number(process.env.UPLOAD_LIMIT_IMAGE ?? 10);
+      const effectiveMb = rawMb * (4 / 3) * 1.05;
+      return Math.ceil(effectiveMb * 1024 * 1024);
+    }
     if (contentType.startsWith('image/')) {
-      return fromEnvMB('UPLOAD_LIMIT_IMAGE', 20);
+      const rawMb = Number(process.env.UPLOAD_LIMIT_IMAGE ?? 10);
+      const effectiveMb = rawMb * BASE64_OVERHEAD_RATIO * SAFETY_MARGIN;
+      return Math.ceil(effectiveMb * 1024 * 1024);
     }
     if (contentType === 'video/mp4') {
       return fromEnvMB('UPLOAD_LIMIT_VIDEO', 100);

+ 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,

+ 12 - 0
prisma/mongo/schema/category-tag.prisma

@@ -0,0 +1,12 @@
+model CategoryTag {
+  id         String @id @map("_id") @default(auto()) @db.ObjectId
+
+  categoryId String @db.ObjectId
+  tagId      String @db.ObjectId
+
+  @@unique([categoryId, tagId])
+  @@index([categoryId])
+  @@index([tagId])
+
+  @@map("categoryTag")
+}

+ 16 - 12
prisma/mongo/schema/category.prisma

@@ -1,18 +1,22 @@
 model Category {
-  id          String     @id @map("_id") @default(auto()) @db.ObjectId
-  name        String                          // 分类名称
-  subtitle    String?                         // 副标题
-  seq         Int        @default(0)         // 排序
-  status      Int                              // 状态 0: 禁用; 1: 启用
+  id        String   @id @map("_id") @default(auto()) @db.ObjectId
+  name      String                               // 分类名称
+  subtitle  String?                              // 副标题
+  seq       Int      @default(0)                 // 排序
+  status    Int      @default(1)                 // 状态 0: 禁用; 1: 启用
 
-  createAt    BigInt     @default(0)          // 创建时间
-  updateAt    BigInt     @default(0)          // 更新时间
+  // epoch seconds stored as BigInt
+  createAt  BigInt   @default(0)                 // 创建时间 (秒)
+  updateAt  BigInt   @default(0)                 // 更新时间 (秒)
 
-  // Relations - storing tag references as objects (id + name)
-  tags        Json?                           // Array of { id, name }
-  
-  // Tag names only for search optimization
-  tagNames    String[]                        // Array of tag names
+  // Legacy/optional: denormalized tag refs (to be discussed)
+  tags      Json?                                // Array of { id, name }
 
+  // Search optimization / browsing helper (keep)
+  tagNames  String[]                             // Array of tag names
+
+  @@index([status])
+  @@index([seq])
+  @@index([tagNames])
   @@map("category")
 }

+ 22 - 21
prisma/mongo/schema/channel.prisma

@@ -1,27 +1,28 @@
 model Channel {
-  id            String    @id @map("_id") @default(auto()) @db.ObjectId
-  channelId     String    @unique
-  name          String                          // 渠道名称
-  landingUrl    String                          // 最新网址
-  videoCdn      String?                         // 视频CDN
-  coverCdn      String?                         // 封面CDN
-  clientName    String?                         // 客户端名称
-  clientNotice  String?                         // 客户端公告
-  remark        String?                         // 备注
-  isDefault     Boolean   @default(false)       // 默认渠道
+  id           String   @id @map("_id") @default(auto()) @db.ObjectId
+  channelId    String   @unique
+  name         String                             // 渠道名称
+  landingUrl   String                             // 最新网址
+  videoCdn     String?                            // 视频CDN
+  coverCdn     String?                            // 封面CDN
+  clientName   String?                            // 客户端名称
+  clientNotice String?                            // 客户端公告
+  remark       String?                            // 备注
+  isDefault    Boolean  @default(false)            // 默认渠道
 
-  // epoch (recommended: ms) stored as BigInt
-  createAt      BigInt     @default(0)          // 创建时间
-  updateAt      BigInt     @default(0)          // 更新时间
+  // epoch seconds stored as BigInt
+  createAt     BigInt   @default(0)               // 创建时间 (秒)
+  updateAt     BigInt   @default(0)               // 更新时间 (秒)
 
-  // Relations - storing category references as objects (id + name)
-  categories    Json?                           // Array of { id, name }
-  
-  // Relations - storing tag references as objects (id + name)
-  tags          Json?                           // Array of { id, name }
-  
-  // Tag names only for search optimization
-  tagNames      String[]                        // Array of tag names
+  // Channel enabled categories (UI: 片库分类 - category selection)
+  categories   Json?                              // Array of { id, name }
 
+  // Homepage recommended tags (UI: 首页推荐)
+  tags         Json?                              // Array of { id, name }
+
+  // Denormalized names derived from tags[].name (trimmed) for quick query/display
+  tagNames     String[]                           // Array of tag names
+
+  @@index([isDefault])
   @@map("channel")
 }

+ 12 - 8
prisma/mongo/schema/tag.prisma

@@ -1,15 +1,19 @@
 model Tag {
-  id          String     @id @map("_id") @default(auto()) @db.ObjectId
-  name        String                        // 标签名称
+  id         String   @id @map("_id") @default(auto()) @db.ObjectId
+  name       String   /// @unique                     // 标签名称
 
-  // DB field is "catergoryId", but we use "categoryId" in code
-  categoryId  String     @map("catergoryId") @db.ObjectId  // 分类 ID
+  // Legacy field: DB is "catergoryId"; tag can exist without Category now
+  categoryId String?  @map("catergoryId") @db.ObjectId
 
-  seq         Int        @default(0)        // 排序
-  status      Int        @default(1)        // 状态 0: 禁用; 1: 启用
+  seq        Int      @default(0)                 // 排序
+  status     Int      @default(1)                 // 状态 0: 禁用; 1: 启用
 
-  createAt    BigInt     @default(0)          // 创建时间
-  updateAt    BigInt     @default(0)          // 更新时间
+  // epoch seconds stored as BigInt
+  createAt   BigInt   @default(0)                 // 创建时间 (秒)
+  updateAt   BigInt   @default(0)                 // 更新时间 (秒)
+
+  @@index([status])
+  @@index([seq])
 
   @@map("tag")
 }

+ 172 - 0
prisma/mongo/schema/video-sync.prisma

@@ -0,0 +1,172 @@
+enum EntityType {
+  VIDEO
+}
+
+enum ChangeType {
+  CREATED  // type = 0
+  UPDATED  // type = 1
+  DELETED  // type = 2
+}
+
+model SyncState {
+  id             String    @id @default(auto()) @map("_id") @db.ObjectId
+  entity         EntityType
+  referId        String?
+  lastRunAt      DateTime?
+  lastFullSyncAt DateTime?
+  createdAt      Int
+  updatedAt      Int
+
+  @@unique([entity])
+  @@map("syncState")
+}
+
+model SyncChangeNotification {
+  id                  String     @id @default(auto()) @map("_id") @db.ObjectId
+  entity              EntityType
+  changeType          Int
+  externalId          String?
+  nonce               String?
+  timestamp           BigInt?
+  sign                String?
+  processed           Boolean    @default(false)
+  processedAt         DateTime?
+  statusCode          Int?
+  errorMsg            String?
+  rawBody             Json?
+  createdAt           Int
+
+  // NEW: explicit notify discriminator (1=new order, 2=status update)
+  notifyType          Int?
+  // NEW: free-form notes / debug info
+  notes               String?
+
+  @@index([entity, externalId])
+  @@map("syncChangeNotification")
+}
+
+model SyncRequestSignature {
+  id         String   @id @default(auto()) @map("_id") @db.ObjectId
+  nonce      String   @unique
+  timestamp  BigInt
+  sign       String
+  endpoint   String
+  createdAt  Int
+
+  @@map("syncRequestSignature")
+}
+
+enum SyncRunType {
+  FULL
+  NOTIFY
+  RETRY
+}
+
+enum SyncAction {
+  CREATED
+  UPDATED
+  DELETED
+  NOOP        // fetched but nothing changed
+  FAILED
+}
+
+model SyncRun {
+  id              String      @id @default(auto()) @map("_id") @db.ObjectId
+  entity          EntityType
+  type            SyncRunType
+  isInitial       Boolean     @default(false)
+  referIdStart    String?
+  referIdEnd      String?
+  pageSize        Int?
+  processedCount  Int         @default(0)
+  createdCount    Int         @default(0)
+  updatedCount    Int         @default(0)
+  deletedCount    Int         @default(0)
+  failedCount     Int         @default(0)
+  startedAt       DateTime
+  finishedAt      DateTime?
+  status          Int?        // 0 success, non-zero error code
+  errorMsg        String?
+  notes           String?     // NEW: free-form reason/context
+  createdAt       Int
+  updatedAt       Int
+
+  records         SyncRecord[] @relation("RunToRecords")
+
+  @@index([entity, type, startedAt])
+  @@map("syncRun")
+}
+
+model SyncRecord {
+  id              String       @id @default(auto()) @map("_id") @db.ObjectId
+  runId           String
+  run             SyncRun      @relation("RunToRecords", fields: [runId], references: [id])
+  entity          EntityType
+  externalId      String
+  action          SyncAction
+  source          SyncRunType
+  processedAt     DateTime?    // CHANGED: allow pending records
+  notificationId  String?
+
+  // Snapshots / Audit
+  before          Json?
+  after           Json?
+  diff            Json?
+  checksumBefore  String?
+  checksumAfter   String?
+
+  // NEW: evidence & normalization
+  payloadRaw      String?      // verbatim upstream JSON string
+  payloadHash     String?      // sha256(payloadRaw)
+  normalized      Json?        // our mapped DTO snapshot
+  resultHash      String?      // sha256(normalized)
+  mediaAudit      Json?        // { photos: [...], video?: {...} }
+  hold            Boolean      @default(false) // legal hold (skip retention)
+  prevRecordId    String?      @db.ObjectId    // chain to previous record for same externalId
+
+  // Status semantics (existing)
+  status          Int?         // 0=success, 1=transient_failed(pending retry), 2=permanent_failed
+  errorMsg        String?
+  createdAt       Int
+  updatedAt       Int?
+
+
+  // ---------------- NEW: Job identity & retry scheduling ----------------
+  /// "SubmitOrder" | "SubmitStatus" (null for older/other record types)
+  jobType         String?
+  attempt         Int          @default(0)
+  maxAttempts     Int          @default(3)
+  /// epoch seconds for backoff scheduling
+  nextRunAt       Int?
+  /// De-dupe key; recommend `${jobType}:${externalId}`
+  dedupeKey       String?
+
+  // ---------------- NEW: HTTP request/response audit --------------------
+  requestUrl      String?
+  requestMethod   String?      // e.g., "POST"
+  requestHeaders  Json?
+  requestBody     Json?
+  responseCode    Int?
+  responseBody    String?
+  latencyMs       Int?
+
+  // ----------- NEW: Idempotency & future auth scaffolding ---------------
+  idempotencyKey  String?
+  securityNonce   String?
+  securityTs      BigInt?
+  securitySign    String?
+
+  @@index([entity, externalId, processedAt])
+  @@index([runId])
+  @@index([entity, action, processedAt])
+  @@index([entity, externalId, createdAt])  // quick newest lookups
+
+  // NEW: scheduler hot-path index
+  @@index([status, nextRunAt])
+
+  // NEW: safe uniqueness for one active logical job per order per type
+  // (compound unique is safe with nulls in Mongo—docs missing any field don't collide)
+  @@unique([entity, externalId, jobType])
+
+  @@map("syncRecord")
+}