Ver código fonte

Merge branch 'master' into wudi_dev

FC_DAN\c9837 3 meses atrás
pai
commit
e62d954822

+ 2 - 0
apps/box-app-api/src/app.module.ts

@@ -12,6 +12,7 @@ import { VideoModule } from './feature/video/video.module';
 import { AdModule } from './feature/ads/ad.module';
 import { HomepageModule } from './feature/homepage/homepage.module';
 import { SysParamsModule } from './feature/sys-params/sys-params.module';
+import { MediaConfigModule } from './feature/media-config/media-config.module';
 import { RedisModule } from '@box/db/redis/redis.module';
 import { RabbitmqModule } from './rabbitmq/rabbitmq.module';
 import { AuthModule } from './feature/auth/auth.module';
@@ -60,6 +61,7 @@ import path from 'path';
     // RecommendationModule,
     HomepageModule,
     SysParamsModule,
+    MediaConfigModule,
   ],
   providers: [
     {

+ 1 - 1
apps/box-app-api/src/feature/auth/auth.service.ts

@@ -104,7 +104,7 @@ export class AuthService {
 
     // 5) startupAds (placeholder: you’ll wire channel-specific ads later)
     // For now return null to keep behaviour deterministic.
-    const startupAds = await this.adService.listAdsByType(1, 500);
+    const startupAds = await this.adService.listAdsByType(1, 20);
 
     return {
       uid,

+ 10 - 0
apps/box-app-api/src/feature/homepage/homepage.controller.ts

@@ -48,6 +48,16 @@ export class HomepageController {
     return this.homepageService.getCategoryList();
   }
 
+  @Get('tags')
+  @ApiOperation({
+    summary: '获取分类列表',
+    description:
+      '返回 Redis 中的完整分类缓存(box:app:category:all),按 seq 升序。',
+  })
+  async getTagList(): Promise<any> {
+    return this.homepageService.getTagList();
+  }
+
   // @Get('tags')
   // @ApiOperation({
   //   summary: '获取标签列表',

+ 30 - 0
apps/box-app-api/src/feature/media-config/media-config.module.ts

@@ -0,0 +1,30 @@
+import { Module } from '@nestjs/common';
+import { MediaManagerModule } from '@box/core/media-manager/media-manager.module';
+import { SysConfigModule } from '@box/core/sys-config/sys-config.module';
+import { SysConfigReaderService } from '@box/core/sys-config/sys-config-reader.service';
+
+const mediaManagerModule = MediaManagerModule.registerAsync({
+  imports: [SysConfigModule],
+  useFactory: async (sysConfigReader: SysConfigReaderService) => {
+    const imageConfig = await sysConfigReader.getImageConfig();
+    return {
+      localRoot: imageConfig.local?.rootPath,
+      aws: imageConfig.s3
+        ? {
+            region: imageConfig.s3.region,
+            endpoint: imageConfig.s3.endpointUrl,
+            accessKeyId: imageConfig.s3.accessKeyId,
+            secretAccessKey: imageConfig.s3.secretAccessKey,
+            bucket: imageConfig.s3.bucket,
+          }
+        : undefined,
+    };
+  },
+  inject: [SysConfigReaderService],
+});
+
+@Module({
+  imports: [SysConfigModule, mediaManagerModule],
+  exports: [MediaManagerModule],
+})
+export class MediaConfigModule {}

+ 12 - 0
apps/box-app-api/src/feature/sys-params/sys-params.controller.ts

@@ -1,3 +1,4 @@
+// apps/box-app-api/src/feature/sys-params/sys-params.controller.ts
 import { Controller, Get, Query } from '@nestjs/common';
 import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
 import { SysParamsService } from './sys-params.service';
@@ -33,4 +34,15 @@ export class SysParamsController {
   async getAdTypes() {
     return this.service.getAdTypes();
   }
+
+  @Get('sysCnf')
+  @ApiOperation({ summary: 'Get sys config appConfig' })
+  @ApiResponse({
+    status: 200,
+    description: 'Returns the appConfig object stored in sysConfig',
+  })
+  async getSysCnf() {
+    const data = await this.service.getSysCnf();
+    return data;
+  }
 }

+ 2 - 1
apps/box-app-api/src/feature/sys-params/sys-params.module.ts

@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
 import { SysParamsService } from './sys-params.service';
 import { SysParamsController } from './sys-params.controller';
 import { PrismaMongoModule } from '../../prisma/prisma-mongo.module';
+import { SysConfigModule } from '@box/core/sys-config/sys-config.module';
 
 @Module({
-  imports: [PrismaMongoModule],
+  imports: [PrismaMongoModule, SysConfigModule],
   controllers: [SysParamsController],
   providers: [SysParamsService],
   exports: [SysParamsService],

+ 9 - 1
apps/box-app-api/src/feature/sys-params/sys-params.service.ts

@@ -10,10 +10,14 @@ import {
   AdSlot,
   HOMEPAGE_CONSTANTS,
 } from '../homepage/homepage.constants';
+import { SysConfigReaderService } from '@box/core/sys-config/sys-config-reader.service';
 
 @Injectable()
 export class SysParamsService {
-  constructor(private readonly prisma: PrismaMongoService) {}
+  constructor(
+    private readonly prisma: PrismaMongoService,
+    private readonly sysConfigReader: SysConfigReaderService,
+  ) {}
 
   getHomepageConstants() {
     return {
@@ -80,4 +84,8 @@ export class SysParamsService {
     if (group === 'system') return system;
     return {};
   }
+
+  async getSysCnf() {
+    return this.sysConfigReader.getAppConfig();
+  }
 }

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

@@ -94,8 +94,8 @@ export class ProviderVideoSyncController {
     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.',
+      'Full sync: set fullSync=true; service ignores updatedAt filters and starts from the provided pageNum without persisting page/pageSize cursors.',
+      'Incremental sync: by default uses the stored checkpoint updatedAtCursor, override with param.updatedAt; the first-ever incremental (no checkpoint) runs a bottom→top baseline partial before resuming.',
     ].join('\n'),
   })
   @ApiBody({ type: ProviderVideoSyncRunDto })
@@ -128,7 +128,7 @@ export class ProviderVideoSyncController {
   @ApiOperation({
     summary: 'Trigger incremental sync quickly (query params)',
     description:
-      'Convenience endpoint. Use /run for full control. Incremental uses param.updatedAt.',
+      'Convenience endpoint that reuses the same body parameters; incremental uses the stored checkpoint updatedAtCursor unless you override with param.updatedAt, and the first-ever incremental (no checkpoint) runs a bottom→top baseline partial before regular paging.',
   })
   @ApiQuery({
     name: 'updatedAt',
@@ -148,7 +148,7 @@ export class ProviderVideoSyncController {
       providerCode: providerCode || undefined,
       fullSync: false,
       resetState: resetState === 'true',
-      pageSize: pageSize ? Number(pageSize) : undefined,
+      pageSize: parsePageSize(pageSize),
       param: {
         status: 'Completed',
         updatedAt: updatedAt || undefined,
@@ -162,7 +162,7 @@ export class ProviderVideoSyncController {
   @ApiOperation({
     summary: 'Trigger full sync quickly (query params)',
     description:
-      'Convenience endpoint. Full sync ignores updatedAt filter and resumes by stored pageNum cursor.',
+      'Convenience endpoint. Full sync ignores updatedAt filters and starts at the provided pageNum without relying on a persisted page cursor.',
   })
   @ApiQuery({ name: 'pageSize', required: false, description: '1..500' })
   @ApiQuery({ name: 'providerCode', required: false })
@@ -182,8 +182,8 @@ export class ProviderVideoSyncController {
       providerCode: providerCode || undefined,
       fullSync: true,
       resetState: resetState === 'true',
-      pageSize: pageSize ? Number(pageSize) : undefined,
-      pageNum: pageNum ? Number(pageNum) : undefined,
+      pageSize: parsePageSize(pageSize),
+      pageNum: parsePositiveInt(pageNum),
       param: {
         status: 'Completed',
       },
@@ -192,3 +192,20 @@ export class ProviderVideoSyncController {
     return this.service.syncFromProvider(options);
   }
 }
+
+function parsePageSize(value?: string): number | undefined {
+  if (!value) return undefined;
+  const parsed = Number(value);
+  if (!Number.isFinite(parsed)) return undefined;
+  const clamped = Math.trunc(parsed);
+  if (clamped < 1) return undefined;
+  return Math.min(500, clamped);
+}
+
+function parsePositiveInt(value?: string): number | undefined {
+  if (!value) return undefined;
+  const parsed = Number(value);
+  if (!Number.isFinite(parsed)) return undefined;
+  const clamped = Math.trunc(parsed);
+  return clamped >= 1 ? clamped : undefined;
+}

+ 5 - 3
apps/box-mgnt-api/src/mgnt-backend/feature/provider-video-sync/provider-video-sync.module.ts

@@ -1,11 +1,13 @@
-import { Module } from '@nestjs/common';
 import { HttpModule } from '@nestjs/axios';
-import { ProviderVideoSyncService } from './provider-video-sync.service';
+import { Module } from '@nestjs/common';
+
 import { ProviderVideoSyncController } from './provider-video-sync.controller';
+import { ProviderVideoSyncService } from './provider-video-sync.service';
 import { PrismaModule } from '@box/db/prisma/prisma.module';
+import { SysConfigModule } from '@box/core/sys-config/sys-config.module';
 
 @Module({
-  imports: [PrismaModule, HttpModule],
+  imports: [PrismaModule, HttpModule, SysConfigModule],
   providers: [ProviderVideoSyncService],
   controllers: [ProviderVideoSyncController],
   exports: [ProviderVideoSyncService],

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

@@ -2,6 +2,7 @@
 import { Injectable, Logger } from '@nestjs/common';
 import { HttpService } from '@nestjs/axios';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { SysConfigReaderService } from '@box/core/sys-config/sys-config-reader.service';
 import { firstValueFrom } from 'rxjs';
 import { EntityType } from '@prisma/mongo/client';
 
@@ -114,6 +115,11 @@ type SyncCursor = {
   updatedAtCursor?: string;
 };
 
+type ProviderPagingInfo = {
+  total?: number;
+  totalPages?: number;
+};
+
 type UpsertOutcome =
   | { ok: true }
   | { ok: false; error: { id?: string; error: string } };
@@ -131,25 +137,31 @@ export class ProviderVideoSyncService {
 
   private lastSyncSummary: ProviderVideoSyncResult | null = null;
 
-  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 DEFAULT_PAGE_SIZE = 500;
   private readonly BATCH_SIZE = 100;
+  private readonly BASELINE_PARTIAL_COUNT = 20000;
 
   constructor(
     private readonly mongo: MongoPrismaService,
     private readonly httpService: HttpService,
+    private readonly sysConfigReader: SysConfigReaderService,
   ) {}
 
   async syncFromProvider(
     options: ProviderVideoSyncOptions = {},
   ): Promise<ProviderVideoSyncResult> {
-    const providerCode = options.providerCode ?? this.DEFAULT_PROVIDER_CODE;
+    const providerConfig = await this.sysConfigReader.getProviderConfig();
+    const providerCode = options.providerCode ?? providerConfig.providerCode;
+    const providerApiUrl = providerConfig.apiUrl;
+    if (!providerApiUrl) {
+      throw new Error(
+        'sysConfig.provider.apiUrl is required for provider sync',
+      );
+    }
 
-    const requestedPageSize = options.pageSize ?? this.DEFAULT_PAGE_SIZE;
+    const defaultPageSize = providerConfig.itemsLimit ?? this.DEFAULT_PAGE_SIZE;
+    const requestedPageSize = options.pageSize ?? defaultPageSize;
     const pageSize = this.clampInt(requestedPageSize, 1, this.MAX_PAGE_SIZE);
 
     const fullSync = options.fullSync ?? false;
@@ -166,14 +178,15 @@ export class ProviderVideoSyncService {
     );
 
     // Load cursor from SyncState (or fresh if resetState)
-    const { cursor: initialCursor } = await this.loadCursor({
-      entity,
-      pageSize,
-      resetState,
-      overridePageNum: options.pageNum,
-      optionUpdatedAt,
-      fullSync,
-    });
+    const { cursor: initialCursor, checkpointUpdatedAtCursor } =
+      await this.loadCursor({
+        entity,
+        pageSize,
+        resetState,
+        overridePageNum: options.pageNum,
+        optionUpdatedAt,
+        fullSync,
+      });
 
     // Counters (Option B: created always 0, updated counts successful upserts)
     let imported = 0;
@@ -195,16 +208,34 @@ export class ProviderVideoSyncService {
       updatedAtCursor: initialCursor.updatedAtCursor,
     };
 
+    const effectiveUpdatedAtCursor = fullSync
+      ? undefined
+      : (options.param?.updatedAt ?? checkpointUpdatedAtCursor);
+
+    const shouldRunBaselinePartial = !fullSync && !checkpointUpdatedAtCursor;
+
     try {
+      if (shouldRunBaselinePartial) {
+        const baselineResult = await this.runBaselinePartialIfNeeded({
+          entity,
+          cursor: initialCursor,
+          paramStatus,
+          optionsParam: options.param,
+          apiUrl: providerApiUrl,
+        });
+        if (baselineResult) {
+          this.lastSyncSummary = baselineResult;
+          return baselineResult;
+        }
+      }
+
       while (true) {
         const body = this.buildProviderBody({
           pageNum,
           pageSize: cursor.pageSize,
           status: paramStatus,
           // fullSync: no updatedAt filter
-          updatedAt: fullSync
-            ? undefined
-            : (cursor.updatedAtCursor ?? optionUpdatedAt),
+          updatedAt: fullSync ? undefined : effectiveUpdatedAtCursor,
           extraParam: options.param,
         });
 
@@ -212,7 +243,7 @@ export class ProviderVideoSyncService {
           `[syncFromProvider] POST pageNum=${pageNum} pageSize=${cursor.pageSize} status=${paramStatus} updatedAt=${fullSync ? '(none)' : (body.param.updatedAt ?? '(none)')}`,
         );
 
-        const rawList = await this.fetchPage(body);
+        const rawList = await this.fetchPage(providerApiUrl, body);
         if (!rawList.length) {
           this.logger.log(
             `[syncFromProvider] No more records (pageNum=${pageNum}). Stop.`,
@@ -223,20 +254,16 @@ export class ProviderVideoSyncService {
           // - 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();
+          if (!fullSync && maxUpdatedAtSeen && imported > 0) {
+            await this.saveCheckpoint({
+              entity,
+              updatedAtCursor: maxUpdatedAtSeen.toISOString(),
+              fullSyncCompleted: false,
+            });
           }
 
           await this.saveCursor({
             entity,
-            cursor,
             fullSyncCompleted,
           });
 
@@ -252,60 +279,18 @@ export class ProviderVideoSyncService {
         }
 
         imported += rawList.length;
-
-        const normalized = rawList.map((item) => this.normalizeItem(item));
-
-        const hasSecondTags = normalized.some(
-          (v) => Array.isArray(v.secondTags) && v.secondTags.length > 0,
+        const processed = await this.processProviderRawList(
+          rawList,
+          maxUpdatedAtSeen,
         );
-
-        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);
-        }
+        updated += processed.updated;
+        skipped += processed.skipped;
+        errors.push(...processed.errors);
+        maxUpdatedAtSeen = processed.maxUpdatedAtSeen;
 
         // 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,
         });
 
@@ -318,13 +303,8 @@ export class ProviderVideoSyncService {
 
       // 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) {
@@ -348,6 +328,173 @@ export class ProviderVideoSyncService {
     }
   }
 
+  private async runBaselinePartialIfNeeded(args: {
+    entity: EntityType;
+    cursor: SyncCursor;
+    paramStatus: string;
+    apiUrl: string;
+    optionsParam?: ProviderVideoSyncOptions['param'];
+  }): Promise<ProviderVideoSyncResult | null> {
+    if (!args.cursor || args.cursor.updatedAtCursor !== undefined) {
+      return null;
+    }
+
+    const probeBody = this.buildProviderBody({
+      pageNum: 1,
+      pageSize: args.cursor.pageSize,
+      status: args.paramStatus,
+      updatedAt: undefined,
+      extraParam: args.optionsParam,
+    });
+
+    const pagination = await this.probeProviderForPaging(
+      args.apiUrl,
+      probeBody,
+    );
+
+    let totalPages = pagination.totalPages;
+    if (totalPages === undefined && pagination.total !== undefined) {
+      totalPages = Math.max(
+        0,
+        Math.ceil(pagination.total / args.cursor.pageSize),
+      );
+    }
+
+    if (!totalPages || totalPages < 1) {
+      this.logger.warn(
+        '[syncFromProvider] Baseline partial skipped because provider did not disclose total/pages; cannot compute bottom→top range.',
+      );
+      return null;
+    }
+
+    const pagesNeeded = Math.min(
+      totalPages,
+      Math.ceil(this.BASELINE_PARTIAL_COUNT / args.cursor.pageSize),
+    );
+
+    if (pagesNeeded <= 0) {
+      return null;
+    }
+
+    const startPage = totalPages;
+    const endPage = Math.max(1, totalPages - pagesNeeded + 1);
+
+    this.logger.log(
+      `[syncFromProvider] Baseline partial (first-ever) running pages ${startPage} down to ${endPage}`,
+    );
+
+    let imported = 0;
+    let updated = 0;
+    let skipped = 0;
+    const errors: Array<{ id?: string; error: string }> = [];
+    let maxUpdatedAtSeen: Date | null = null;
+
+    for (let page = startPage; page >= endPage; page -= 1) {
+      const body = this.buildProviderBody({
+        pageNum: page,
+        pageSize: args.cursor.pageSize,
+        status: args.paramStatus,
+        updatedAt: undefined,
+        extraParam: args.optionsParam,
+      });
+
+      this.logger.log(`[syncFromProvider] param body ${JSON.stringify(body)} `);
+
+      const rawList = await this.fetchPage(args.apiUrl, body);
+      if (!rawList.length) {
+        this.logger.log(
+          `[syncFromProvider] Baseline partial page ${page} returned 0 records; continuing.`,
+        );
+        continue;
+      }
+
+      imported += rawList.length;
+      const processed = await this.processProviderRawList(
+        rawList,
+        maxUpdatedAtSeen,
+      );
+      updated += processed.updated;
+      skipped += processed.skipped;
+      errors.push(...processed.errors);
+      maxUpdatedAtSeen = processed.maxUpdatedAtSeen;
+    }
+
+    if (maxUpdatedAtSeen && imported > 0) {
+      await this.saveCheckpoint({
+        entity: args.entity,
+        updatedAtCursor: maxUpdatedAtSeen.toISOString(),
+        fullSyncCompleted: false,
+      });
+    }
+
+    return {
+      imported,
+      created: 0,
+      updated,
+      skipped,
+      errors: errors.length ? errors.slice(0, 10) : undefined,
+    };
+  }
+
+  private async probeProviderForPaging(
+    apiUrl: string,
+    body: {
+      pageNum: number;
+      pageSize: number;
+      param: Record<string, any>;
+    },
+  ): Promise<ProviderPagingInfo> {
+    try {
+      const response = await firstValueFrom(
+        this.httpService.post(apiUrl, body, {
+          headers: { 'Content-Type': 'application/json' },
+          timeout: 30000,
+        }),
+      );
+
+      return {
+        total: this.extractNumberFromPaths(response.data, [
+          'total',
+          'data.total',
+          'data.totalCount',
+          'data.pageInfo.total',
+          'data.pageInfo.totalCount',
+        ]),
+        totalPages: this.extractNumberFromPaths(response.data, [
+          'pages',
+          'data.pages',
+          'data.totalPages',
+          'data.pageInfo.pages',
+          'data.pageInfo.totalPages',
+        ]),
+      };
+    } catch (error: any) {
+      this.logger.error(
+        `[probeProviderForPaging] Provider API call failed: ${error?.message ?? error}`,
+      );
+      throw new Error(`Provider API error: ${error?.message ?? 'unknown'}`);
+    }
+  }
+
+  private extractNumberFromPaths(
+    data: any,
+    paths: string[],
+  ): number | undefined {
+    if (!data || typeof data !== 'object') return undefined;
+    for (const path of paths) {
+      const value = path
+        .split('.')
+        .reduce<any>(
+          (obj, key) => (obj && typeof obj === 'object' ? obj[key] : undefined),
+          data,
+        );
+      if (value === undefined || value === null) continue;
+      const num = typeof value === 'number' ? value : Number(value);
+      if (Number.isFinite(num)) return num;
+    }
+    return undefined;
+  }
+
   getLastSyncSummary(): ProviderVideoSyncResult | null {
     return this.lastSyncSummary;
   }
@@ -386,21 +533,52 @@ export class ProviderVideoSyncService {
       param,
     };
   }
-
-  private async fetchPage(body: {
-    pageNum: number;
-    pageSize: number;
-    param: Record<string, any>;
-  }): Promise<RawProviderVideo[]> {
+  private async fetchPage(
+    apiUrl: string,
+    body: {
+      pageNum: number;
+      pageSize: number;
+      param: Record<string, any>;
+    },
+  ): Promise<RawProviderVideo[]> {
     try {
+      // Provider expects { data: "<json string>" } (based on code=400 Field=data expecting string)
+      const wrappedBody = {
+        data: JSON.stringify({
+          pageNum: body.pageNum,
+          pageSize: body.pageSize,
+          param: body.param,
+        }),
+      };
+
       const response = await firstValueFrom(
-        this.httpService.post(this.PROVIDER_API_URL, body, {
+        this.httpService.post(apiUrl, wrappedBody, {
           headers: { 'Content-Type': 'application/json' },
-          timeout: 30000,
+          timeout: 30_000,
         }),
       );
 
-      const list = this.extractList(response.data);
+      // Axios response unwrap: providerJson is the actual provider payload
+      const providerJson = (response as any)?.data ?? response;
+
+      // Log a small preview for debugging (avoid huge logs)
+      this.logger.log(
+        `[fetchPage] Provider response preview: ${JSON.stringify(
+          providerJson,
+        ).slice(0, 400)}...`,
+      );
+
+      // Fail fast on provider errors (prevents "successful" runs with empty lists)
+      const code = (providerJson as any)?.code;
+      if (code !== 200) {
+        const msg = (providerJson as any)?.msg ?? 'unknown';
+        const tip = (providerJson as any)?.tip ?? '';
+        throw new Error(
+          `Provider error code=${code} msg=${msg}${tip ? ` tip=${tip}` : ''}`,
+        );
+      }
+
+      const list = this.extractList(providerJson);
       this.logger.log(`[fetchPage] Received ${list.length} items`);
       return list;
     } catch (error: any) {
@@ -411,11 +589,101 @@ export class ProviderVideoSyncService {
     }
   }
 
+  private async processProviderRawList(
+    rawList: RawProviderVideo[],
+    currentMaxUpdatedAt: Date | null,
+  ): Promise<{
+    updated: number;
+    skipped: number;
+    errors: Array<{ id?: string; error: string }>;
+    maxUpdatedAtSeen: Date | null;
+  }> {
+    if (!rawList.length) {
+      return {
+        updated: 0,
+        skipped: 0,
+        errors: [],
+        maxUpdatedAtSeen: currentMaxUpdatedAt,
+      };
+    }
+
+    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);
+    }
+
+    let maxUpdatedAtSeen = currentMaxUpdatedAt;
+    for (const n of normalized) {
+      const d = n.updatedAt as Date;
+      if (!maxUpdatedAtSeen || d.getTime() > maxUpdatedAtSeen.getTime()) {
+        maxUpdatedAtSeen = d;
+      }
+    }
+
+    let updated = 0;
+    let skipped = 0;
+    const errors: Array<{ id?: string; error: string }> = [];
+
+    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);
+    }
+
+    return {
+      updated,
+      skipped,
+      errors,
+      maxUpdatedAtSeen,
+    };
+  }
+
+  private debugRespShape(resp: unknown) {
+    const r: any = resp as any;
+    const keys = r && typeof r === 'object' ? Object.keys(r).slice(0, 12) : [];
+    const dataKeys =
+      r?.data && typeof r.data === 'object'
+        ? Object.keys(r.data).slice(0, 12)
+        : [];
+    const dataDataKeys =
+      r?.data?.data && typeof r.data.data === 'object'
+        ? Object.keys(r.data.data).slice(0, 12)
+        : [];
+    this.logger.warn(
+      `[debugRespShape] topKeys=${JSON.stringify(keys)} dataKeys=${JSON.stringify(
+        dataKeys,
+      )} dataDataKeys=${JSON.stringify(dataDataKeys)} hasStatus=${Boolean(r?.status)} hasCode=${Boolean(
+        r?.code,
+      )} hasDataCode=${Boolean(r?.data?.code)}`,
+    );
+  }
+
   private extractList(apiResponse: unknown): RawProviderVideo[] {
     const data = apiResponse as any;
 
     if (Array.isArray(data)) return data as RawProviderVideo[];
 
+    // ✅ axios response: { data: { code, data: { total, list } } }
+    if (data?.data?.data?.list && Array.isArray(data.data.data.list)) {
+      return data.data.data.list as RawProviderVideo[];
+    }
+
+    // provider json directly: { code, data: { total, list } }
     if (data?.data?.list && Array.isArray(data.data.list)) {
       return data.data.list as RawProviderVideo[];
     }
@@ -551,9 +819,7 @@ export class ProviderVideoSyncService {
     overridePageNum?: number;
     optionUpdatedAt?: string;
     fullSync: boolean;
-  }): Promise<{ cursor: SyncCursor; hasState: boolean }> {
-    const nowSec = Math.floor(Date.now() / 1000);
-
+  }): Promise<{ cursor: SyncCursor; checkpointUpdatedAtCursor?: string }> {
     if (args.resetState) {
       return {
         cursor: {
@@ -561,41 +827,25 @@ export class ProviderVideoSyncService {
           pageSize: args.pageSize,
           updatedAtCursor: args.fullSync ? undefined : args.optionUpdatedAt,
         },
-        hasState: false,
+        checkpointUpdatedAtCursor: undefined,
       };
     }
 
-    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 checkpoint = await this.loadCheckpoint(args.entity);
 
     const cursor: SyncCursor = {
-      pageNum: args.overridePageNum ?? parsed?.pageNum ?? 1,
+      pageNum: args.overridePageNum ?? 1,
       pageSize: args.pageSize,
       updatedAtCursor: args.fullSync
         ? undefined
-        : (parsed?.updatedAtCursor ?? args.optionUpdatedAt),
+        : (checkpoint.updatedAtCursor ?? args.optionUpdatedAt),
     };
 
-    return { cursor, hasState: Boolean(state.referId) };
+    return { cursor, checkpointUpdatedAtCursor: checkpoint.updatedAtCursor };
   }
 
   private async saveCursor(args: {
     entity: EntityType;
-    cursor: SyncCursor;
     fullSyncCompleted: boolean;
   }) {
     const now = new Date();
@@ -604,7 +854,6 @@ export class ProviderVideoSyncService {
     await this.mongo.syncState.update({
       where: { entity: args.entity },
       data: {
-        referId: JSON.stringify(args.cursor),
         lastRunAt: now,
         lastFullSyncAt: args.fullSyncCompleted ? now : undefined,
         updatedAt: nowSec,
@@ -612,38 +861,45 @@ export class ProviderVideoSyncService {
     });
   }
 
+  private async loadCheckpoint(entity: EntityType): Promise<{
+    updatedAtCursor?: string;
+  }> {
+    const nowSec = Math.floor(Date.now() / 1000);
+
+    const state = await this.mongo.syncState.upsert({
+      where: { entity },
+      update: {
+        updatedAt: nowSec,
+      },
+      create: {
+        entity,
+        referId: null,
+        lastRunAt: null,
+        lastFullSyncAt: null,
+        createdAt: nowSec,
+        updatedAt: nowSec,
+      },
+    });
+
+    const parsed = this.safeParseCursor(state.referId);
+    return { updatedAtCursor: parsed?.updatedAtCursor };
+  }
+
   private async saveCheckpoint(args: {
     entity: EntityType;
-    nextUpdatedAtCursor?: string; // ONLY when batch completed
+    updatedAtCursor?: string | null;
     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),
+        referId:
+          args.updatedAtCursor !== undefined && args.updatedAtCursor !== null
+            ? JSON.stringify({ updatedAtCursor: args.updatedAtCursor })
+            : null,
         lastRunAt: now,
         lastFullSyncAt: args.fullSyncCompleted ? now : undefined,
         updatedAt: nowSec,
@@ -766,85 +1022,100 @@ export class ProviderVideoSyncService {
   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);
+    try {
+      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;
+      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 });
           }
+        }
+      };
 
-          // 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),
-            },
-          });
+      await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
 
-          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 });
-        }
+      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}`,
+        );
       }
-    };
 
-    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 };
+    } catch (error: any) {
+      const message = error?.message ?? 'Unhandled tag upsert error';
+      const trace = error?.stack ?? undefined;
+      this.logger.error(
+        `[upsertSecondTagsFromVideos_NoUniqueName] ${message}`,
+        trace,
       );
+      return {
+        unique: 0,
+        upserted: 0,
+        skipped: 0,
+        errors: [{ name: 'global', error: message }],
+      };
     }
-
-    return { unique: names.length, upserted, skipped, errors };
   }
 }

+ 2 - 1
apps/box-mgnt-api/src/mgnt-backend/feature/s3/s3.module.ts

@@ -1,9 +1,10 @@
 import { Module } from '@nestjs/common';
 import { S3Service } from './s3.service';
 import { S3Controller } from './s3.controller';
+import { SysConfigModule } from '@box/core/sys-config/sys-config.module';
 
 @Module({
-  imports: [],
+  imports: [SysConfigModule],
   providers: [S3Service],
   controllers: [S3Controller],
   exports: [S3Service],

+ 16 - 15
apps/box-mgnt-api/src/mgnt-backend/feature/s3/s3.service.ts

@@ -10,13 +10,17 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import { Logger } from 'nestjs-pino';
 import { appendFile, mkdir } from 'fs/promises';
 import * as path from 'path';
+import { SysConfigReaderService } from '@box/core/sys-config/sys-config-reader.service';
 
 @Injectable()
 export class S3Service {
   private readonly s3: S3Client;
   private readonly bucket: string;
 
-  constructor(private readonly logger: Logger) {
+  constructor(
+    private readonly logger: Logger,
+    private readonly sysConfigReader: SysConfigReaderService,
+  ) {
     this.s3 = new S3Client({
       region: process.env.AWS_S3_REGION_NAME,
       credentials: {
@@ -62,7 +66,7 @@ export class S3Service {
     const ext = filename.split('.').pop();
     const key = `${folder}/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
 
-    const maxSize = this.resolveMaxSize(contentType);
+    const maxSize = await this.resolveMaxSize(contentType);
     this.logger.log(`maxSize for upload: ${maxSize} bytes`);
 
     const { url, fields } = await createPresignedPost(this.s3, {
@@ -81,34 +85,31 @@ export class S3Service {
     return { url, fields, key };
   }
 
-  private resolveMaxSize(contentType: string): number {
+  private async resolveMaxSize(contentType: string): Promise<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;
-    };
+    const imageConfig = await this.sysConfigReader.getImageConfig();
+    const limitsMb = imageConfig.limitsMb ?? {};
+    const imageLimitMb = limitsMb.image ?? 10;
+    const videoLimitMb = limitsMb.video ?? 100;
 
-    // ✅ 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;
+      const effectiveMb = imageLimitMb * BASE64_OVERHEAD_RATIO * SAFETY_MARGIN;
       return Math.ceil(effectiveMb * 1024 * 1024);
     }
     if (contentType.startsWith('image/')) {
-      const rawMb = Number(process.env.UPLOAD_LIMIT_IMAGE ?? 10);
-      const effectiveMb = rawMb * BASE64_OVERHEAD_RATIO * SAFETY_MARGIN;
+      const effectiveMb = imageLimitMb * BASE64_OVERHEAD_RATIO * SAFETY_MARGIN;
       return Math.ceil(effectiveMb * 1024 * 1024);
     }
     if (contentType === 'video/mp4') {
-      return fromEnvMB('UPLOAD_LIMIT_VIDEO', 100);
+      return videoLimitMb * 1024 * 1024;
     }
     if (contentType === 'application/pdf') {
-      return fromEnvMB('UPLOAD_LIMIT_PDF', 10);
+      return 10 * 1024 * 1024;
     }
-    return fromEnvMB('UPLOAD_LIMIT_DEFAULT', 10);
+    return 10 * 1024 * 1024;
   }
 
   /**

+ 135 - 27
apps/box-mgnt-api/src/mgnt-backend/feature/sync-videomedia/sync-videomedia.service.ts

@@ -89,6 +89,17 @@ export class SyncVideomediaService {
     );
     this.logger.debug('[syncFromJson] First record sample:', normalized[0]);
 
+    const hasSecondTags = normalized.some(
+      (v) => Array.isArray(v.secondTags) && v.secondTags.length > 0,
+    );
+
+    if (hasSecondTags) {
+      // this.logger.log(
+      //   `[syncFromJson] Extracted secondTags from ${normalized.length} records`,
+      // );
+      await this.upsertSecondTagsFromVideos_NoUniqueName(normalized);
+    }
+
     // Batch processing - try to create each record individually and catch duplicate errors
     const BATCH_SIZE = 100;
     let created = 0;
@@ -105,38 +116,31 @@ export class SyncVideomediaService {
       await Promise.all(
         batch.map(async (record) => {
           try {
-            // Try to create the record
+            const exists = await this.mongo.videoMedia.findUnique({
+              where: { id: record.id },
+              select: { id: true },
+            });
+
+            if (exists?.id) {
+              const { id, ...updateData } = record;
+              await this.mongo.videoMedia.update({
+                where: { id },
+                data: updateData,
+              });
+              updated++;
+              this.logger.debug(`[syncFromJson] Updated record: ${id}`);
+              return;
+            }
+
             await this.mongo.videoMedia.create({ data: record });
             created++;
             this.logger.debug(`[syncFromJson] Created record: ${record.id}`);
           } catch (error: any) {
-            this.logger.debug(
-              `[syncFromJson] Create failed for ${record.id}: ${error.code} ${error.message?.substring(0, 100)}`,
+            this.logger.error(
+              `[syncFromJson] Failed for ${record.id}: ${error.message}`,
             );
-            // 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,
-                });
-                updated++;
-                this.logger.debug(`[syncFromJson] Updated record: ${id}`);
-              } catch (updateError: any) {
-                this.logger.error(
-                  `[syncFromJson] Update failed for ${record.id}: ${updateError.message}`,
-                );
-                skipped++;
-                errors.push({ id: record.id, error: updateError.message });
-              }
-            } else {
-              this.logger.error(
-                `[syncFromJson] Skipped ${record.id}: ${error.message}`,
-              );
-              skipped++;
-              errors.push({ id: record.id, error: error.message });
-            }
+            skipped++;
+            errors.push({ id: record.id, error: error.message });
           }
         }),
       );
@@ -158,6 +162,110 @@ export class SyncVideomediaService {
     };
   }
 
+  private async upsertSecondTagsFromVideos_NoUniqueName(
+    normalizedVideos: Array<{ secondTags?: string[] }>,
+  ): Promise<any> {
+    try {
+      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);
+        }
+      }
+
+      // this.logger.log(
+      //   `[upsertSecondTagsFromVideos] secondTags found in: ${normalizedVideos}`,
+      // );
+
+      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 };
+    } catch (error: any) {
+      const message = error?.message ?? 'Unhandled tag upsert error';
+      const trace = error?.stack ?? undefined;
+      this.logger.error(
+        `[upsertSecondTagsFromVideos_NoUniqueName] ${message}`,
+        trace,
+      );
+      return {
+        unique: 0,
+        upserted: 0,
+        skipped: 0,
+        errors: [{ name: 'global', error: message }],
+      };
+    }
+  }
+
   /**
    * Extracts the list of items from different possible JSON shapes.
    */

+ 3 - 4
libs/core/src/media-manager/adapters/local.adapter.ts

@@ -4,11 +4,10 @@ import * as fs from 'fs';
 import { mkdir, rename, unlink } from 'fs/promises';
 import * as path from 'path';
 
+const DEFAULT_LOCAL_ROOT = '/data/media';
+
 export class LocalStorageAdapter implements StorageAdapter {
-  constructor(
-    private readonly localRoot: string = process.env.MEDIA_MANAGER_LOCAL_ROOT ||
-      '/data/media',
-  ) {}
+  constructor(private readonly localRoot: string = DEFAULT_LOCAL_ROOT) {}
 
   private resolveAbsolutePath(
     localStoragePrefix: string,

+ 70 - 55
libs/core/src/media-manager/media-manager.module.ts

@@ -17,72 +17,87 @@ export interface MediaManagerModuleOptions {
   aws?: MediaManagerAwsOptions;
 }
 
+export interface MediaManagerModuleAsyncOptions {
+  imports?: any[];
+  inject?: any[];
+  useFactory: (...args: any[]) => Promise<MediaManagerModuleOptions> | MediaManagerModuleOptions;
+}
+
 const S3_CLIENT = 'MEDIA_MANAGER_S3_CLIENT';
+const MEDIA_MANAGER_OPTIONS = 'MEDIA_MANAGER_OPTIONS';
 
 @Module({})
 export class MediaManagerModule {
   static register(options?: MediaManagerModuleOptions): DynamicModule {
-    const localRoot =
-      options?.localRoot ||
-      process.env.MEDIA_MANAGER_LOCAL_ROOT ||
-      '/data/media';
+    return this.registerAsync({
+      useFactory: () => options ?? {},
+    });
+  }
 
-    const awsOptions: MediaManagerAwsOptions = {
-      region:
-        options?.aws?.region || process.env.MEDIA_MANAGER_AWS_REGION,
-      endpoint:
-        options?.aws?.endpoint ||
-        process.env.MEDIA_MANAGER_AWS_ENDPOINT_URL,
-      accessKeyId:
-        options?.aws?.accessKeyId ||
-        process.env.MEDIA_MANAGER_AWS_ACCESS_KEY_ID,
-      secretAccessKey:
-        options?.aws?.secretAccessKey ||
-        process.env.MEDIA_MANAGER_AWS_SECRET_ACCESS_KEY,
-      bucket:
-        options?.aws?.bucket || process.env.MEDIA_MANAGER_AWS_BUCKET,
+  static registerAsync(
+    asyncOptions: MediaManagerModuleAsyncOptions,
+  ): DynamicModule {
+    const providers: Provider[] = this.createProviders();
+    const optionProvider: Provider = {
+      provide: MEDIA_MANAGER_OPTIONS,
+      useFactory: asyncOptions.useFactory,
+      inject: asyncOptions.inject || [],
     };
 
-    const providers: Provider[] = [
-      {
-        provide: LocalStorageAdapter,
-        useFactory: () => new LocalStorageAdapter(localRoot),
-      },
-      {
-        provide: S3_CLIENT,
-        useFactory: () =>
-          new S3Client({
-            region: awsOptions.region,
-            endpoint: awsOptions.endpoint,
-            credentials:
-              awsOptions.accessKeyId && awsOptions.secretAccessKey
-                ? {
-                    accessKeyId: awsOptions.accessKeyId,
-                    secretAccessKey: awsOptions.secretAccessKey,
-                  }
-                : undefined,
-          }),
-      },
-      {
-        provide: S3StorageAdapter,
-        useFactory: (client: S3Client) =>
-          new S3StorageAdapter(client, awsOptions.bucket ?? ''),
-        inject: [S3_CLIENT],
-      },
-      {
-        provide: MediaManagerService,
-        useFactory: (
-          localAdapter: LocalStorageAdapter,
-          s3Adapter: S3StorageAdapter,
-        ) => new MediaManagerService(localAdapter, s3Adapter),
-        inject: [LocalStorageAdapter, S3StorageAdapter],
-      },
-    ];
-
     return {
       module: MediaManagerModule,
-      providers,
+      imports: asyncOptions.imports || [],
+      providers: [optionProvider, ...providers],
       exports: [MediaManagerService],
     };
   }
+
+  private static createProviders(): Provider[] {
+    const localStorageProvider: Provider = {
+      provide: LocalStorageAdapter,
+      useFactory: (options: MediaManagerModuleOptions) =>
+        new LocalStorageAdapter(options.localRoot),
+      inject: [MEDIA_MANAGER_OPTIONS],
+    };
+
+    const s3ClientProvider: Provider = {
+      provide: S3_CLIENT,
+      useFactory: (options: MediaManagerModuleOptions) =>
+        new S3Client({
+          region: options.aws?.region,
+          endpoint: options.aws?.endpoint,
+          credentials:
+            options.aws?.accessKeyId && options.aws?.secretAccessKey
+              ? {
+                  accessKeyId: options.aws.accessKeyId,
+                  secretAccessKey: options.aws.secretAccessKey,
+                }
+              : undefined,
+        }),
+      inject: [MEDIA_MANAGER_OPTIONS],
+    };
+
+    const s3StorageAdapterProvider: Provider = {
+      provide: S3StorageAdapter,
+      useFactory: (client: S3Client, options: MediaManagerModuleOptions) =>
+        new S3StorageAdapter(client, options.aws?.bucket ?? ''),
+      inject: [S3_CLIENT, MEDIA_MANAGER_OPTIONS],
+    };
+
+    const serviceProvider: Provider = {
+      provide: MediaManagerService,
+      useFactory: (
+        localAdapter: LocalStorageAdapter,
+        s3Adapter: S3StorageAdapter,
+      ) => new MediaManagerService(localAdapter, s3Adapter),
+      inject: [LocalStorageAdapter, S3StorageAdapter],
+    };
+
+    return [
+      localStorageProvider,
+      s3ClientProvider,
+      s3StorageAdapterProvider,
+      serviceProvider,
+    ];
+  }
 }

+ 89 - 0
libs/core/src/sys-config/sys-config-reader.service.ts

@@ -0,0 +1,89 @@
+import { Injectable } from '@nestjs/common';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+
+type SysConfigDoc = {
+  _id: number;
+  appConfig?: Record<string, unknown>;
+  imageConfig?: ImageConfig;
+  provider?: ProviderConfig;
+};
+
+type ImageConfig = {
+  s3Enabled?: boolean;
+  storageStrategy?: string;
+  local?: {
+    rootPath?: string;
+    baseUrl?: string;
+  };
+  limitsMb?: {
+    image?: number;
+    video?: number;
+  };
+  s3?: {
+    accessKeyId?: string;
+    secretAccessKey?: string;
+    bucket?: string;
+    region?: string;
+    endpointUrl?: string;
+    imageBaseUrl?: string;
+  };
+};
+
+type ProviderConfig = {
+  providerCode?: string;
+  apiUrl?: string;
+  itemsLimit?: number;
+};
+
+type MongoFindResult<T> = {
+  cursor: {
+    firstBatch: T[];
+  };
+};
+
+function isMongoFindResult<T>(value: unknown): value is MongoFindResult<T> {
+  if (typeof value !== 'object' || value === null) {
+    return false;
+  }
+
+  const anyValue = value as { cursor?: { firstBatch?: unknown } };
+  return Array.isArray(anyValue.cursor?.firstBatch);
+}
+
+/**
+ * This is the only allowed access point for the `sysConfig` document.
+ * No other modules or services should read `sysConfig` directly.
+ */
+@Injectable()
+export class SysConfigReaderService {
+  constructor(private readonly prisma: MongoPrismaService) {}
+
+  async getAppConfig(): Promise<Record<string, unknown>> {
+    const doc = await this.fetchDoc();
+    return doc?.appConfig ?? {};
+  }
+
+  async getImageConfig(): Promise<ImageConfig> {
+    const doc = await this.fetchDoc();
+    return doc?.imageConfig ?? {};
+  }
+
+  async getProviderConfig(): Promise<ProviderConfig> {
+    const doc = await this.fetchDoc();
+    return doc?.provider ?? {};
+  }
+
+  private async fetchDoc(): Promise<SysConfigDoc | undefined> {
+    const raw: unknown = await this.prisma.$runCommandRaw({
+      find: 'sysConfig',
+      filter: { _id: -1 },
+      limit: 1,
+    });
+
+    if (!isMongoFindResult<SysConfigDoc>(raw)) {
+      return undefined;
+    }
+
+    return raw.cursor.firstBatch[0];
+  }
+}

+ 10 - 0
libs/core/src/sys-config/sys-config.module.ts

@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { PrismaModule } from '@box/db/prisma/prisma.module';
+import { SysConfigReaderService } from './sys-config-reader.service';
+
+@Module({
+  imports: [PrismaModule],
+  providers: [SysConfigReaderService],
+  exports: [SysConfigReaderService],
+})
+export class SysConfigModule {}

+ 2 - 1
package.json

@@ -21,6 +21,7 @@
     "prisma:seed:box-admin": "dotenv -e .env -- ts-node -P tsconfig.seed.json prisma/mongo/seed.ts",
     "prisma:seed:box-stats": "dotenv -e .env -- ts-node -P tsconfig.seed.json prisma/mongo-stats/seed.ts",
     "prisma:seed:box-admin:ads": "dotenv -e .env -- ts-node -T prisma/mongo/seed-ads.ts",
+    "prisma:seed:box-admin:sys-config": "dotenv -e .env -- ts-node -P tsconfig.seed.json prisma/mongo/seed-sys-config.ts",
     "prisma:seed:box-admin:ads:clean": "dotenv -e .env -- ts-node -T prisma/mongo/seed-ads.ts --clean",
     "seed:ads": "dotenv -e .env -- ts-node -T prisma/seed-ads.ts",
     "seed:ads:clean": "dotenv -e .env -- ts-node -T prisma/seed-ads.ts --clean",
@@ -127,4 +128,4 @@
     "tsx": "^4.20.6",
     "typescript": "^5.4.5"
   }
-}
+}

+ 47 - 0
prisma/mongo/README.md

@@ -0,0 +1,47 @@
+# Mongo seeds
+
+## sysConfig helper
+
+`pnpm prisma:seed:box-admin:sys-config` upserts the singleton `{ _id: -1 }` document so that `appConfig.imageCdn`, `imageConfig`, and `provider` are populated for `box-app-api`. The ts-node script is `ts-node -P tsconfig.seed.json prisma/mongo/seed-sys-config.ts`.
+
+Generated document shape:
+
+```json
+{
+  "_id": -1,
+  "appConfig": {
+    "imageCdn": {
+      "s3": "...",
+      "local": "..."
+    }
+  },
+  "imageConfig": {
+    "s3Enabled": true,
+    "storageStrategy": "S3_AND_LOCAL",
+    "local": {
+      "rootPath": "something (e.g. /opt/app/node/ww-images)",
+      "baseUrl": "https://man.boxt3yk.com/images"
+    },
+    "limitsMb": { "image": 10, "video": 100 },
+    "s3": {
+      "accessKeyId": "...",
+      "secretAccessKey": "...",
+      "bucket": "...",
+      "region": "ap-east-1",
+      "endpointUrl": "https://s3.ap-east-1.amazonaws.com",
+      "imageBaseUrl": "https://s3.ap-east-1.amazonaws.com/mybucket-imgs"
+    }
+  },
+  "provider": {
+    "providerCode": "PARTNER",
+    "apiUrl": "https://wwapi.hxc1t.com",
+    "itemsLimit": 100
+  }
+}
+```
+
+Equivalent `mongosh` call:
+
+```
+mongosh --eval "db.sysConfig.updateOne({ _id: -1 }, { $set: { appConfig: { imageCdn: { s3: 'https://s3.ap-east-1.amazonaws.com/mybucket-imgs', local: 'https://man.boxt3yk.com/' } }, imageConfig: { s3Enabled: true, storageStrategy: 'S3_AND_LOCAL', local: { rootPath: '/opt/app/node/ww-images', baseUrl: 'https://man.boxt3yk.com/images' }, limitsMb: { image: 10, video: 100 }, s3: { accessKeyId: 'AKIA6GSNGR5PISMIKCJ4', secretAccessKey: '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', bucket: 'mybucket-imgs', region: 'ap-east-1', endpointUrl: 'https://s3.ap-east-1.amazonaws.com', imageBaseUrl: 'https://s3.ap-east-1.amazonaws.com/mybucket-imgs' } }, provider: { providerCode: 'PARTNER', apiUrl: 'https://wwapi.hxc1t.com', itemsLimit: 100 } } }, { upsert: true, multi: false })"
+```

+ 2 - 2
prisma/mongo/seed-admin.ts

@@ -38,8 +38,8 @@ async function seedSysConfig(): Promise<void> {
               storageStrategy: 'LOCAL_ONLY' as ImgSource,
               local: {
                 rootPath: '/opt/app/node/ww-images',
-                baseUrl: 'https://mgnt.cqf.wang/images',
-                chatUpload: 'https://mgnt.cqf.wang/api/chat/upload',
+                baseUrl: 'https://man.boxt3yk.com/images',
+                chatUpload: 'https://man.boxt3yk.com/api/chat/upload',
               },
               limitsMb: { image: 10, video: 100 },
               s3: {

+ 67 - 0
prisma/mongo/seed-sys-config.ts

@@ -0,0 +1,67 @@
+// prisma/mongo/seed-sys-config.ts
+import { PrismaClient } from '@prisma/mongo/client';
+
+const prisma = new PrismaClient();
+
+async function main() {
+  await prisma.$runCommandRaw({
+    update: 'sysConfig',
+    updates: [
+      {
+        q: { _id: -1 },
+        u: {
+          $set: {
+            appConfig: {
+              videoCdn: {
+                image: 'https://vm.rvakc.xyz/res/decode',
+                video: 'https://vm.rvakc.xyz/api/web/media/m3u8/',
+              },
+              adsCdn: {
+                s3: 'https://s3.ap-east-1.amazonaws.com/mybucket-imgs',
+                local: 'https://man.boxt3yk.com/images',
+              },
+            },
+            imageConfig: {
+              s3Enabled: true,
+              storageStrategy: 'S3_AND_LOCAL',
+              local: {
+                rootPath: '/opt/app/node/ww-images',
+                baseUrl: 'https://man.boxt3yk.com/images',
+              },
+              limitsMb: {
+                image: 10,
+                video: 100,
+              },
+              s3: {
+                accessKeyId: 'AKIA6GSNGR5PISMIKCJ4',
+                secretAccessKey: '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',
+                bucket: 'mybucket-imgs',
+                region: 'ap-east-1',
+                endpointUrl: 'https://s3.ap-east-1.amazonaws.com',
+                imageBaseUrl:
+                  'https://s3.ap-east-1.amazonaws.com/mybucket-imgs',
+              },
+            },
+            provider: {
+              providerCode: 'PARTNER',
+              apiUrl: 'https://wwapi.hxc1t.com',
+              itemsLimit: 100,
+            },
+          },
+        },
+        upsert: true,
+        multi: false,
+      },
+    ],
+  });
+  console.log('sysConfig upserted with appConfig.imageCdn');
+}
+
+main()
+  .catch((err) => {
+    console.error('Failed to upsert sysConfig:', err);
+    process.exitCode = 1;
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+  });