Kaynağa Gözat

refactor: consolidate image upload functionality into media manager

- Removed AdsImageService and its related methods for uploading ad covers.
- Updated AdsImageController to use AdsService for cover image uploads.
- Removed ImageUploadModule and its dependencies from FeatureModule.
- Integrated media upload logic into VideoMediaService using MediaManagerService.
- Added MediaManagerModule to handle local and S3 storage strategies.
- Implemented LocalStorageAdapter and S3StorageAdapter for file handling.
- Introduced validation for relative paths in uploads.
- Updated seeding script to include new configuration for image uploads.
Dave 3 ay önce
ebeveyn
işleme
1740f2d3d8

+ 1 - 2
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.controller.ts

@@ -9,7 +9,6 @@ import {
   Put,
   Req,
 } from '@nestjs/common';
-import type { FastifyRequest } from 'fastify';
 import {
   ApiBody,
   ApiConsumes,
@@ -17,6 +16,7 @@ import {
   ApiResponse,
   ApiTags,
 } from '@nestjs/swagger';
+import type { FastifyRequest } from 'fastify';
 import { CreateAdsDto, ListAdsDto, UpdateAdsDto, AdsDto } from './ads.dto';
 import { AdsService } from './ads.service';
 import { MongoIdParamDto } from '../common/mongo-id.dto';
@@ -106,7 +106,6 @@ export class AdsController {
     @Param() { id }: MongoIdParamDto,
     @Req() req: FastifyRequest,
   ) {
-    // Use Fastify multipart instead of Express multer
     const mpFile = await (req as any).file();
 
     if (!mpFile) {

+ 26 - 15
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.module.ts

@@ -1,31 +1,42 @@
 import { Module } from '@nestjs/common';
 import { PrismaModule } from '@box/db/prisma/prisma.module';
 import { CacheSyncModule } from '../../../cache-sync/cache-sync.module';
-import { ImageUploadModule } from '../image-upload/image-upload.module';
 import { AdsService } from './ads.service';
 import { AdsController } from './ads.controller';
-import { AdsImageService } from './image/ads-image.service';
-import { LocalImageStorageService } from './image/storage/local-image-storage.service';
-import { S3ImageStorageService } from './image/storage/s3-image-storage.service';
 import { ImageUrlBuilderService } from './image/image-url-builder.service';
 import { AdsImageController } from './image/ads-image.controller';
+import { MediaManagerModule } from '@box/core/media-manager/media-manager.module';
+import { MEDIA_STORAGE_STRATEGY } from '../../../shared/tokens';
+
+const awsEnabled = process.env.BOX_IMAGE_S3_ENABLED === 'true';
+
+const mediaManagerModule = MediaManagerModule.register({
+  localRoot: process.env.BOX_IMAGE_LOCAL_ROOT || '/tmp/box-images',
+  aws: awsEnabled
+    ? {
+        region: process.env.AWS_S3_REGION_NAME,
+        endpoint: process.env.AWS_S3_ENDPOINT_URL,
+        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
+        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
+        bucket: process.env.AWS_STORAGE_BUCKET_NAME,
+      }
+    : undefined,
+});
 
 @Module({
-  imports: [PrismaModule, CacheSyncModule, ImageUploadModule],
+  imports: [PrismaModule, CacheSyncModule, mediaManagerModule],
   providers: [
     AdsService,
-    AdsImageService,
-    LocalImageStorageService,
-    S3ImageStorageService,
     ImageUrlBuilderService,
+    {
+      provide: MEDIA_STORAGE_STRATEGY,
+      useValue:
+        process.env.BOX_IMAGE_S3_ENABLED === 'true'
+          ? 'S3_AND_LOCAL'
+          : 'LOCAL_ONLY',
+    },
   ],
   controllers: [AdsController, AdsImageController],
-  exports: [
-    AdsService,
-    AdsImageService,
-    LocalImageStorageService,
-    S3ImageStorageService,
-    ImageUrlBuilderService,
-  ],
+  exports: [AdsService, ImageUrlBuilderService],
 })
 export class AdsModule {}

+ 55 - 36
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.service.ts

@@ -4,14 +4,12 @@ import {
   BadRequestException,
   NotFoundException,
   Logger,
+  Inject,
 } from '@nestjs/common';
 import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import { CacheSyncService } from '../../../cache-sync/cache-sync.service';
-import { ImageUploadService } from '../image-upload/image-upload.service';
 import type { MultipartFile } from '@fastify/multipart';
-import * as fs from 'fs/promises';
-import * as path from 'path';
 import {
   CreateAdsDto,
   ListAdsDto,
@@ -20,9 +18,12 @@ import {
 } from './ads.dto';
 import { CommonStatus } from '../common/status.enum';
 import { ImageUrlBuilderService } from './image/image-url-builder.service';
+import { MediaManagerService } from '@box/core/media-manager/media-manager.service';
+import { StorageStrategy } from '@box/core/media-manager/types';
+import { MEDIA_STORAGE_STRATEGY } from '../../../shared/tokens';
+import { randomUUID } from 'crypto';
 import type { AdType as PrismaAdType } from '@prisma/mongo/client';
 import { nowSecBigInt, toSecBigInt } from '@box/common/time/time.util';
-
 /**
  * MIGRATION NOTES:
  * - Ads previously referenced `adsModuleId` with a relation; the new schema stores `adType` as an enum and no longer joins `AdsModule`.
@@ -36,7 +37,9 @@ export class AdsService {
   constructor(
     private readonly mongoPrismaService: MongoPrismaService,
     private readonly cacheSyncService: CacheSyncService,
-    private readonly imageUploadService: ImageUploadService,
+    private readonly mediaManagerService: MediaManagerService,
+    @Inject(MEDIA_STORAGE_STRATEGY)
+    private readonly mediaStorageStrategy: StorageStrategy,
     private readonly imageUrlBuilderService: ImageUrlBuilderService,
   ) {}
 
@@ -342,50 +345,66 @@ export class AdsService {
    * Deletes old image file before uploading new one.
    */
   async updateAdsCover(id: string, file: MultipartFile) {
-    // Ensure ad exists
-    const ad = await this.mongoPrismaService.ads.findUnique({
-      where: { id },
+    const ad = await this.mongoPrismaService.ads.findUnique({ where: { id } });
+    if (!ad) throw new NotFoundException('Ads not found');
+
+    const previous = {
+      path: ad.adsCoverImg,
+      strategy: ad.imgSource as StorageStrategy | undefined,
+    };
+
+    const filename = this.sanitizeFilename(file.filename);
+    const relativePath = this.buildRelativePath('ads', 'images', id, filename);
+    const strategy = this.mediaStorageStrategy;
+
+    const uploadResult = await this.mediaManagerService.upload({
+      storageStrategy: strategy,
+      relativePath: [relativePath],
+      localStoragePrefix: 'local',
+      fileStreams: [file.file],
     });
-    if (!ad) {
-      throw new NotFoundException('Ads not found');
-    }
 
-    // Delete old image file if exists
-    if (ad.adsCoverImg && ad.imgSource === 'LOCAL_ONLY') {
-      const localRoot = process.env.BOX_IMAGE_LOCAL_ROOT || '/tmp/box-images';
-      const oldFilePath = path.join(localRoot, ad.adsCoverImg);
-
-      try {
-        await fs.unlink(oldFilePath);
-        console.log(`[AdsService] Deleted old cover image: ${oldFilePath}`);
-      } catch (err) {
-        // Log error but don't fail the upload
-        console.warn(
-          `[AdsService] Failed to delete old image: ${oldFilePath}`,
-          err,
-        );
-      }
+    if (uploadResult.status !== 1) {
+      throw new BadRequestException('Failed to upload cover image');
     }
 
-    // Upload new image
-    const { key, imgSource } = await this.imageUploadService.uploadCoverImage(
-      'ads-cover',
-      file,
-    );
-
-    // Update Ads record
     const updated = await this.mongoPrismaService.ads.update({
       where: { id },
       data: {
-        adsCoverImg: key,
-        imgSource,
+        adsCoverImg: relativePath,
+        imgSource: uploadResult.storageStrategy,
         updateAt: this.nowSeconds(),
       },
     });
 
-    // Schedule cache refresh
     await this.cacheSyncService.scheduleAdRefresh(id, ad.adType);
+    await this.cleanupPreviousCover(previous);
 
     return updated;
   }
+
+  private async cleanupPreviousCover(previous: {
+    path?: string | null;
+    strategy?: StorageStrategy;
+  }) {
+    if (!previous.path || !previous.strategy) return;
+    await this.mediaManagerService
+      .cleanup(previous.strategy, [previous.path], 'local')
+      .catch(() => undefined);
+  }
+
+  private buildRelativePath(
+    domain: string,
+    type: 'images' | 'videos' | 'others',
+    id: string,
+    filename: string,
+  ): string {
+    return `${domain}/${type}/${id}/${filename}`;
+  }
+
+  private sanitizeFilename(name?: string | null): string {
+    const raw = (name || 'file').trim();
+    const cleaned = raw.replace(/[\\/]+/g, '');
+    return cleaned || `${randomUUID()}.jpg`;
+  }
 }

+ 3 - 38
apps/box-mgnt-api/src/mgnt-backend/feature/ads/image/ads-image.controller.ts

@@ -7,55 +7,20 @@ import {
   Req,
 } from '@nestjs/common';
 import type { FastifyRequest } from 'fastify';
-import * as fs from 'fs';
-import * as path from 'path';
-import { pipeline } from 'stream/promises';
-import { v4 as uuid } from 'uuid';
-import { AdsImageService } from './ads-image.service';
+import { AdsService } from '../ads.service';
 
 @Controller('ads-legacy')
 export class AdsImageController {
-  constructor(private readonly adsImageService: AdsImageService) {}
+  constructor(private readonly adsService: AdsService) {}
 
   @Post(':id/cover')
   async uploadCover(@Param('id') id: string, @Req() req: FastifyRequest) {
-    // fastify-multipart attaches .file() to request
     const mpFile = await (req as any).file();
 
     if (!mpFile) {
       throw new BadRequestException('No file uploaded');
     }
 
-    const allowed = ['image/jpeg', 'image/png', 'image/webp'];
-    if (!allowed.includes(mpFile.mimetype)) {
-      throw new BadRequestException('仅支持 JPG / PNG / WEBP 格式的图片');
-    }
-
-    const rootPath = process.env.IMAGE_ROOT_PATH || '/data/box-images';
-    const adsFolder = process.env.IMAGE_ADS_SUBFOLDER || 'ads-cover';
-
-    const now = new Date();
-    const year = now.getFullYear();
-    const month = String(now.getMonth() + 1).padStart(2, '0');
-    const day = String(now.getDate()).padStart(2, '0');
-
-    const folder = path.join(rootPath, adsFolder, year.toString(), month, day);
-    fs.mkdirSync(folder, { recursive: true });
-
-    const ext = path.extname(mpFile.filename || '').toLowerCase() || '.bin';
-    const filename = `${uuid()}${ext}`;
-    const absPath = path.join(folder, filename);
-
-    // mpFile.file is a stream (Readable)
-    await pipeline(mpFile.file, fs.createWriteStream(absPath));
-
-    const result = await this.adsImageService.uploadAdCover(id, absPath);
-
-    return {
-      id: result.id,
-      imgSource: result.imgSource,
-      adsCoverImg: result.adsCoverImg,
-      adsCoverImgUrl: result.adsCoverImgUrl,
-    };
+    return this.adsService.updateAdsCover(id, mpFile);
   }
 }

+ 0 - 87
apps/box-mgnt-api/src/mgnt-backend/feature/ads/image/ads-image.service.ts

@@ -1,87 +0,0 @@
-// ads-image.service.ts
-import { Injectable, NotFoundException } from '@nestjs/common';
-import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
-import { LocalImageStorageService } from './storage/local-image-storage.service';
-import { S3ImageStorageService } from './storage/s3-image-storage.service';
-import { ImageUrlBuilderService } from '../image/image-url-builder.service';
-import { nowSecBigInt } from '@box/common/time/time.util';
-
-interface UploadAdCoverResult {
-  id: string;
-  adsCoverImg: string;
-  imgSource: 'PROVIDER' | 'LOCAL_ONLY' | 'S3_ONLY' | 'S3_AND_LOCAL';
-  adsCoverImgUrl: string | null;
-}
-
-@Injectable()
-export class AdsImageService {
-  constructor(
-    private readonly mongo: MongoPrismaService,
-    private readonly local: LocalImageStorageService,
-    private readonly s3: S3ImageStorageService,
-    private readonly imageUrlBuilder: ImageUrlBuilderService,
-  ) {}
-
-  async uploadAdCover(
-    adId: string,
-    savedAbsPath: string,
-  ): Promise<UploadAdCoverResult> {
-    const ad = await this.mongo.ads.findUnique({ where: { id: adId } });
-
-    if (!ad) {
-      this.local.deleteByAbsolutePath(savedAbsPath);
-      throw new NotFoundException('Ads record not found.');
-    }
-
-    const oldKey = ad.adsCoverImg;
-    const oldImgSource = ad.imgSource;
-
-    // 1. Derive relative key from saved absolute path
-    const newKey = this.local.getRelativeKeyFromAbsolutePath(savedAbsPath);
-
-    // 2. Update Ads record to LOCAL_ONLY
-    const now = nowSecBigInt();
-    const updated = await this.mongo.ads.update({
-      where: { id: adId },
-      data: {
-        adsCoverImg: newKey,
-        imgSource: 'LOCAL_ONLY',
-        updateAt: now,
-      },
-    });
-
-    // 3. Cleanup old images
-    if (oldKey && oldKey !== newKey) {
-      this.local.deleteLocal(oldKey);
-
-      if (oldImgSource === 'S3_AND_LOCAL' || oldImgSource === 'S3_ONLY') {
-        this.s3.delete(oldKey);
-      }
-    }
-
-    // 4. Background S3 upload
-    if (this.s3.isEnabled()) {
-      this.s3.upload(newKey, savedAbsPath).then(async (success) => {
-        if (success) {
-          await this.mongo.ads.update({
-            where: { id: adId },
-            data: { imgSource: 'S3_AND_LOCAL' },
-          });
-        }
-      });
-    }
-
-    // 5. Build URL based on current state (LOCAL_ONLY)
-    const coverUrl = this.imageUrlBuilder.buildAdsCoverUrl({
-      adsCoverImg: updated.adsCoverImg,
-      imgSource: updated.imgSource,
-    });
-
-    return {
-      id: updated.id,
-      adsCoverImg: updated.adsCoverImg!,
-      imgSource: updated.imgSource,
-      adsCoverImgUrl: coverUrl,
-    };
-  }
-}

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

@@ -12,7 +12,6 @@ import { ChannelModule } from './channel/channel.module';
 import { TagModule } from './tag/tag.module';
 import { VideoMediaModule } from './video-media/video-media.module';
 import { HealthModule } from './health/health.module';
-import { ImageUploadModule } from './image-upload/image-upload.module';
 
 @Module({
   imports: [
@@ -28,7 +27,6 @@ import { ImageUploadModule } from './image-upload/image-upload.module';
     ProviderVideoSyncModule,
     VideoMediaModule,
     HealthModule,
-    ImageUploadModule,
   ],
 })
 export class FeatureModule {}

+ 4 - 157
apps/box-mgnt-api/src/mgnt-backend/feature/image-upload/image-upload.service.ts

@@ -1,165 +1,12 @@
-import { Injectable, BadRequestException } from '@nestjs/common';
-import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
-import { Logger } from 'nestjs-pino';
-import { mkdir, writeFile } from 'fs/promises';
-import { randomUUID } from 'crypto';
-import * as path from 'path';
-import { encryptImageWithHeader } from '@box/common/utils/image-lib';
+import { Injectable } from '@nestjs/common';
 import type { MultipartFile } from '@fastify/multipart';
 
 type ImageType = 'ads-cover' | 'video-cover';
 
-interface UploadResult {
-  key: string;
-  imgSource: 'LOCAL_ONLY' | 'S3_AND_LOCAL';
-}
-
 @Injectable()
 export class ImageUploadService {
-  private readonly s3Client?: S3Client;
-  private readonly localRoot: string;
-  private readonly s3Enabled: boolean;
-  private readonly s3Bucket?: string;
-
-  constructor(private readonly logger: Logger) {
-    this.localRoot = process.env.BOX_IMAGE_LOCAL_ROOT || '/tmp/box-images';
-    this.s3Enabled = process.env.BOX_IMAGE_S3_ENABLED === 'true';
-
-    if (this.s3Enabled) {
-      this.s3Bucket = process.env.AWS_STORAGE_BUCKET_NAME;
-      if (!this.s3Bucket) {
-        this.logger.warn(
-          '[ImageUploadService] S3 enabled but AWS_STORAGE_BUCKET_NAME not set',
-        );
-      }
-      this.s3Client = new S3Client({
-        region: process.env.AWS_S3_REGION_NAME,
-        credentials: {
-          accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
-          secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
-        },
-        endpoint: process.env.AWS_S3_ENDPOINT_URL,
-      });
-    }
-  }
-
-  /**
-   * Upload a cover image WITHOUT encryption for management API.
-   * Management API serves to admins only, so encryption is not needed.
-   * Stores locally, optionally uploads to S3, and returns storage metadata.
-   */
-  async uploadCoverImage(
-    type: ImageType,
-    file: MultipartFile,
-  ): Promise<UploadResult> {
-    this.validateMimeType(file.mimetype);
-
-    const key = this.buildStorageKey(type, file.filename);
-
-    // Convert stream to buffer
-    const chunks: Buffer[] = [];
-    for await (const chunk of file.file) {
-      chunks.push(chunk);
-    }
-    const buffer = Buffer.concat(chunks);
-
-    // NOTE: No encryption for management API - admins can view directly
-    // const encryptedBuffer = encryptImageWithHeader(buffer);
-
-    // Write to local filesystem (unencrypted)
-    await this.writeLocal(key, buffer);
-
-    // Optionally upload to S3 (also unencrypted for management API)
-    let s3Success = false;
-    if (this.s3Enabled && this.s3Client && this.s3Bucket) {
-      s3Success = await this.uploadToS3(key, buffer, file.mimetype);
-    }
-
-    return {
-      key,
-      imgSource: s3Success ? 'S3_AND_LOCAL' : 'LOCAL_ONLY',
-    };
-  }
-
-  /**
-   * Build canonical storage key: <folder>/<yyyy>/<MM>/<dd>/<uuid>.<ext>
-   */
-  private buildStorageKey(type: ImageType, originalName: string): string {
-    const folder = type === 'ads-cover' ? 'ads-cover' : 'video-cover';
-    const now = new Date();
-    const yyyy = now.getFullYear();
-    const MM = String(now.getMonth() + 1).padStart(2, '0');
-    const dd = String(now.getDate()).padStart(2, '0');
-    const uuid = randomUUID();
-    const ext = path.extname(originalName).toLowerCase() || '.jpg';
-    return `${folder}/${yyyy}/${MM}/${dd}/${uuid}${ext}`;
-  }
-
-  /**
-   * Validate that the file is an image.
-   */
-  private validateMimeType(mimetype: string): void {
-    const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
-    if (!allowed.includes(mimetype.toLowerCase())) {
-      throw new BadRequestException(
-        `Invalid image type: ${mimetype}. Allowed: ${allowed.join(', ')}`,
-      );
-    }
-  }
-
-  /**
-   * Write encrypted buffer to local filesystem.
-   */
-  private async writeLocal(key: string, buffer: Uint8Array): Promise<void> {
-    const fullPath = path.join(this.localRoot, key);
-    const dir = path.dirname(fullPath);
-
-    try {
-      await mkdir(dir, { recursive: true });
-      await writeFile(fullPath, buffer);
-      this.logger.log(
-        { key, path: fullPath },
-        '[ImageUploadService] Wrote local file',
-      );
-    } catch (err) {
-      this.logger.error(
-        { err, key, path: fullPath },
-        '[ImageUploadService] Failed to write local file',
-      );
-      throw new BadRequestException('Failed to save image locally');
-    }
-  }
-
-  /**
-   * Upload encrypted buffer to S3.
-   * Returns true on success, false on failure (logged, but non-blocking).
-   */
-  private async uploadToS3(
-    key: string,
-    buffer: Uint8Array,
-    mimetype: string,
-  ): Promise<boolean> {
-    if (!this.s3Client || !this.s3Bucket) return false;
-
-    try {
-      const command = new PutObjectCommand({
-        Bucket: this.s3Bucket,
-        Key: key,
-        Body: buffer,
-        ContentType: mimetype,
-      });
-      await this.s3Client.send(command);
-      this.logger.log(
-        { key, bucket: this.s3Bucket },
-        '[ImageUploadService] Uploaded to S3',
-      );
-      return true;
-    } catch (err) {
-      this.logger.error(
-        { err, key, bucket: this.s3Bucket },
-        '[ImageUploadService] S3 upload failed',
-      );
-      return false;
-    }
+  async uploadCoverImage(type: ImageType, file: MultipartFile) {
+    // Replaced by libs/core/media-upload
+    throw new Error('NOT_IMPLEMENTED_MEDIA_UPLOAD');
   }
 }

+ 1 - 2
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.controller.ts

@@ -7,9 +7,9 @@ import {
   Patch,
   Body,
   Post,
+  Delete,
   Req,
   BadRequestException,
-  Delete,
 } from '@nestjs/common';
 import type { FastifyRequest } from 'fastify';
 import {
@@ -243,7 +243,6 @@ export class VideoMediaController {
   })
   @Post(':id/cover')
   async updateCover(@Param('id') id: string, @Req() req: FastifyRequest) {
-    // Use Fastify multipart instead of Express multer
     const mpFile = await (req as any).file();
 
     if (!mpFile) {

+ 28 - 3
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.module.ts

@@ -3,12 +3,37 @@ import { VideoMediaService } from './video-media.service';
 import { VideoMediaController } from './video-media.controller';
 import { PrismaModule } from '@box/db/prisma/prisma.module';
 import { CacheSyncModule } from '../../../cache-sync/cache-sync.module';
-import { ImageUploadModule } from '../image-upload/image-upload.module';
+import { MediaManagerModule } from '@box/core/media-manager/media-manager.module';
+import { MEDIA_STORAGE_STRATEGY } from '../../../shared/tokens';
+
+const awsEnabled = process.env.BOX_IMAGE_S3_ENABLED === 'true';
+
+const mediaManagerModule = MediaManagerModule.register({
+  localRoot: process.env.BOX_IMAGE_LOCAL_ROOT || '/tmp/box-images',
+  aws: awsEnabled
+    ? {
+        region: process.env.AWS_S3_REGION_NAME,
+        endpoint: process.env.AWS_S3_ENDPOINT_URL,
+        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
+        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
+        bucket: process.env.AWS_STORAGE_BUCKET_NAME,
+      }
+    : undefined,
+});
 
 @Module({
-  imports: [PrismaModule, CacheSyncModule, ImageUploadModule],
+  imports: [PrismaModule, CacheSyncModule, mediaManagerModule],
+  providers: [
+    VideoMediaService,
+    {
+      provide: MEDIA_STORAGE_STRATEGY,
+      useValue:
+        process.env.BOX_IMAGE_S3_ENABLED === 'true'
+          ? 'S3_AND_LOCAL'
+          : 'LOCAL_ONLY',
+    },
+  ],
   controllers: [VideoMediaController],
-  providers: [VideoMediaService],
   exports: [VideoMediaService],
 })
 export class VideoMediaModule {}

+ 62 - 15
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.service.ts

@@ -2,25 +2,31 @@ import {
   Injectable,
   NotFoundException,
   BadRequestException,
+  Inject,
 } from '@nestjs/common';
 import type { MultipartFile } from '@fastify/multipart';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
-import { ImageUploadService } from '../image-upload/image-upload.service';
 import { CacheSyncService } from '../../../cache-sync/cache-sync.service';
 import { CacheEntityType } from '../../../cache-sync/cache-sync.types';
+import { MediaManagerService } from '@box/core/media-manager/media-manager.service';
+import type { StorageStrategy } from '@box/core/media-manager/types';
+import { randomUUID } from 'crypto';
 import {
   VideoMediaListQueryDto,
   UpdateVideoMediaManageDto,
   UpdateVideoMediaStatusDto,
   BatchUpdateVideoMediaStatusDto,
 } from './video-media.dto';
+import { MEDIA_STORAGE_STRATEGY } from '../../../shared/tokens';
 
 @Injectable()
 export class VideoMediaService {
   constructor(
     private readonly prisma: MongoPrismaService,
-    private readonly imageUploadService: ImageUploadService,
     private readonly cacheSyncService: CacheSyncService,
+    private readonly mediaManagerService: MediaManagerService,
+    @Inject(MEDIA_STORAGE_STRATEGY)
+    private readonly mediaStorageStrategy: StorageStrategy,
   ) {}
 
   async findAll(query: VideoMediaListQueryDto): Promise<any> {
@@ -350,36 +356,50 @@ export class VideoMediaService {
    * Upload and update VideoMedia cover image.
    */
   async updateCover(id: string, file: MultipartFile) {
-    // Ensure video exists
-    const video = await this.prisma.videoMedia.findUnique({
-      where: { id },
-    });
+    const video = await this.prisma.videoMedia.findUnique({ where: { id } });
 
     if (!video) {
       throw new NotFoundException('Video not found');
     }
 
-    // Upload image
-    const { key, imgSource } = await this.imageUploadService.uploadCoverImage(
-      'video-cover',
-      file,
+    const previous = {
+      path: video.coverImg,
+      strategy: video.imgSource as StorageStrategy | undefined,
+    };
+
+    const filename = this.sanitizeFilename(file.filename);
+    const relativePath = this.buildRelativePath(
+      'videos',
+      'images',
+      id,
+      filename,
     );
+    const strategy = this.mediaStorageStrategy;
 
-    const editedAt = BigInt(Date.now());
+    const uploadResult = await this.mediaManagerService.upload({
+      storageStrategy: strategy,
+      relativePath: [relativePath],
+      localStoragePrefix: 'local',
+      fileStreams: [file.file],
+    });
+
+    if (uploadResult.status !== 1) {
+      throw new BadRequestException('Failed to upload cover image');
+    }
+
+    const editedAt = BigInt(Math.floor(Date.now() / 1000));
     const updatedAt = new Date();
 
-    // Update VideoMedia record
     const updated = await this.prisma.videoMedia.update({
       where: { id },
       data: {
-        coverImg: key,
-        imgSource,
+        coverImg: relativePath,
+        imgSource: uploadResult.storageStrategy,
         editedAt,
         updatedAt,
       },
     });
 
-    // Refresh category video lists cache if video has a category
     if (video.categoryIds && video.categoryIds.length > 0) {
       for (const categoryId of video.categoryIds) {
         await this.cacheSyncService.scheduleAction({
@@ -390,6 +410,8 @@ export class VideoMediaService {
       }
     }
 
+    await this.cleanupPreviousCover(previous);
+
     return {
       id: updated.id,
       coverImg: updated.coverImg,
@@ -398,6 +420,31 @@ export class VideoMediaService {
     };
   }
 
+  private async cleanupPreviousCover(previous: {
+    path?: string | null;
+    strategy?: StorageStrategy;
+  }) {
+    if (!previous.path || !previous.strategy) return;
+    await this.mediaManagerService
+      .cleanup(previous.strategy, [previous.path], 'local')
+      .catch(() => undefined);
+  }
+
+  private buildRelativePath(
+    domain: string,
+    type: 'images' | 'videos' | 'others',
+    id: string,
+    filename: string,
+  ): string {
+    return `${domain}/${type}/${id}/${filename}`;
+  }
+
+  private sanitizeFilename(name?: string | null): string {
+    const raw = (name || 'file').trim();
+    const cleaned = raw.replace(/[\\/]+/g, '');
+    return cleaned || `${randomUUID()}.jpg`;
+  }
+
   private async validateCategoryAndTags(
     categoryId: string | null | undefined,
     tagIds: string[] | undefined,

+ 4 - 0
apps/box-mgnt-api/src/shared/tokens.ts

@@ -0,0 +1,4 @@
+import type { StorageStrategy } from '@box/core/media-manager/types';
+
+export const MEDIA_STORAGE_STRATEGY = 'MEDIA_STORAGE_STRATEGY';
+export type MediaStorageStrategyToken = StorageStrategy;

+ 37 - 0
libs/core/media-manager/CONTRACT.md

@@ -0,0 +1,37 @@
+# Media Manager Contract
+
+- `media-manager` is dumb and generic: it only parses multipart streams and writes them to storage.
+- The library never decides filenames, folders, MIME rules, limits, auth, URLs, or database writes.
+- Each caller provides the full relative path (including filename) for every uploaded file, e.g. `tea/452/photo.jpg`.
+
+## Storage Strategies
+
+1. `LOCAL_ONLY` – persist only to local storage using `<localRoot>/<localStoragePrefix>/<relativePath>` (prefix defaults to `local`).
+2. `S3_ONLY` – always stage the file locally, upload the staged file to S3 (key == `relativePath`), and delete the stage on success. Failure rolls back the stage and returns `status=0`.
+3. `S3_AND_LOCAL` – stage locally and try to upload to S3. If S3 succeeds, both storages are considered written (status=1, `storageStrategy='S3_AND_LOCAL'`). If S3 fails, the local stage remains and the result downgrades to `storageStrategy='LOCAL_ONLY'` with `savedPath` pointing at the local file (status=1).
+
+## Inputs & Constraints
+
+- `upload(storageStrategy, relativePath[], localStoragePrefix?)` must include exactly one file stream and one relative path entry. If not, return failure before touching any storage.
+- Every `relativePath` entry must be strictly relative (no leading `/`, drive letters, or `..`) and must include a filename.
+- Mapping is 1:1 in order: `relativePath[i]` corresponds to the `i`th file stream.
+
+## Outputs
+
+```ts
+{
+  status: 0 | 1,
+  storageStrategy: 'LOCAL_ONLY' | 'S3_ONLY' | 'S3_AND_LOCAL',
+  relativePath: string[],
+  savedPath: string // absolute local path or `s3://bucket/key`
+}
+```
+
+- `storageStrategy` should reflect what was actually persisted (downgrading to `LOCAL_ONLY` when S3 fails).
+- `savedPath` is the raw locator returned by the successful adapter: local absolute path for local persistence, or `s3://bucket/key` when S3 won.
+
+## Cleanup
+
+- `cleanup(storageStrategy, relativePath[], localStoragePrefix?)` deletes exactly the requested paths/keys according to the strategy.
+- For `S3_AND_LOCAL`, attempt both deletes; return `status=1` only when every requested deletion succeeded.
+- No recursive deletion, no filesystem traversal beyond resolving `<localStoragePrefix>/<relativePath>`.

+ 60 - 0
libs/core/src/media-manager/adapters/local.adapter.ts

@@ -0,0 +1,60 @@
+import { StorageAdapter, SavedPath } from '../types';
+import { pipeline } from 'stream/promises';
+import * as fs from 'fs';
+import { mkdir, rename, unlink } from 'fs/promises';
+import * as path from 'path';
+
+export class LocalStorageAdapter implements StorageAdapter {
+  constructor(
+    private readonly localRoot: string = process.env.MEDIA_MANAGER_LOCAL_ROOT ||
+      '/data/media',
+  ) {}
+
+  private resolveAbsolutePath(
+    localStoragePrefix: string,
+    relativePath: string,
+  ) {
+    return path.resolve(this.localRoot, localStoragePrefix, relativePath);
+  }
+
+  private tempPath(targetPath: string) {
+    return `${targetPath}.part`;
+  }
+
+  async put(
+    relativePath: string,
+    localStoragePrefix: string,
+    stream: NodeJS.ReadableStream,
+  ): Promise<{ savedPath: SavedPath }> {
+    const targetPath = this.resolveAbsolutePath(
+      localStoragePrefix,
+      relativePath,
+    );
+    const targetDir = path.dirname(targetPath);
+    await mkdir(targetDir, { recursive: true });
+    const stagingPath = this.tempPath(targetPath);
+    const writeStream = fs.createWriteStream(stagingPath, { flags: 'w' });
+    try {
+      await pipeline(stream, writeStream);
+      await rename(stagingPath, targetPath);
+      return { savedPath: targetPath };
+    } catch (err) {
+      await unlink(stagingPath).catch(() => undefined);
+      throw err;
+    }
+  }
+
+  async delete(
+    relativePath: string[],
+    localStoragePrefix: string,
+  ): Promise<void> {
+    for (const relative of relativePath) {
+      const targetPath = this.resolveAbsolutePath(localStoragePrefix, relative);
+      await unlink(targetPath).catch((err) => {
+        if (err.code !== 'ENOENT') {
+          throw err;
+        }
+      });
+    }
+  }
+}

+ 72 - 0
libs/core/src/media-manager/adapters/s3.adapter.ts

@@ -0,0 +1,72 @@
+import { StorageAdapter, SavedPath } from '../types';
+import {
+  S3Client,
+  PutObjectCommand,
+  DeleteObjectCommand,
+} from '@aws-sdk/client-s3';
+import { Readable } from 'stream';
+
+export class S3StorageAdapter implements StorageAdapter {
+  constructor(
+    private readonly client: S3Client,
+    private readonly bucket: string,
+  ) {}
+
+  private cumulativeSavedPath(relativePath: string): SavedPath {
+    return `s3://${this.bucket}/${relativePath}`;
+  }
+
+  async put(
+    relativePath: string,
+    _localStoragePrefix: string,
+    stream: NodeJS.ReadableStream,
+  ): Promise<{ savedPath: SavedPath }> {
+    const command = new PutObjectCommand({
+      Bucket: this.bucket,
+      Key: relativePath,
+      Body: this.ensureNodeReadable(stream),
+    });
+    await this.client.send(command);
+    return { savedPath: this.cumulativeSavedPath(relativePath) };
+  }
+
+  async delete(
+    relativePath: string[],
+    _localStoragePrefix: string,
+  ): Promise<void> {
+    for (const key of relativePath) {
+      const command = new DeleteObjectCommand({
+        Bucket: this.bucket,
+        Key: key,
+      });
+      await this.client.send(command).catch((err) => {
+        if (err.name && err.name === 'NoSuchKey') {
+          return;
+        }
+        throw err;
+      });
+    }
+  }
+
+  private ensureNodeReadable(stream: NodeJS.ReadableStream): Readable {
+    if (stream instanceof Readable) {
+      return stream;
+    }
+
+    const globalReadableStream = (
+      globalThis as typeof globalThis & {
+        ReadableStream?: typeof globalThis.ReadableStream;
+      }
+    ).ReadableStream;
+
+    if (
+      typeof globalReadableStream === 'function' &&
+      stream instanceof globalReadableStream &&
+      typeof Readable.fromWeb === 'function'
+    ) {
+      return Readable.fromWeb(stream as globalThis.ReadableStream);
+    }
+
+    return stream as Readable;
+  }
+}

+ 88 - 0
libs/core/src/media-manager/media-manager.module.ts

@@ -0,0 +1,88 @@
+import { DynamicModule, Module, Provider } from '@nestjs/common';
+import { S3Client } from '@aws-sdk/client-s3';
+import { MediaManagerService } from './media-manager.service';
+import { LocalStorageAdapter } from './adapters/local.adapter';
+import { S3StorageAdapter } from './adapters/s3.adapter';
+
+export interface MediaManagerAwsOptions {
+  region?: string;
+  endpoint?: string;
+  accessKeyId?: string;
+  secretAccessKey?: string;
+  bucket?: string;
+}
+
+export interface MediaManagerModuleOptions {
+  localRoot?: string;
+  aws?: MediaManagerAwsOptions;
+}
+
+const S3_CLIENT = 'MEDIA_MANAGER_S3_CLIENT';
+
+@Module({})
+export class MediaManagerModule {
+  static register(options?: MediaManagerModuleOptions): DynamicModule {
+    const localRoot =
+      options?.localRoot ||
+      process.env.MEDIA_MANAGER_LOCAL_ROOT ||
+      '/data/media';
+
+    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,
+    };
+
+    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,
+      exports: [MediaManagerService],
+    };
+  }
+}

+ 238 - 0
libs/core/src/media-manager/media-manager.service.ts

@@ -0,0 +1,238 @@
+import { createReadStream } from 'fs';
+import type {
+  CleanupResult,
+  MediaManagerService as MediaManagerServiceContract,
+  StorageAdapter,
+  StorageStrategy,
+  UploadInput,
+  UploadResult,
+} from './types';
+import { validateRelativePath } from './validators/path-validator';
+
+export class MediaManagerService implements MediaManagerServiceContract {
+  constructor(
+    private readonly localAdapter: StorageAdapter,
+    private readonly s3Adapter: StorageAdapter,
+  ) {}
+
+  async upload(input: UploadInput): Promise<UploadResult> {
+    const {
+      storageStrategy,
+      relativePath,
+      localStoragePrefix = 'local',
+      fileStreams,
+    } = input;
+
+    if (relativePath.length !== 1 || fileStreams.length !== 1) {
+      return this.buildFailure(storageStrategy, relativePath);
+    }
+
+    const pathEntry = relativePath[0];
+    try {
+      validateRelativePath(pathEntry);
+    } catch {
+      return this.buildFailure(storageStrategy, relativePath);
+    }
+
+    const stream = fileStreams[0];
+    if (!stream) {
+      return this.buildFailure(storageStrategy, relativePath);
+    }
+
+    switch (storageStrategy) {
+      case 'LOCAL_ONLY':
+        return this.uploadLocal(
+          pathEntry,
+          localStoragePrefix,
+          storageStrategy,
+          stream,
+        );
+      case 'S3_ONLY':
+        return this.uploadS3Only(
+          pathEntry,
+          localStoragePrefix,
+          storageStrategy,
+          stream,
+        );
+      case 'S3_AND_LOCAL':
+        return this.uploadS3AndLocal(
+          pathEntry,
+          localStoragePrefix,
+          storageStrategy,
+          stream,
+        );
+      default:
+        return this.buildFailure(storageStrategy, relativePath);
+    }
+  }
+
+  async cleanup(
+    storageStrategy: StorageStrategy,
+    relativePath: string[],
+    localStoragePrefix: string = 'local',
+  ): Promise<CleanupResult> {
+    if (relativePath.length === 0) {
+      return { status: 0 };
+    }
+
+    for (const pathEntry of relativePath) {
+      try {
+        validateRelativePath(pathEntry);
+      } catch {
+        return { status: 0 };
+      }
+    }
+
+    try {
+      switch (storageStrategy) {
+        case 'LOCAL_ONLY':
+          await this.localAdapter.delete(relativePath, localStoragePrefix);
+          return { status: 1 };
+        case 'S3_ONLY':
+          await this.s3Adapter.delete(relativePath, localStoragePrefix);
+          return { status: 1 };
+        case 'S3_AND_LOCAL': {
+          const localResult = await this.localAdapter
+            .delete(relativePath, localStoragePrefix)
+            .then(() => true)
+            .catch(() => false);
+          const s3Result = await this.s3Adapter
+            .delete(relativePath, localStoragePrefix)
+            .then(() => true)
+            .catch(() => false);
+          return { status: localResult && s3Result ? 1 : 0 };
+        }
+        default:
+          return { status: 0 };
+      }
+    } catch {
+      return { status: 0 };
+    }
+  }
+
+  private async uploadLocal(
+    relativePath: string,
+    localStoragePrefix: string,
+    storageStrategy: StorageStrategy,
+    stream: NodeJS.ReadableStream,
+  ): Promise<UploadResult> {
+    try {
+      const { savedPath } = await this.localAdapter.put(
+        relativePath,
+        localStoragePrefix,
+        stream,
+      );
+      return this.buildSuccess(storageStrategy, [relativePath], savedPath);
+    } catch {
+      return this.buildFailure(storageStrategy, [relativePath]);
+    }
+  }
+
+  private async uploadS3Only(
+    relativePath: string,
+    localStoragePrefix: string,
+    storageStrategy: StorageStrategy,
+    stream: NodeJS.ReadableStream,
+  ): Promise<UploadResult> {
+    let localResult;
+    try {
+      localResult = await this.localAdapter.put(
+        relativePath,
+        localStoragePrefix,
+        stream,
+      );
+    } catch {
+      return this.buildFailure(storageStrategy, [relativePath]);
+    }
+
+    let s3Stream: ReturnType<typeof createReadStream> | undefined;
+    let s3SavedPath = '';
+    try {
+      s3Stream = createReadStream(localResult.savedPath);
+      const s3Result = await this.s3Adapter.put(
+        relativePath,
+        localStoragePrefix,
+        s3Stream,
+      );
+      s3SavedPath = s3Result.savedPath;
+    } catch {
+      await this.localAdapter
+        .delete([relativePath], localStoragePrefix)
+        .catch(() => undefined);
+      return this.buildFailure(storageStrategy, [relativePath]);
+    } finally {
+      if (s3Stream) {
+        s3Stream.destroy();
+      }
+    }
+
+    await this.localAdapter
+      .delete([relativePath], localStoragePrefix)
+      .catch(() => undefined);
+    return this.buildSuccess('S3_ONLY', [relativePath], s3SavedPath);
+  }
+
+  private async uploadS3AndLocal(
+    relativePath: string,
+    localStoragePrefix: string,
+    storageStrategy: StorageStrategy,
+    stream: NodeJS.ReadableStream,
+  ): Promise<UploadResult> {
+    let localResult;
+    try {
+      localResult = await this.localAdapter.put(
+        relativePath,
+        localStoragePrefix,
+        stream,
+      );
+    } catch {
+      return this.buildFailure(storageStrategy, [relativePath]);
+    }
+
+    let s3Stream: ReturnType<typeof createReadStream> | undefined;
+    try {
+      s3Stream = createReadStream(localResult.savedPath);
+      await this.s3Adapter.put(relativePath, localStoragePrefix, s3Stream);
+      return this.buildSuccess(
+        'S3_AND_LOCAL',
+        [relativePath],
+        localResult.savedPath,
+      );
+    } catch {
+      return this.buildSuccess(
+        'LOCAL_ONLY',
+        [relativePath],
+        localResult.savedPath,
+      );
+    } finally {
+      if (s3Stream) {
+        s3Stream.destroy();
+      }
+    }
+  }
+
+  private buildFailure(
+    storageStrategy: StorageStrategy,
+    relativePath: string[],
+  ): UploadResult {
+    return {
+      status: 0,
+      storageStrategy,
+      relativePath,
+      savedPath: '',
+    };
+  }
+
+  private buildSuccess(
+    storageStrategy: StorageStrategy,
+    relativePath: string[],
+    savedPath: string,
+  ): UploadResult {
+    return {
+      status: 1,
+      storageStrategy,
+      relativePath,
+      savedPath,
+    };
+  }
+}

+ 75 - 0
libs/core/src/media-manager/types.ts

@@ -0,0 +1,75 @@
+/**
+ * Storage strategy enum for uploads and cleanups.
+ */
+export type StorageStrategy =
+  | 'LOCAL_ONLY'
+  | 'S3_ONLY'
+  | 'S3_AND_LOCAL';
+
+/**
+ * Raw locator returned by adapters. Callers should treat it as an opaque storage reference.
+ */
+export type SavedPath = string;
+
+/**
+ * Input payload for uploads, matching the frozen contract.
+ */
+export interface UploadInput {
+  storageStrategy: StorageStrategy;
+  relativePath: string[];
+  localStoragePrefix?: string;
+  fileStreams: NodeJS.ReadableStream[];
+}
+
+/**
+ * Result returned from upload operations.
+ * `storageStrategy` reflects what actually persisted (e.g., downgrade to `LOCAL_ONLY`
+ * when S3 fails) rather than the originally requested strategy.
+ * `savedPath` is the raw locator for whichever store succeeded: local paths for
+ * local persistence (including the LOCAL_ONLY fallback after S3 failure),
+ * and `s3://bucket/key` when S3 is the effective destination.
+ */
+export interface UploadResult {
+  status: 0 | 1;
+  storageStrategy: StorageStrategy;
+  relativePath: string[];
+  savedPath: SavedPath;
+}
+
+/**
+ * Result returned from cleanup operations.
+ */
+export interface CleanupResult {
+  status: 0 | 1;
+}
+
+/**
+ * Optional service interface outlining upload/cleanup entry points.
+ */
+export interface MediaManagerService {
+  upload(input: UploadInput): Promise<UploadResult>;
+  cleanup(
+    storageStrategy: StorageStrategy,
+    relativePath: string[],
+    localStoragePrefix?: string,
+  ): Promise<CleanupResult>;
+}
+
+/**
+ * Adapter contract used by the orchestrator.
+ */
+export interface StorageAdapter {
+  /**
+   * Writes a stream to storage and returns the raw location.
+   */
+  put(
+    relativePath: string,
+    localStoragePrefix: string,
+    stream: NodeJS.ReadableStream,
+  ): Promise<{ savedPath: SavedPath }>;
+
+  /**
+   * Deletes the given paths/keys. Local adapters use the prefix; S3 adapters ignore it.
+   */
+  delete(relativePath: string[], localStoragePrefix: string): Promise<void>;
+}

+ 27 - 0
libs/core/src/media-manager/validators/path-validator.ts

@@ -0,0 +1,27 @@
+import * as path from 'path';
+
+const WINDOWS_DRIVE = /^[A-Za-z]:/;
+
+export function validateRelativePath(value: string): void {
+  if (typeof value !== 'string' || value.trim() === '') {
+    throw new Error('relativePath must be a non-empty string');
+  }
+
+  if (path.isAbsolute(value) || WINDOWS_DRIVE.test(value)) {
+    throw new Error('relativePath must be relative');
+  }
+
+  if (value.endsWith('/') || value.endsWith('\\')) {
+    throw new Error('relativePath must include a filename');
+  }
+
+  const segments = value.split(/[/\\]+/);
+  if (segments.includes('..')) {
+    throw new Error('relativePath must not traverse directories');
+  }
+
+  const basename = path.basename(value);
+  if (!basename || basename === '.' || basename === '..') {
+    throw new Error('relativePath must include a filename');
+  }
+}

+ 76 - 0
libs/core/src/media-upload/types.ts

@@ -0,0 +1,76 @@
+/**
+ * Supported storage strategies for uploads and cleanup.
+ */
+export enum StorageStrategy {
+  LOCAL_ONLY = 'LOCAL_ONLY',
+  S3_ONLY = 'S3_ONLY',
+  S3_AND_LOCAL = 'S3_AND_LOCAL',
+}
+
+/**
+ * Path convention: local adapters return an absolute filesystem path, S3 adapters
+ * return `s3://bucket/key` URIs.
+ */
+export type SavedPath = string;
+
+/**
+ * Input payload for the shared upload orchestrator.
+ */
+export interface UploadInput {
+  /**
+   * Determines which adapters run (local, S3, or both).
+   */
+  storageStrategy: StorageStrategy;
+  /**
+   * One relative path per uploaded file, always including the filename.
+   */
+  relativePath: string[];
+  /**
+   * Optional prefix for the local adapter (defaults to `local`). Ignored for S3-only uploads.
+   */
+  localStoragePrefix?: string;
+  /**
+   * Streams produced by multipart parsing; must align 1:1 with `relativePath`.
+   */
+  fileStreams: NodeJS.ReadableStream[];
+}
+
+/**
+ * Return value from `upload`; mirrors the frozen contract.
+ */
+export interface UploadResult {
+  status: 0 | 1;
+  storageStrategy: StorageStrategy;
+  relativePath: string[];
+  /**
+   * Each entry corresponds to the storage destination(s) for the matching path. For
+   * dual strategies, adapters may emit one or more entries per file (local first).
+   */
+  savedPath: SavedPath[];
+}
+
+/**
+ * Result from cleanup operations.
+ */
+export interface CleanupResult {
+  status: 0 | 1;
+}
+
+/**
+ * Storage adapter contract used by the orchestrator.
+ */
+export interface StorageAdapter {
+  /**
+   * Writes a single stream to the underlying storage and returns the resolved path.
+   */
+  put(
+    relativePath: string,
+    stream: NodeJS.ReadableStream,
+    localStoragePrefix?: string,
+  ): Promise<SavedPath>;
+
+  /**
+   * Deletes a single resource identified by the relative path.
+   */
+  delete(relativePath: string, localStoragePrefix?: string): Promise<void>;
+}

+ 63 - 0
prisma/mongo/seed-admin.ts

@@ -4,6 +4,64 @@ import type { Prisma } from '@prisma/mongo/client';
 
 const prisma = new PrismaClient();
 
+type ImgSource = 'LOCAL_ONLY' | 'S3_ONLY' | 'S3_AND_LOCAL' | 'PROVIDER';
+
+async function seedSysConfig(): Promise<void> {
+  const nowSec = Math.floor(Date.now() / 1000);
+
+  // Equivalent to:
+  // db.sysConfig.updateOne({ _id: -1 }, { $set: {...} }, { upsert: true })
+  await prisma.$runCommandRaw({
+    update: 'sysConfig',
+    updates: [
+      {
+        q: { _id: -1 },
+        u: {
+          $set: {
+            game: { apiUrl: 'http://119.28.182.132:83', token: '' },
+            imchat: { apiUrl: 'http://172.17.0.1:88', token: '' },
+            mgnt: { apiUrl: 'http://172.17.0.1:83', token: 'nyqFLchjstAR' },
+            partner: {
+              baseUrl: 'https://wwapi.hxc1t.com',
+              signSecret: 'Z3VhbmNpbmV3ZWl4aWFvMTIzNDU2',
+              md5Key:
+                '160360904be3dd23bf4f1278a74196efdbf3f9b834ce883ef6ae09eb05c5c652',
+              itemsLimit: 100,
+              endpoints: {
+                orderAdd: '/open/open/order/add',
+                orderUpdateStatus: '/open/open/order/updateStatus',
+                chatAdd: '/open/order/chat/send',
+              },
+            },
+            upload: {
+              s3Enabled: true,
+              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',
+              },
+              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',
+              },
+            },
+            updatedAt: nowSec,
+          },
+        },
+        upsert: true,
+        multi: false,
+      },
+    ],
+  });
+}
+
 // =============================================================================
 // MENU SEEDS DATA
 // =============================================================================
@@ -1121,9 +1179,14 @@ async function main() {
 
   try {
     // Seed in order: users first, then menus
+    console.log('seeding users...');
     await seedUsers();
     console.log('');
+    console.log('seeding menus...');
     await seedMenus();
+    console.log('');
+    console.log('seeding system config...');
+    await seedSysConfig();
 
     console.log('\n🎉 Database seeding completed successfully!');
   } catch (error) {