Просмотр исходного кода

feat(image-upload): implement cover image upload for ads and video media; add image configuration service and update Prisma schemas

Dave 3 месяцев назад
Родитель
Сommit
fd5a0f088f

+ 1 - 1
.env.mgnt

@@ -72,7 +72,7 @@ OSS_ACCESS_KEY_SECRET=UU5ctILkrN/wMVVkg9zmDoQvXzBAPLfCdV9tkpbx
 OSS_BUCKET=ww-buckets
 OSS_REGION=ap-east-1
 
-
+BOX_IMAGE_S3_ENABLED=false
 AWS_ACCESS_KEY_ID='AKIA6GSNGR5PISMIKCJ4'
 AWS_SECRET_ACCESS_KEY='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'
 

+ 1 - 1
.env.mgnt.dev

@@ -62,7 +62,7 @@ OSS_ACCESS_KEY_SECRET=UU5ctILkrN/wMVVkg9zmDoQvXzBAPLfCdV9tkpbx
 OSS_BUCKET=ww-buckets
 OSS_REGION=ap-east-1
 
-
+BOX_IMAGE_S3_ENABLED=false
 AWS_ACCESS_KEY_ID='AKIA6GSNGR5PISMIKCJ4'
 AWS_SECRET_ACCESS_KEY='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'
 

+ 1 - 1
.env.mgnt.test

@@ -61,7 +61,7 @@ OSS_ACCESS_KEY_SECRET=UU5ctILkrN/wMVVkg9zmDoQvXzBAPLfCdV9tkpbx
 OSS_BUCKET=ww-buckets
 OSS_REGION=ap-east-1
 
-
+BOX_IMAGE_S3_ENABLED=false
 AWS_ACCESS_KEY_ID='AKIA6GSNGR5PISMIKCJ4'
 AWS_SECRET_ACCESS_KEY='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'
 

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

@@ -7,8 +7,17 @@ import {
   Param,
   Post,
   Put,
+  UploadedFile,
+  UseInterceptors,
 } from '@nestjs/common';
-import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { FileInterceptor } from '@nestjs/platform-express';
+import {
+  ApiBody,
+  ApiConsumes,
+  ApiOperation,
+  ApiResponse,
+  ApiTags,
+} from '@nestjs/swagger';
 import { CreateAdsDto, ListAdsDto, UpdateAdsDto, AdsDto } from './ads.dto';
 import { AdsService } from './ads.service';
 import { MongoIdParamDto } from '../common/mongo-id.dto';
@@ -70,6 +79,33 @@ export class AdsController {
     return this.service.remove(id);
   }
 
+  @Post(':id/cover')
+  @UseInterceptors(FileInterceptor('file'))
+  @ApiConsumes('multipart/form-data')
+  @ApiOperation({ summary: 'Upload cover image for an ad' })
+  @ApiBody({
+    schema: {
+      type: 'object',
+      properties: {
+        file: {
+          type: 'string',
+          format: 'binary',
+          description: 'Cover image file (JPEG, PNG, GIF, WEBP)',
+        },
+      },
+    },
+  })
+  @ApiResponse({ status: 200, type: AdsDto })
+  async uploadCover(
+    @Param() { id }: MongoIdParamDto,
+    @UploadedFile() file: Express.Multer.File,
+  ) {
+    if (!file) {
+      throw new BadRequestException('No file uploaded');
+    }
+    return this.service.updateAdsCover(id, file);
+  }
+
   @Get('modules/list')
   @ApiOperation({ summary: 'Get list of ads modules' })
   @ApiResponse({

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

@@ -1,11 +1,12 @@
 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';
 
 @Module({
-  imports: [PrismaModule, CacheSyncModule],
+  imports: [PrismaModule, CacheSyncModule, ImageUploadModule],
   providers: [AdsService],
   controllers: [AdsController],
   exports: [AdsService],

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

@@ -1,3 +1,4 @@
+// apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.service.ts
 import {
   Injectable,
   BadRequestException,
@@ -6,6 +7,7 @@ import {
 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 { CommonStatus } from '../common/status.enum';
 
@@ -14,6 +16,7 @@ export class AdsService {
   constructor(
     private readonly mongoPrismaService: MongoPrismaService,
     private readonly cacheSyncService: CacheSyncService,
+    private readonly imageUploadService: ImageUploadService,
   ) {}
 
   /**
@@ -226,4 +229,40 @@ export class AdsService {
     });
     return adsModules;
   }
+
+  /**
+   * Upload and update Ads cover image.
+   */
+  async updateAdsCover(id: string, file: Express.Multer.File) {
+    // Ensure ad exists
+    const ad = await this.mongoPrismaService.ads.findUnique({
+      where: { id },
+      include: { adsModule: true },
+    });
+    if (!ad) {
+      throw new NotFoundException('Ads not found');
+    }
+
+    // Upload 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,
+        updateAt: this.now(),
+      },
+      include: { channel: true, adsModule: true },
+    });
+
+    // Schedule cache refresh
+    await this.cacheSyncService.scheduleAdRefresh(id, ad.adsModule.adType);
+
+    return updated;
+  }
 }

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

@@ -11,6 +11,7 @@ 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: [
@@ -25,6 +26,7 @@ import { HealthModule } from './health/health.module';
     SyncVideomediaModule,
     VideoMediaModule,
     HealthModule,
+    ImageUploadModule,
   ],
 })
 export class FeatureModule {}

+ 8 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/image-upload/image-upload.module.ts

@@ -0,0 +1,8 @@
+import { Module } from '@nestjs/common';
+import { ImageUploadService } from './image-upload.service';
+
+@Module({
+  providers: [ImageUploadService],
+  exports: [ImageUploadService],
+})
+export class ImageUploadModule {}

+ 154 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/image-upload/image-upload.service.ts

@@ -0,0 +1,154 @@
+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';
+
+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 with optional encryption.
+   * Stores locally, optionally uploads to S3, and returns storage metadata.
+   */
+  async uploadCoverImage(
+    type: ImageType,
+    file: Express.Multer.File,
+  ): Promise<UploadResult> {
+    this.validateMimeType(file.mimetype);
+
+    const key = this.buildStorageKey(type, file.originalname);
+    const encryptedBuffer = encryptImageWithHeader(file.buffer);
+
+    // Write to local filesystem
+    await this.writeLocal(key, encryptedBuffer);
+
+    // Optionally upload to S3
+    let s3Success = false;
+    if (this.s3Enabled && this.s3Client && this.s3Bucket) {
+      s3Success = await this.uploadToS3(key, encryptedBuffer, 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-covers' : 'video-covers';
+    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;
+    }
+  }
+}

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

@@ -9,6 +9,7 @@ import {
   Post,
   UseInterceptors,
   UploadedFile,
+  BadRequestException,
 } from '@nestjs/common';
 import { FileInterceptor } from '@nestjs/platform-express';
 import {
@@ -246,10 +247,9 @@ export class VideoMediaController {
     @Param('id') id: string,
     @UploadedFile() file: Express.Multer.File,
   ) {
-    // TODO: 上传 file 到 S3,得到 coverImg URL / key
-    // const coverImg = await this.s3Service.uploadVideoCover(id, file);
-    // 暂时先用假值占位,避免编译报错
-    const coverImg = ''; // replace with real URL
-    return this.videoMediaService.updateCover(id, coverImg);
+    if (!file) {
+      throw new BadRequestException('No file uploaded');
+    }
+    return this.videoMediaService.updateCover(id, file);
   }
 }

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

@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
 import { VideoMediaService } from './video-media.service';
 import { VideoMediaController } from './video-media.controller';
 import { PrismaModule } from '@box/db/prisma/prisma.module';
+import { ImageUploadModule } from '../image-upload/image-upload.module';
 
 @Module({
-  imports: [PrismaModule],
+  imports: [PrismaModule, ImageUploadModule],
   controllers: [VideoMediaController],
   providers: [VideoMediaService],
   exports: [VideoMediaService],

+ 21 - 7
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.service.ts

@@ -4,6 +4,7 @@ import {
   BadRequestException,
 } from '@nestjs/common';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { ImageUploadService } from '../image-upload/image-upload.service';
 import {
   VideoMediaListQueryDto,
   UpdateVideoMediaManageDto,
@@ -13,7 +14,10 @@ import {
 
 @Injectable()
 export class VideoMediaService {
-  constructor(private readonly prisma: MongoPrismaService) {}
+  constructor(
+    private readonly prisma: MongoPrismaService,
+    private readonly imageUploadService: ImageUploadService,
+  ) {}
 
   async findAll(query: VideoMediaListQueryDto): Promise<any> {
     const page = query.page ?? 1;
@@ -251,9 +255,10 @@ export class VideoMediaService {
   }
 
   /**
-   * 封面更新逻辑占位(S3 上传完成后更新 coverImg)
+   * Upload and update VideoMedia cover image.
    */
-  async updateCover(id: string, coverImg: string) {
+  async updateCover(id: string, file: Express.Multer.File) {
+    // Ensure video exists
     const video = await this.prisma.videoMedia.findUnique({
       where: { id },
     });
@@ -262,21 +267,30 @@ export class VideoMediaService {
       throw new NotFoundException('Video not found');
     }
 
+    // Upload image
+    const { key, imgSource } = await this.imageUploadService.uploadCoverImage(
+      'video-cover',
+      file,
+    );
+
     const editedAt = BigInt(Date.now());
     const updatedAt = new Date();
 
-    await this.prisma.videoMedia.update({
+    // Update VideoMedia record
+    const updated = await this.prisma.videoMedia.update({
       where: { id },
       data: {
-        coverImg,
+        coverImg: key,
+        imgSource,
         editedAt,
         updatedAt,
       },
     });
 
     return {
-      id,
-      coverImg,
+      id: updated.id,
+      coverImg: updated.coverImg,
+      imgSource: updated.imgSource,
       editedAt: editedAt.toString(),
     };
   }

+ 74 - 0
libs/common/src/utils/image-url-resolver.ts

@@ -0,0 +1,74 @@
+export type ImageSource =
+  | 'PROVIDER'
+  | 'LOCAL_ONLY'
+  | 'S3_ONLY'
+  | 'S3_AND_LOCAL';
+
+export interface ImageConfigInput {
+  channelId: number | null;
+  providerDecodeBase: string | null;
+  localBaseUrl: string | null;
+  s3BaseUrl: string | null;
+  preferredSource: ImageSource;
+}
+
+function isAbsoluteUrl(url?: string | null) {
+  if (!url) return false;
+  return /^https?:\/\//i.test(url) || url.startsWith('//');
+}
+
+function joinUrl(base: string, path: string) {
+  if (!base) return path;
+  if (!path) return base;
+  const trimmedBase = base.replace(/\/+$|\/$/g, '');
+  const trimmedPath = path.replace(/^\/+/, '');
+  return `${trimmedBase}/${trimmedPath}`;
+}
+
+export class ImageUrlResolver {
+  /**
+   * Resolve the public URL for an image based on source and config.
+   * Falls back to provider-style URLs for backward compatibility.
+   */
+  static resolve(options: {
+    coverImg?: string | null;
+    imgSource?: ImageSource | null;
+    channelId?: number | null;
+    config?: ImageConfigInput | null;
+  }): string | null {
+    const { coverImg, imgSource, channelId: _channelId, config } = options;
+    if (!coverImg) return null;
+
+    // Already a full URL → return as-is.
+    if (isAbsoluteUrl(coverImg)) return coverImg;
+
+    const source: ImageSource =
+      imgSource || config?.preferredSource || 'PROVIDER';
+
+    const fromProvider = () =>
+      config?.providerDecodeBase
+        ? joinUrl(config.providerDecodeBase, coverImg)
+        : coverImg;
+    const fromLocal = () =>
+      config?.localBaseUrl
+        ? joinUrl(config.localBaseUrl, coverImg)
+        : fromProvider();
+    const fromS3 = () =>
+      config?.s3BaseUrl ? joinUrl(config.s3BaseUrl, coverImg) : fromProvider();
+
+    switch (source) {
+      case 'LOCAL_ONLY':
+        return fromLocal();
+      case 'S3_ONLY':
+        return fromS3();
+      case 'S3_AND_LOCAL': {
+        if (config?.s3BaseUrl) return fromS3();
+        if (config?.localBaseUrl) return fromLocal();
+        return fromProvider();
+      }
+      case 'PROVIDER':
+      default:
+        return fromProvider();
+    }
+  }
+}

+ 69 - 0
libs/db/src/image-config.service.ts

@@ -0,0 +1,69 @@
+import { Injectable } from '@nestjs/common';
+
+import { MysqlPrismaService } from './prisma/mysql-prisma.service';
+
+type ImageConfigRecord = {
+  id: number;
+  channelId: number | null;
+  providerDecodeBase: string | null;
+  localBaseUrl: string | null;
+  s3BaseUrl: string | null;
+  preferredSource: string;
+  status: number;
+  createAt: bigint;
+  updateAt: bigint;
+};
+
+@Injectable()
+export class ImageConfigService {
+  private readonly cache = new Map<string, ImageConfigRecord | null>();
+
+  constructor(private readonly prisma: MysqlPrismaService) {}
+
+  private buildCacheKey(channelId?: number) {
+    return channelId != null ? `channel:${channelId}` : 'default';
+  }
+
+  async getConfig(channelId?: number): Promise<ImageConfigRecord | null> {
+    const cacheKey = this.buildCacheKey(channelId);
+    if (this.cache.has(cacheKey)) {
+      return this.cache.get(cacheKey) ?? null;
+    }
+
+    const client = this.prisma as unknown as {
+      imageConfig: {
+        findFirst: (args: {
+          where: Record<string, unknown>;
+          orderBy: { id: 'asc' | 'desc' };
+        }) => Promise<ImageConfigRecord | null>;
+      };
+    };
+
+    const config = await client.imageConfig.findFirst({
+      where: channelId != null ? { channelId } : { channelId: null },
+      orderBy: { id: 'desc' },
+    });
+
+    // Fallback to default (channelId null) when channel-specific config is missing
+    const finalConfig =
+      config ??
+      (channelId != null
+        ? await client.imageConfig.findFirst({
+            where: { channelId: null },
+            orderBy: { id: 'desc' },
+          })
+        : null);
+
+    this.cache.set(cacheKey, finalConfig ?? null);
+    return finalConfig ?? null;
+  }
+
+  async refresh(channelId?: number): Promise<ImageConfigRecord | null> {
+    this.cache.delete(this.buildCacheKey(channelId));
+    return this.getConfig(channelId);
+  }
+
+  clearCache() {
+    this.cache.clear();
+  }
+}

+ 4 - 3
libs/db/src/shared.module.ts

@@ -1,12 +1,13 @@
 import { HttpModule } from '@nestjs/axios';
 import { Global, Module } from '@nestjs/common';
-import { UtilsService } from './utils.service';
 import { PrismaModule } from './prisma/prisma.module';
+import { ImageConfigService } from './image-config.service';
+import { UtilsService } from './utils.service';
 
 @Global()
 @Module({
   imports: [PrismaModule, HttpModule],
-  providers: [UtilsService],
-  exports: [PrismaModule, HttpModule, UtilsService],
+  providers: [UtilsService, ImageConfigService],
+  exports: [PrismaModule, HttpModule, UtilsService, ImageConfigService],
 })
 export class SharedModule {}

+ 2 - 0
prisma/mongo/schema/ads.prisma

@@ -8,6 +8,8 @@ model Ads {
   adsCoverImg  String?                       // 广告图片
   adsUrl       String?                       // 广告链接
 
+  imgSource    ImageSource @default(PROVIDER)
+
   // 有效期,使用 BigInt epoch
   startDt      BigInt                        // 开始时间
   expiryDt     BigInt                        // 到期时间

+ 7 - 0
prisma/mongo/schema/main.prisma

@@ -9,3 +9,10 @@ datasource db {
   provider = "mongodb"
   url      = env("MONGO_URL")
 }
+
+enum ImageSource {
+  PROVIDER
+  LOCAL_ONLY
+  S3_ONLY
+  S3_AND_LOCAL
+}

+ 2 - 0
prisma/mongo/schema/video-media.prisma

@@ -88,6 +88,8 @@ model VideoMedia {
   listStatus    Int        @default(0)       // 上/下架
   editedAt      BigInt     @default(0)       // 本地编辑时间 (epoch ms)
 
+  imgSource     ImageSource @default(PROVIDER)
+
   // Relations (optional, just for Prisma)
   // category      Category?  @relation(fields: [categoryId], references: [id])
 

+ 21 - 0
prisma/mysql/schema/image-config.prisma

@@ -0,0 +1,21 @@
+enum ImageSource {
+  PROVIDER
+  LOCAL_ONLY
+  S3_ONLY
+  S3_AND_LOCAL
+}
+
+model ImageConfig {
+  id                 Int          @id @default(autoincrement())
+  channelId          Int?         @map("channel_id")
+  providerDecodeBase String?      @map("provider_decode_base")
+  localBaseUrl       String?      @map("local_base_url")
+  s3BaseUrl          String?      @map("s3_base_url")
+  preferredSource    ImageSource  @default(PROVIDER) @map("preferred_source")
+  status             Int          @default(1)
+  createAt           BigInt       @default(0) @map("create_at")
+  updateAt           BigInt       @default(0) @map("update_at")
+
+  @@map("image_config")
+  @@index([channelId], map: "idx_image_config_channel_id")
+}