소스 검색

feat(media-config): add ImageConfig module and service for managing media configurations
feat(sys-params): implement endpoint to retrieve system configuration
fix(media-manager): refactor MediaManagerModule to support async registration
chore(package): add seed script for sysConfig and update README with usage instructions

Dave 1 개월 전
부모
커밋
de9b8a6e89

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

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

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

@@ -0,0 +1,31 @@
+import { Module } from '@nestjs/common';
+import { PrismaMongoModule } from '../../prisma/prisma-mongo.module';
+import { ImageConfigService } from './image-config.service';
+import { MediaManagerModule } from '@box/core/media-manager/media-manager.module';
+
+const mediaManagerModule = MediaManagerModule.registerAsync({
+  imports: [PrismaMongoModule],
+  useFactory: async (imageConfigService: ImageConfigService) => {
+    const imageConfig = await imageConfigService.getImageConfig();
+    return {
+      localRoot: imageConfig.local?.rootPath,
+      aws: imageConfig.s3
+        ? {
+            region: imageConfig.s3.region,
+            endpoint: imageConfig.s3.endpointUrl,
+            accessKeyId: imageConfig.s3.accessKeyId,
+            secretAccessKey: imageConfig.s3.secretAccessKey,
+            bucket: imageConfig.s3.bucket,
+          }
+        : undefined,
+    };
+  },
+  inject: [ImageConfigService],
+});
+
+@Module({
+  imports: [PrismaMongoModule, mediaManagerModule],
+  providers: [ImageConfigService],
+  exports: [MediaManagerModule],
+})
+export class ImageConfigModule {}

+ 57 - 0
apps/box-app-api/src/feature/media-config/image-config.service.ts

@@ -0,0 +1,57 @@
+import { Injectable } from '@nestjs/common';
+import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
+
+type ImageConfigDoc = {
+  _id: number;
+  imageConfig?: ImageConfig;
+};
+
+type ImageConfig = {
+  local?: {
+    rootPath?: string;
+    baseUrl?: string;
+  };
+  s3?: {
+    region?: string;
+    endpointUrl?: string;
+    accessKeyId?: string;
+    secretAccessKey?: string;
+    bucket?: string;
+  };
+};
+
+type MongoFindResult<T> = {
+  cursor: {
+    firstBatch: T[];
+  };
+};
+
+function isMongoFindResult<T>(value: unknown): value is MongoFindResult<T> {
+  if (typeof value !== 'object' || value === null) {
+    return false;
+  }
+
+  const anyValue = value as { cursor?: { firstBatch?: unknown } };
+  const firstBatch = anyValue.cursor?.firstBatch;
+  return Array.isArray(firstBatch);
+}
+
+@Injectable()
+export class ImageConfigService {
+  constructor(private readonly prisma: PrismaMongoService) {}
+
+  async getImageConfig(): Promise<ImageConfig> {
+    const raw: unknown = await this.prisma.$runCommandRaw({
+      find: 'sysConfig',
+      filter: { _id: -1 },
+      limit: 1,
+    });
+
+    if (!isMongoFindResult<ImageConfigDoc>(raw)) {
+      return {};
+    }
+
+    const doc = raw.cursor.firstBatch[0];
+    return doc?.imageConfig ?? {};
+  }
+}

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

@@ -33,4 +33,15 @@ export class SysParamsController {
   async getAdTypes() {
     return this.service.getAdTypes();
   }
+
+  @Get('sysCnf')
+  @ApiOperation({ summary: 'Get sys config appConfig' })
+  @ApiResponse({
+    status: 200,
+    description: 'Returns the appConfig object stored in sysConfig',
+  })
+  async getSysCnf() {
+    const data = await this.service.getSysCnf();
+    return { code: 1, msg: 'success', data };
+  }
 }

+ 35 - 0
apps/box-app-api/src/feature/sys-params/sys-params.service.ts

@@ -11,6 +11,27 @@ import {
   HOMEPAGE_CONSTANTS,
 } from '../homepage/homepage.constants';
 
+type SysConfigDoc = {
+  _id: number;
+  appConfig?: unknown;
+};
+
+type MongoFindResult<T> = {
+  cursor: {
+    firstBatch: T[];
+  };
+};
+
+function isMongoFindResult<T>(value: unknown): value is MongoFindResult<T> {
+  if (typeof value !== 'object' || value === null) {
+    return false;
+  }
+
+  const anyValue = value as { cursor?: { firstBatch?: unknown } };
+  const firstBatch = anyValue.cursor?.firstBatch;
+  return Array.isArray(firstBatch);
+}
+
 @Injectable()
 export class SysParamsService {
   constructor(private readonly prisma: PrismaMongoService) {}
@@ -80,4 +101,18 @@ export class SysParamsService {
     if (group === 'system') return system;
     return {};
   }
+
+  async getSysCnf() {
+    const raw: unknown = await this.prisma.$runCommandRaw({
+      find: 'sysConfig',
+      filter: { _id: -1 },
+      limit: 1,
+    });
+
+    const doc = isMongoFindResult<SysConfigDoc>(raw)
+      ? raw.cursor.firstBatch[0]
+      : undefined;
+
+    return (doc?.appConfig ?? {}) as Record<string, any>;
+  }
 }

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

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

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

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

+ 2 - 1
package.json

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

+ 9 - 0
prisma/mongo/README.md

@@ -0,0 +1,9 @@
+# Mongo seeds
+
+## sysConfig helper
+Run `pnpm prisma:seed:box-admin:sys-config` from the repo root to upsert the singleton Mongo document `{ _id: -1 }` with `appConfig.imageCdn`. That script uses `ts-node -P tsconfig.seed.json prisma/mongo/seed-sys-config.ts`.
+
+As an alternative you can do:
+```
+mongosh --eval "db.sysConfig.updateOne({ _id: -1 }, { $set: { appConfig: { imageCdn: { s3: 'https://s3.ap-east-1.amazonaws.com/mybucket-imgs', local: 'https://ww.xczox.xyz/images' } } } }, { upsert: true, multi: false })"
+```

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

@@ -0,0 +1,37 @@
+// prisma/mongo/seed-sys-config.ts
+import { PrismaClient } from '@prisma/mongo/client';
+
+const prisma = new PrismaClient();
+
+async function main() {
+  await prisma.$runCommandRaw({
+    update: 'sysConfig',
+    updates: [
+      {
+        q: { _id: -1 },
+        u: {
+          $set: {
+            appConfig: {
+              imageCdn: {
+                s3: 'https://s3.ap-east-1.amazonaws.com/mybucket-imgs',
+                local: 'https://ww.xczox.xyz/images',
+              },
+            },
+          },
+        },
+        upsert: true,
+        multi: false,
+      },
+    ],
+  });
+  console.log('sysConfig upserted with appConfig.imageCdn');
+}
+
+main()
+  .catch((err) => {
+    console.error('Failed to upsert sysConfig:', err);
+    process.exitCode = 1;
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+  });