Prechádzať zdrojové kódy

feat: enhance image storage and upload functionality

- Added local and S3 image storage configurations in environment files.
- Updated auth module and service to remove unnecessary dependencies on AdService.
- Implemented Fastify multipart file handling for image uploads in AdsController and VideoMediaController.
- Created AdsImageController and AdsImageService for managing ad cover image uploads.
- Introduced LocalImageStorageService and S3ImageStorageService for handling local and S3 storage operations.
- Added image URL building logic in ImageUrlBuilderService.
- Updated AdsService to manage image uploads and deletions, ensuring old images are removed.
- Enhanced error handling and logging for image upload processes.
- Updated package dependencies for UUID support.
Dave 3 mesiacov pred
rodič
commit
c67e8963d6
24 zmenil súbory, kde vykonal 699 pridanie a 61 odobranie
  1. 27 0
      .env.app
  2. 27 0
      .env.app.dev
  3. 29 0
      .env.app.test
  4. 15 5
      .env.mgnt
  5. 12 5
      .env.mgnt.dev
  6. 12 5
      .env.mgnt.test
  7. 0 2
      apps/box-app-api/src/feature/auth/auth.module.ts
  8. 1 5
      apps/box-app-api/src/feature/auth/auth.service.ts
  9. 8 1
      apps/box-mgnt-api/src/main.ts
  10. 9 7
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.controller.ts
  11. 22 2
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.dto.ts
  12. 20 3
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.module.ts
  13. 59 7
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.service.ts
  14. 41 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/image/ads-cover-multer.config.ts
  15. 61 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/image/ads-image.controller.ts
  16. 86 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/image/ads-image.service.ts
  17. 97 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/image/image-url-builder.service.ts
  18. 42 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/image/storage/local-image-storage.service.ts
  19. 80 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/image/storage/s3-image-storage.service.ts
  20. 19 8
      apps/box-mgnt-api/src/mgnt-backend/feature/image-upload/image-upload.service.ts
  21. 8 10
      apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.controller.ts
  22. 2 1
      apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.service.ts
  23. 2 0
      package.json
  24. 20 0
      pnpm-lock.yaml

+ 27 - 0
.env.app

@@ -57,3 +57,30 @@ RABBITMQ_STATS_AD_IMPRESSION_ROUTING_KEY="stats.ad.impression"
 RECOMMENDATION_CHANNEL_BOOST=1.1
 # Minimum candidates from tags before falling back to global set
 RECOMMENDATION_MIN_CANDIDATES_BEFORE_FALLBACK=5
+
+# LOCAL IMAGE STORAGE 配置
+IMAGE_ROOT_PATH=/media/dave/DAVEWORKS/works/fctech.my/box-project/box-repo/data/box-images
+IMAGE_ADS_SUBFOLDER=ads-cover
+
+# S3 IMAGE STORAGE 配置
+BOX_IMAGE_S3_ENABLED=false
+AWS_ACCESS_KEY_ID=AKIA6GSNGR5PISMIKCJ4
+AWS_SECRET_ACCESS_KEY=o236gEpw8NkqIaTHmu7d2N2d9NIMqLLu6Mktfyyd
+
+# Bucket name
+AWS_STORAGE_BUCKET_NAME=mybucket-imgs
+
+# The region of your bucket, more info:
+# http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
+AWS_S3_REGION_NAME=ap-east-1
+
+# The endpoint of your bucket, more info:
+# http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
+AWS_S3_ENDPOINT_URL=https://s3.ap-east-1.amazonaws.com
+
+# 上传限制
+UPLOAD_LIMIT_IMAGE=20
+UPLOAD_LIMIT_VIDEO=100
+UPLOAD_LIMIT_PDF=10
+UPLOAD_LIMIT_DEFAULT=10
+

+ 27 - 0
.env.app.dev

@@ -56,3 +56,30 @@ RABBITMQ_STATS_AD_IMPRESSION_ROUTING_KEY="stats.ad.impression"
 RECOMMENDATION_CHANNEL_BOOST=1.1
 # Minimum candidates from tags before falling back to global set
 RECOMMENDATION_MIN_CANDIDATES_BEFORE_FALLBACK=5
+
+# LOCAL IMAGE STORAGE 配置
+IMAGE_ROOT_PATH=/media/dave/DAVEWORKS/works/fctech.my/box-project/box-repo/data/box-images
+IMAGE_ADS_SUBFOLDER=ads-cover
+
+# S3 IMAGE STORAGE 配置
+BOX_IMAGE_S3_ENABLED=false
+AWS_ACCESS_KEY_ID=AKIA6GSNGR5PISMIKCJ4
+AWS_SECRET_ACCESS_KEY=o236gEpw8NkqIaTHmu7d2N2d9NIMqLLu6Mktfyyd
+
+# Bucket name
+AWS_STORAGE_BUCKET_NAME=mybucket-imgs
+
+# The region of your bucket, more info:
+# http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
+AWS_S3_REGION_NAME=ap-east-1
+
+# The endpoint of your bucket, more info:
+# http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
+AWS_S3_ENDPOINT_URL=https://s3.ap-east-1.amazonaws.com
+
+# 上传限制
+UPLOAD_LIMIT_IMAGE=20
+UPLOAD_LIMIT_VIDEO=100
+UPLOAD_LIMIT_PDF=10
+UPLOAD_LIMIT_DEFAULT=10
+

+ 29 - 0
.env.app.test

@@ -57,3 +57,32 @@ RABBITMQ_STATS_AD_IMPRESSION_ROUTING_KEY="stats.ad.impression"
 RECOMMENDATION_CHANNEL_BOOST=1.1
 # Minimum candidates from tags before falling back to global set
 RECOMMENDATION_MIN_CANDIDATES_BEFORE_FALLBACK=5
+
+
+
+# LOCAL IMAGE STORAGE 配置
+IMAGE_ROOT_PATH=/media/dave/DAVEWORKS/works/fctech.my/box-project/box-repo/data/box-images
+IMAGE_ADS_SUBFOLDER=ads-cover
+
+# S3 IMAGE STORAGE 配置
+BOX_IMAGE_S3_ENABLED=false
+AWS_ACCESS_KEY_ID=AKIA6GSNGR5PISMIKCJ4
+AWS_SECRET_ACCESS_KEY=o236gEpw8NkqIaTHmu7d2N2d9NIMqLLu6Mktfyyd
+
+# Bucket name
+AWS_STORAGE_BUCKET_NAME=mybucket-imgs
+
+# The region of your bucket, more info:
+# http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
+AWS_S3_REGION_NAME=ap-east-1
+
+# The endpoint of your bucket, more info:
+# http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
+AWS_S3_ENDPOINT_URL=https://s3.ap-east-1.amazonaws.com
+
+# 上传限制
+UPLOAD_LIMIT_IMAGE=20
+UPLOAD_LIMIT_VIDEO=100
+UPLOAD_LIMIT_PDF=10
+UPLOAD_LIMIT_DEFAULT=10
+

+ 15 - 5
.env.mgnt

@@ -83,20 +83,29 @@ OSS_ACCESS_KEY_SECRET=UU5ctILkrN/wMVVkg9zmDoQvXzBAPLfCdV9tkpbx
 OSS_BUCKET=ww-buckets
 OSS_REGION=ap-east-1
 
+# LOCAL IMAGE STORAGE 配置
+# NOTE: Images are encrypted - frontend needs to access via appropriate endpoint
+# For now, using localhost. Update to actual server IP/domain for remote access
+IMAGE_BASE_URL=http://0.0.0.0:3300/images
+IMAGE_ROOT_PATH=/media/dave/DAVEWORKS/works/fctech.my/box-project/box-repo/data/box-images
+IMAGE_ADS_SUBFOLDER=ads-cover
+BOX_IMAGE_LOCAL_ROOT=/media/dave/DAVEWORKS/works/fctech.my/box-project/box-repo/data/box-images
+
+# S3 IMAGE STORAGE 配置
 BOX_IMAGE_S3_ENABLED=false
-AWS_ACCESS_KEY_ID='AKIA6GSNGR5PISMIKCJ4'
-AWS_SECRET_ACCESS_KEY='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'
+AWS_ACCESS_KEY_ID=AKIA6GSNGR5PISMIKCJ4
+AWS_SECRET_ACCESS_KEY=o236gEpw8NkqIaTHmu7d2N2d9NIMqLLu6Mktfyyd
 
 # Bucket name
-AWS_STORAGE_BUCKET_NAME='mybucket-imgs'
+AWS_STORAGE_BUCKET_NAME=mybucket-imgs
 
 # The region of your bucket, more info:
 # http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
-AWS_S3_REGION_NAME='ap-east-1'
+AWS_S3_REGION_NAME=ap-east-1
 
 # The endpoint of your bucket, more info:
 # http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
-AWS_S3_ENDPOINT_URL='https://s3.ap-east-1.amazonaws.com'
+AWS_S3_ENDPOINT_URL=https://s3.ap-east-1.amazonaws.com
 
 # 上传限制
 UPLOAD_LIMIT_IMAGE=20
@@ -104,3 +113,4 @@ UPLOAD_LIMIT_VIDEO=100
 UPLOAD_LIMIT_PDF=10
 UPLOAD_LIMIT_DEFAULT=10
 
+

+ 12 - 5
.env.mgnt.dev

@@ -72,20 +72,26 @@ OSS_ACCESS_KEY_SECRET=UU5ctILkrN/wMVVkg9zmDoQvXzBAPLfCdV9tkpbx
 OSS_BUCKET=ww-buckets
 OSS_REGION=ap-east-1
 
+# LOCAL IMAGE STORAGE 配置
+IMAGE_BASE_URL=http://localhost:3300/images
+IMAGE_ROOT_PATH=/data/box-images
+IMAGE_ADS_SUBFOLDER=ads-cover
+
+# S3 IMAGE STORAGE 配置
 BOX_IMAGE_S3_ENABLED=false
-AWS_ACCESS_KEY_ID='AKIA6GSNGR5PISMIKCJ4'
-AWS_SECRET_ACCESS_KEY='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'
+AWS_ACCESS_KEY_ID=AKIA6GSNGR5PISMIKCJ4
+AWS_SECRET_ACCESS_KEY=o236gEpw8NkqIaTHmu7d2N2d9NIMqLLu6Mktfyyd
 
 # Bucket name
-AWS_STORAGE_BUCKET_NAME='mybucket-imgs'
+AWS_STORAGE_BUCKET_NAME=mybucket-imgs
 
 # The region of your bucket, more info:
 # http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
-AWS_S3_REGION_NAME='ap-east-1'
+AWS_S3_REGION_NAME=ap-east-1
 
 # The endpoint of your bucket, more info:
 # http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
-AWS_S3_ENDPOINT_URL='https://s3.ap-east-1.amazonaws.com'
+AWS_S3_ENDPOINT_URL=https://s3.ap-east-1.amazonaws.com
 
 # 上传限制
 UPLOAD_LIMIT_IMAGE=20
@@ -93,3 +99,4 @@ UPLOAD_LIMIT_VIDEO=100
 UPLOAD_LIMIT_PDF=10
 UPLOAD_LIMIT_DEFAULT=10
 
+

+ 12 - 5
.env.mgnt.test

@@ -72,20 +72,26 @@ OSS_ACCESS_KEY_SECRET=UU5ctILkrN/wMVVkg9zmDoQvXzBAPLfCdV9tkpbx
 OSS_BUCKET=ww-buckets
 OSS_REGION=ap-east-1
 
+# LOCAL IMAGE STORAGE 配置
+IMAGE_BASE_URL=http://localhost:3300/images
+IMAGE_ROOT_PATH=/data/box-images
+IMAGE_ADS_SUBFOLDER=ads-cover
+
+# S3 IMAGE STORAGE 配置
 BOX_IMAGE_S3_ENABLED=false
-AWS_ACCESS_KEY_ID='AKIA6GSNGR5PISMIKCJ4'
-AWS_SECRET_ACCESS_KEY='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'
+AWS_ACCESS_KEY_ID=AKIA6GSNGR5PISMIKCJ4
+AWS_SECRET_ACCESS_KEY=o236gEpw8NkqIaTHmu7d2N2d9NIMqLLu6Mktfyyd
 
 # Bucket name
-AWS_STORAGE_BUCKET_NAME='mybucket-imgs'
+AWS_STORAGE_BUCKET_NAME=mybucket-imgs
 
 # The region of your bucket, more info:
 # http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
-AWS_S3_REGION_NAME='ap-east-1'
+AWS_S3_REGION_NAME=ap-east-1
 
 # The endpoint of your bucket, more info:
 # http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
-AWS_S3_ENDPOINT_URL='https://s3.ap-east-1.amazonaws.com'
+AWS_S3_ENDPOINT_URL=https://s3.ap-east-1.amazonaws.com
 
 # 上传限制
 UPLOAD_LIMIT_IMAGE=20
@@ -93,3 +99,4 @@ UPLOAD_LIMIT_VIDEO=100
 UPLOAD_LIMIT_PDF=10
 UPLOAD_LIMIT_DEFAULT=10
 
+

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

@@ -9,14 +9,12 @@ import { AuthService } from './auth.service';
 import { CoreModule } from '@box/core/core.module';
 import { JwtStrategy } from './strategies/jwt.strategy';
 import { JwtAuthGuard } from './guards/jwt-auth.guard';
-import { AdModule } from '../ads/ad.module';
 
 @Module({
   imports: [
     PrismaMongoModule,
     RabbitmqModule,
     CoreModule,
-    forwardRef(() => AdModule),
     PassportModule.register({ defaultStrategy: 'jwt' }),
     JwtModule.registerAsync({
       imports: [ConfigModule],

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

@@ -3,7 +3,6 @@ import { Injectable, Logger } from '@nestjs/common';
 import { JwtService } from '@nestjs/jwt';
 import { UserLoginEventPayload } from '@box/common/events/user-login-event.dto';
 import { RabbitmqPublisherService } from '../../rabbitmq/rabbitmq-publisher.service';
-import { AdService } from '../ads/ad.service';
 import { AppJwtPayload } from './interfaces/app-jwt-payload';
 import { PrismaMongoStatsService } from '../../prisma/prisma-mongo-stats.service';
 import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
@@ -14,7 +13,6 @@ export class AuthService {
   constructor(
     private readonly jwtService: JwtService,
     private readonly rabbitmqPublisher: RabbitmqPublisherService,
-    private readonly adService: AdService,
     private readonly prismaMongoStatsService: PrismaMongoStatsService,
     private readonly prismaMongoService: PrismaMongoService,
   ) {}
@@ -114,9 +112,7 @@ export class AuthService {
     //   startupAdWithoutUrl = rest;
     // }
 
-    const allAds = await this.adService.listAdsByType(1, 100000);
-
-    return { accessToken, allAds };
+    return { accessToken };
   }
 
   // add a function to retrieve user record from mongo by uid

+ 8 - 1
apps/box-mgnt-api/src/main.ts

@@ -9,8 +9,9 @@ import {
 import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
 import { Logger } from 'nestjs-pino';
 import multipart from '@fastify/multipart';
-
 import { AppModule } from './app.module';
+import fastifyStatic from '@fastify/static';
+import * as path from 'path';
 
 // @ts-expect-error: allow JSON.stringify(BigInt)
 BigInt.prototype.toJSON = function () {
@@ -31,6 +32,12 @@ async function bootstrap() {
     },
   });
 
+  // after creating fastifyAdapter but before NestFactory.create:
+  await fastifyAdapter.register(fastifyStatic as any, {
+    root: path.resolve(process.env.IMAGE_ROOT_PATH || '/data/box-images'),
+    prefix: '/images/', // so /images/... → that folder
+  });
+
   const app = await NestFactory.create<NestFastifyApplication>(
     AppModule,
     fastifyAdapter,

+ 9 - 7
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.controller.ts

@@ -7,10 +7,9 @@ import {
   Param,
   Post,
   Put,
-  UploadedFile,
-  UseInterceptors,
+  Req,
 } from '@nestjs/common';
-import { FileInterceptor } from '@nestjs/platform-express';
+import type { FastifyRequest } from 'fastify';
 import {
   ApiBody,
   ApiConsumes,
@@ -80,7 +79,6 @@ export class AdsController {
   }
 
   @Post(':id/cover')
-  @UseInterceptors(FileInterceptor('file'))
   @ApiConsumes('multipart/form-data')
   @ApiOperation({ summary: 'Upload cover image for an ad' })
   @ApiBody({
@@ -98,12 +96,16 @@ export class AdsController {
   @ApiResponse({ status: 200, type: AdsDto })
   async uploadCover(
     @Param() { id }: MongoIdParamDto,
-    @UploadedFile() file: Express.Multer.File,
+    @Req() req: FastifyRequest,
   ) {
-    if (!file) {
+    // Use Fastify multipart instead of Express multer
+    const mpFile = await (req as any).file();
+
+    if (!mpFile) {
       throw new BadRequestException('No file uploaded');
     }
-    return this.service.updateAdsCover(id, file);
+
+    return this.service.updateAdsCover(id, mpFile);
   }
 
   @Get('modules/list')

+ 22 - 2
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.dto.ts

@@ -60,7 +60,7 @@ export class AdsDto {
     example: 'https://cdn.example.com/ads/banner.png',
   })
   @IsOptional()
-  @IsUrl()
+  @IsString()
   adsCoverImg?: string | null;
 
   @ApiPropertyOptional({
@@ -140,7 +140,7 @@ export class CreateAdsDto {
 
   @ApiPropertyOptional({ description: '广告图片地址' })
   @IsOptional()
-  @IsUrl()
+  @IsString()
   adsCoverImg?: string;
 
   @ApiPropertyOptional({ description: '广告跳转链接' })
@@ -217,3 +217,23 @@ export class ListAdsDto extends PageListDto {
   @IsEnum(CommonStatus)
   status?: CommonStatus;
 }
+
+export interface AdsInterfaceDto {
+  id: string;
+  adsModuleId: string;
+  advertiser: string;
+  title: string;
+  adsContent?: string | null;
+  adsUrl?: string | null;
+
+  imgSource: 'PROVIDER' | 'LOCAL_ONLY' | 'S3_ONLY' | 'S3_AND_LOCAL';
+  adsCoverImg?: string | null; // stored key (optional to expose)
+  adsCoverImgUrl?: string | null; // final URL for frontend to use
+
+  startDt: bigint;
+  expiryDt: bigint;
+  seq: number;
+  status: number;
+  createAt: bigint;
+  updateAt: bigint;
+}

+ 20 - 3
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.module.ts

@@ -4,11 +4,28 @@ 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';
 
 @Module({
   imports: [PrismaModule, CacheSyncModule, ImageUploadModule],
-  providers: [AdsService],
-  controllers: [AdsController],
-  exports: [AdsService],
+  providers: [
+    AdsService,
+    AdsImageService,
+    LocalImageStorageService,
+    S3ImageStorageService,
+    ImageUrlBuilderService,
+  ],
+  controllers: [AdsController, AdsImageController],
+  exports: [
+    AdsService,
+    AdsImageService,
+    LocalImageStorageService,
+    S3ImageStorageService,
+    ImageUrlBuilderService,
+  ],
 })
 export class AdsModule {}

+ 59 - 7
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.service.ts

@@ -8,8 +8,17 @@ 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 { CreateAdsDto, ListAdsDto, UpdateAdsDto } from './ads.dto';
+import type { MultipartFile } from '@fastify/multipart';
+import * as fs from 'fs/promises';
+import * as path from 'path';
+import {
+  CreateAdsDto,
+  ListAdsDto,
+  UpdateAdsDto,
+  AdsInterfaceDto,
+} from './ads.dto';
 import { CommonStatus } from '../common/status.enum';
+import { ImageUrlBuilderService } from './image/image-url-builder.service';
 
 @Injectable()
 export class AdsService {
@@ -17,6 +26,7 @@ export class AdsService {
     private readonly mongoPrismaService: MongoPrismaService,
     private readonly cacheSyncService: CacheSyncService,
     private readonly imageUploadService: ImageUploadService,
+    private readonly imageUrlBuilderService: ImageUrlBuilderService,
   ) {}
 
   /**
@@ -62,6 +72,27 @@ export class AdsService {
     }
   }
 
+  private mapToDto(ad: any): AdsInterfaceDto {
+    return {
+      id: ad.id,
+      adsModuleId: ad.adsModuleId,
+      advertiser: ad.advertiser,
+      title: ad.title,
+      adsContent: ad.adsContent,
+      adsUrl: ad.adsUrl,
+      imgSource: ad.imgSource,
+      adsCoverImg: ad.adsCoverImg,
+      adsCoverImgUrl: this.imageUrlBuilderService.buildAdsCoverUrl(ad),
+
+      startDt: ad.startDt,
+      expiryDt: ad.expiryDt,
+      seq: ad.seq,
+      status: ad.status,
+      createAt: ad.createAt,
+      updateAt: ad.updateAt,
+    };
+  }
+
   async create(dto: CreateAdsDto) {
     this.ensureTimeRange(dto.startDt, dto.expiryDt);
 
@@ -88,7 +119,8 @@ export class AdsService {
     // Auto-schedule cache refresh (per-ad + pool)
     await this.cacheSyncService.scheduleAdRefresh(ad.id, ad.adsModule.adType);
 
-    return ad;
+    // Return created ad mapped to AdsInterfaceDto
+    return this.mapToDto(ad);
   }
 
   async update(dto: UpdateAdsDto) {
@@ -126,7 +158,7 @@ export class AdsService {
       // Auto-schedule cache refresh (per-ad + pool)
       await this.cacheSyncService.scheduleAdRefresh(ad.id, ad.adsModule.adType);
 
-      return ad;
+      return this.mapToDto(ad);
     } catch (e) {
       if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
         throw new NotFoundException('Ads not found');
@@ -145,7 +177,7 @@ export class AdsService {
       throw new NotFoundException('Ads not found');
     }
 
-    return row;
+    return this.mapToDto(row);
   }
 
   async list(dto: ListAdsDto) {
@@ -181,9 +213,11 @@ export class AdsService {
       }),
     ]);
 
+    const mappedData = data.map((ad) => this.mapToDto(ad));
+
     return {
       total,
-      data,
+      data: mappedData,
       totalPages: Math.ceil(total / size),
       page,
       size,
@@ -224,8 +258,9 @@ export class AdsService {
 
   /**
    * Upload and update Ads cover image.
+   * Deletes old image file before uploading new one.
    */
-  async updateAdsCover(id: string, file: Express.Multer.File) {
+  async updateAdsCover(id: string, file: MultipartFile) {
     // Ensure ad exists
     const ad = await this.mongoPrismaService.ads.findUnique({
       where: { id },
@@ -235,7 +270,24 @@ export class AdsService {
       throw new NotFoundException('Ads not found');
     }
 
-    // Upload image
+    // 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,
+        );
+      }
+    }
+
+    // Upload new image
     const { key, imgSource } = await this.imageUploadService.uploadCoverImage(
       'ads-cover',
       file,

+ 41 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/ads/image/ads-cover-multer.config.ts

@@ -0,0 +1,41 @@
+// box-mgnt-api/src/mgnt-backend/feature/ads/image/ads-cover-multer.config.ts
+import { extname } from 'path';
+import { diskStorage } from 'multer';
+import { v4 as uuid } from 'uuid';
+import * as fs from 'fs';
+
+// We return a plain MulterOptions object here, no ConfigService needed.
+export const AdsCoverMulterConfig = {
+  storage: diskStorage({
+    destination: (req, file, cb) => {
+      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 = `${rootPath}/${adsFolder}/${year}/${month}/${day}`;
+      fs.mkdirSync(folder, { recursive: true });
+
+      cb(null, folder);
+    },
+    filename: (req, file, cb) => {
+      const fileExt = extname(file.originalname).toLowerCase();
+      cb(null, uuid() + fileExt);
+    },
+  }),
+
+  fileFilter: (req: any, file: Express.Multer.File, cb: any) => {
+    const allowed = ['image/jpeg', 'image/png', 'image/webp'];
+    if (!allowed.includes(file.mimetype)) {
+      return cb(new Error('Only JPG, PNG, WEBP images are allowed!'), false);
+    }
+    cb(null, true);
+  },
+
+  limits: {
+    fileSize: Number(process.env.UPLOAD_LIMIT_IMAGE || '20') * 1024 * 1024, // MB → bytes
+  },
+};

+ 61 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/ads/image/ads-image.controller.ts

@@ -0,0 +1,61 @@
+// ads-image.controller.ts
+import {
+  BadRequestException,
+  Controller,
+  Param,
+  Post,
+  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';
+
+@Controller('ads-legacy')
+export class AdsImageController {
+  constructor(private readonly adsImageService: AdsImageService) {}
+
+  @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,
+    };
+  }
+}

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

@@ -0,0 +1,86 @@
+// 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';
+
+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 = BigInt(Date.now());
+    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,
+    };
+  }
+}

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

@@ -0,0 +1,97 @@
+// image-url-builder.service.ts
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+
+export interface AdsLike {
+  adsCoverImg: string | null;
+  imgSource: 'PROVIDER' | 'LOCAL_ONLY' | 'S3_ONLY' | 'S3_AND_LOCAL';
+}
+
+@Injectable()
+export class ImageUrlBuilderService {
+  private readonly logger = new Logger(ImageUrlBuilderService.name);
+
+  private readonly localBaseUrl: string;
+  private readonly s3BaseUrl: string;
+
+  constructor(private readonly config: ConfigService) {
+    // Optional local base URL, if you decide to set it
+    this.localBaseUrl = this.normalizeBaseUrl(
+      this.config.get<string>('IMAGE_BASE_URL') || '',
+      'IMAGE_BASE_URL',
+    );
+
+    // Prefer explicit S3_IMAGE_BASE_URL if provided
+    const explicitS3 = this.config.get<string>('S3_IMAGE_BASE_URL') || '';
+
+    if (explicitS3) {
+      this.s3BaseUrl = this.normalizeBaseUrl(explicitS3, 'S3_IMAGE_BASE_URL');
+    } else {
+      // Derive from AWS_S3_ENDPOINT_URL + AWS_STORAGE_BUCKET_NAME
+      const endpoint = this.config.get<string>('AWS_S3_ENDPOINT_URL') || '';
+      const bucket = this.config.get<string>('AWS_STORAGE_BUCKET_NAME') || '';
+      const derived = endpoint && bucket ? `${endpoint}/${bucket}` : '';
+
+      this.s3BaseUrl = this.normalizeBaseUrl(
+        derived,
+        'AWS_S3_ENDPOINT_URL + AWS_STORAGE_BUCKET_NAME',
+      );
+    }
+  }
+
+  buildAdsCoverUrl(ad: AdsLike | null | undefined): string | null {
+    if (!ad || !ad.adsCoverImg) return null;
+
+    const key = ad.adsCoverImg;
+
+    if (ad.imgSource === 'PROVIDER') {
+      if (this.isAbsoluteUrl(key)) {
+        return key;
+      }
+      if (this.localBaseUrl) {
+        return this.joinUrl(this.localBaseUrl, key);
+      }
+      return key;
+    }
+
+    if (ad.imgSource === 'S3_AND_LOCAL') {
+      if (this.s3BaseUrl) {
+        return this.joinUrl(this.s3BaseUrl, key);
+      }
+      if (this.localBaseUrl) {
+        this.logger.warn(
+          `imgSource=S3_AND_LOCAL but S3 base URL missing, falling back to local for key: ${key}`,
+        );
+        return this.joinUrl(this.localBaseUrl, key);
+      }
+      return key;
+    }
+
+    // LOCAL_ONLY / S3_ONLY -> treat as local-hosted unless you later change it
+    if (this.localBaseUrl) {
+      return this.joinUrl(this.localBaseUrl, key);
+    }
+
+    return key;
+  }
+
+  // helpers
+  private normalizeBaseUrl(url: string, configKey: string): string {
+    const trimmed = url.trim().replace(/\/+$/, '');
+    if (!trimmed) {
+      this.logger.warn(
+        `Base URL for ${configKey} is empty. Image URLs may be relative keys instead of absolute URLs.`,
+      );
+    }
+    return trimmed;
+  }
+
+  private isAbsoluteUrl(url: string): boolean {
+    return /^https?:\/\//i.test(url);
+  }
+
+  private joinUrl(base: string, key: string): string {
+    if (!base) return key;
+    return `${base}/${key}`.replace(/([^:]\/)\/+/g, '$1');
+  }
+}

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

@@ -0,0 +1,42 @@
+// local-image-storage.service.ts
+import { Injectable } from '@nestjs/common';
+import * as fs from 'fs';
+import * as path from 'path';
+import { ConfigService } from '@nestjs/config';
+
+@Injectable()
+export class LocalImageStorageService {
+  private readonly root: string;
+
+  constructor(private readonly config: ConfigService) {
+    this.root =
+      this.config.get<string>('IMAGE_ROOT_PATH') || '/data/box-images';
+  }
+
+  getRelativeKeyFromFile(file: Express.Multer.File): string {
+    return file.path.replace(this.root + '/', '');
+  }
+
+  getRelativeKeyFromAbsolutePath(absPath: string): string {
+    return absPath.replace(this.root + '/', '');
+  }
+
+  getAbsolutePath(relativeKey: string): string {
+    return path.join(this.root, relativeKey);
+  }
+
+  deleteLocal(relativeKey: string): void {
+    const abs = this.getAbsolutePath(relativeKey);
+    this.deleteByAbsolutePath(abs);
+  }
+
+  deleteByAbsolutePath(absPath: string): void {
+    if (fs.existsSync(absPath)) {
+      try {
+        fs.unlinkSync(absPath);
+      } catch (err) {
+        console.error(`❌ Failed to delete local file: ${absPath}`, err);
+      }
+    }
+  }
+}

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

@@ -0,0 +1,80 @@
+// s3-image-storage.service.ts
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import {
+  S3Client,
+  PutObjectCommand,
+  DeleteObjectCommand,
+} from '@aws-sdk/client-s3';
+import * as fs from 'fs';
+
+@Injectable()
+export class S3ImageStorageService {
+  private readonly logger = new Logger(S3ImageStorageService.name);
+
+  private readonly enabled: boolean;
+  private readonly bucket: string;
+  private readonly client: S3Client;
+
+  constructor(private readonly config: ConfigService) {
+    this.enabled = this.config.get<string>('BOX_IMAGE_S3_ENABLED') === 'true';
+    this.bucket = this.config.get<string>('AWS_STORAGE_BUCKET_NAME') || '';
+
+    const region = this.config.get<string>('AWS_S3_REGION_NAME');
+    const endpoint = this.config.get<string>('AWS_S3_ENDPOINT_URL');
+    const accessKeyId = this.config.get<string>('AWS_ACCESS_KEY_ID');
+    const secretAccessKey = this.config.get<string>('AWS_SECRET_ACCESS_KEY');
+
+    this.client = new S3Client({
+      region,
+      endpoint, // optional, works with AWS or compatible S3
+      credentials:
+        accessKeyId && secretAccessKey
+          ? {
+              accessKeyId,
+              secretAccessKey,
+            }
+          : undefined,
+    });
+  }
+
+  isEnabled(): boolean {
+    return this.enabled;
+  }
+
+  async upload(relativeKey: string, absolutePath: string): Promise<boolean> {
+    if (!this.enabled) return false;
+
+    try {
+      const fileData = fs.readFileSync(absolutePath);
+      await this.client.send(
+        new PutObjectCommand({
+          Bucket: this.bucket,
+          Key: relativeKey,
+          Body: fileData,
+        }),
+      );
+      this.logger.log(`✅ Uploaded to S3 → ${relativeKey}`);
+      return true;
+    } catch (err) {
+      this.logger.error(`❌ S3 upload failed for ${relativeKey}`, err);
+      return false;
+    }
+  }
+
+  async delete(relativeKey: string): Promise<void> {
+    if (!this.enabled) return;
+
+    try {
+      await this.client.send(
+        new DeleteObjectCommand({
+          Bucket: this.bucket,
+          Key: relativeKey,
+        }),
+      );
+      this.logger.log(`🗑️ Deleted S3 object → ${relativeKey}`);
+    } catch (err) {
+      this.logger.error(`❌ Failed to delete S3 object: ${relativeKey}`, err);
+    }
+  }
+}

+ 19 - 8
apps/box-mgnt-api/src/mgnt-backend/feature/image-upload/image-upload.service.ts

@@ -5,6 +5,7 @@ import { mkdir, writeFile } from 'fs/promises';
 import { randomUUID } from 'crypto';
 import * as path from 'path';
 import { encryptImageWithHeader } from '@box/common/utils/image-lib';
+import type { MultipartFile } from '@fastify/multipart';
 
 type ImageType = 'ads-cover' | 'video-cover';
 
@@ -43,25 +44,35 @@ export class ImageUploadService {
   }
 
   /**
-   * Upload a cover image with optional encryption.
+   * 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: Express.Multer.File,
+    file: MultipartFile,
   ): Promise<UploadResult> {
     this.validateMimeType(file.mimetype);
 
-    const key = this.buildStorageKey(type, file.originalname);
-    const encryptedBuffer = encryptImageWithHeader(file.buffer);
+    const key = this.buildStorageKey(type, file.filename);
 
-    // Write to local filesystem
-    await this.writeLocal(key, encryptedBuffer);
+    // 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
+    // Optionally upload to S3 (also unencrypted for management API)
     let s3Success = false;
     if (this.s3Enabled && this.s3Client && this.s3Bucket) {
-      s3Success = await this.uploadToS3(key, encryptedBuffer, file.mimetype);
+      s3Success = await this.uploadToS3(key, buffer, file.mimetype);
     }
 
     return {

+ 8 - 10
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.controller.ts

@@ -7,11 +7,10 @@ import {
   Patch,
   Body,
   Post,
-  UseInterceptors,
-  UploadedFile,
+  Req,
   BadRequestException,
 } from '@nestjs/common';
-import { FileInterceptor } from '@nestjs/platform-express';
+import type { FastifyRequest } from 'fastify';
 import {
   ApiTags,
   ApiOperation,
@@ -242,14 +241,13 @@ export class VideoMediaController {
     description: '文件格式或大小不符合要求',
   })
   @Post(':id/cover')
-  @UseInterceptors(FileInterceptor('file'))
-  async updateCover(
-    @Param('id') id: string,
-    @UploadedFile() file: Express.Multer.File,
-  ) {
-    if (!file) {
+  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) {
       throw new BadRequestException('No file uploaded');
     }
-    return this.videoMediaService.updateCover(id, file);
+    return this.videoMediaService.updateCover(id, mpFile);
   }
 }

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

@@ -3,6 +3,7 @@ import {
   NotFoundException,
   BadRequestException,
 } 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';
@@ -318,7 +319,7 @@ export class VideoMediaService {
   /**
    * Upload and update VideoMedia cover image.
    */
-  async updateCover(id: string, file: Express.Multer.File) {
+  async updateCover(id: string, file: MultipartFile) {
     // Ensure video exists
     const video = await this.prisma.videoMedia.findUnique({
       where: { id },

+ 2 - 0
package.json

@@ -85,6 +85,7 @@
     "qrcode": "^1.5.4",
     "reflect-metadata": "^0.2.2",
     "rxjs": "^7.8.1",
+    "uuid": "^13.0.0",
     "xlsx": "^0.18.5"
   },
   "devDependencies": {
@@ -105,6 +106,7 @@
     "@types/passport-jwt": "^4.0.1",
     "@types/passport-local": "^1.0.38",
     "@types/supertest": "^6.0.2",
+    "@types/uuid": "^11.0.0",
     "@types/xlsx": "^0.0.36",
     "@typescript-eslint/eslint-plugin": "^7.8.0",
     "@typescript-eslint/parser": "^7.8.0",

+ 20 - 0
pnpm-lock.yaml

@@ -170,6 +170,9 @@ importers:
       rxjs:
         specifier: ^7.8.1
         version: 7.8.2
+      uuid:
+        specifier: ^13.0.0
+        version: 13.0.0
       xlsx:
         specifier: ^0.18.5
         version: 0.18.5
@@ -225,6 +228,9 @@ importers:
       '@types/supertest':
         specifier: ^6.0.2
         version: 6.0.3
+      '@types/uuid':
+        specifier: ^11.0.0
+        version: 11.0.0
       '@types/xlsx':
         specifier: ^0.0.36
         version: 0.0.36
@@ -1879,6 +1885,10 @@ packages:
   '@types/supertest@6.0.3':
     resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
 
+  '@types/uuid@11.0.0':
+    resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==}
+    deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.
+
   '@types/validator@13.15.10':
     resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
 
@@ -5168,6 +5178,10 @@ packages:
     resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
     engines: {node: '>= 0.4.0'}
 
+  uuid@13.0.0:
+    resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
+    hasBin: true
+
   uuid@8.3.2:
     resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
     hasBin: true
@@ -7568,6 +7582,10 @@ snapshots:
       '@types/methods': 1.1.4
       '@types/superagent': 8.1.9
 
+  '@types/uuid@11.0.0':
+    dependencies:
+      uuid: 13.0.0
+
   '@types/validator@13.15.10': {}
 
   '@types/webidl-conversions@7.0.3': {}
@@ -11188,6 +11206,8 @@ snapshots:
 
   utils-merge@1.0.1: {}
 
+  uuid@13.0.0: {}
+
   uuid@8.3.2: {}
 
   v8-compile-cache-lib@3.0.1: {}