From 33f1cc1859826b029a9c94e7262da59e0484d7a5 Mon Sep 17 00:00:00 2001 From: WangDL Date: Mon, 18 May 2026 10:50:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20P2=20infrastructure=20=E2=80=94=20Docke?= =?UTF-8?q?r=20Compose,=20shutdown=20hooks,=20Prisma=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B20: docker-compose.yml with MySQL 8.0, Redis 7, API, BullMQ Worker, Nginx - B20: Dockerfile.worker + worker.module.ts + worker.main.ts for standalone worker - B20: nginx/nginx.conf reverse proxy with gzip, /api/* routes, health check - B21: app.enableShutdownHooks() in main.ts for graceful SIGTERM handling - B22: migration adding objectKey/bucket to UploadedFile, AiUsageLog, WaitlistEntry Co-Authored-By: Claude Opus 4.7 --- Dockerfile.worker | 29 ++++ docker-compose.yml | 148 ++++++++++++++++++ nginx/nginx.conf | 85 ++++++++++ .../migration.sql | 48 ++++++ src/main.ts | 2 + src/worker.main.ts | 12 ++ src/worker.module.ts | 69 ++++++++ 7 files changed, 393 insertions(+) create mode 100644 Dockerfile.worker create mode 100644 docker-compose.yml create mode 100644 nginx/nginx.conf create mode 100644 prisma/migrations/20250518000000_add_objectkey_bucket_aiusage_waitlist/migration.sql create mode 100644 src/worker.main.ts create mode 100644 src/worker.module.ts diff --git a/Dockerfile.worker b/Dockerfile.worker new file mode 100644 index 0000000..4ba0f12 --- /dev/null +++ b/Dockerfile.worker @@ -0,0 +1,29 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache openssl + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY tsconfig.json tsconfig.build.json nest-cli.json ./ +COPY prisma ./prisma +COPY src ./src + +RUN npx prisma generate +RUN npm run build +RUN npm prune --production + +FROM node:22-alpine + +WORKDIR /app + +RUN apk add --no-cache openssl + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/package.json ./ + +CMD ["node", "dist/worker.main.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6ad3906 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,148 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: zhixi-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword} + MYSQL_DATABASE: zhixi + MYSQL_USER: zhixi_user + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-Zhixi@2026!App} + ports: + - '3307:3306' + volumes: + - mysql_data:/var/lib/mysql + - ./prisma/init:/docker-entrypoint-initdb.d + healthcheck: + test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost'] + interval: 10s + timeout: 5s + retries: 5 + networks: + - zhixi-net + + redis: + image: redis:7-alpine + container_name: zhixi-redis + restart: unless-stopped + ports: + - '6379:6379' + volumes: + - redis_data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + networks: + - zhixi-net + + api: + build: + context: . + dockerfile: Dockerfile + image: zhixi-api:latest + container_name: zhixi-api + restart: unless-stopped + ports: + - '3000:3000' + environment: + NODE_ENV: production + PORT: '3000' + DATABASE_URL: mysql://zhixi_user:${MYSQL_PASSWORD:-Zhixi@2026!App}@mysql:3306/zhixi + REDIS_HOST: redis + REDIS_PORT: '6379' + REDIS_PASSWORD: '' + REDIS_DB: '0' + AI_PROVIDER: ${AI_PROVIDER:-mock} + AI_DEFAULT_TIER: ${AI_DEFAULT_TIER:-primary} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com} + MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + MINIMAX_BASE_URL: ${MINIMAX_BASE_URL:-https://api.minimaxi.com} + JWT_SECRET: ${JWT_SECRET:-change_me_in_production} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-1h} + JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d} + DEV_SECRET: ${DEV_SECRET:-} + APPLE_BUNDLE_ID: ${APPLE_BUNDLE_ID:-cloud.longde.AIStudyApp} + APPLE_ISSUER: ${APPLE_ISSUER:-https://appleid.apple.com} + APPLE_JWKS_URL: ${APPLE_JWKS_URL:-https://appleid.apple.com/auth/keys} + ENABLE_SWAGGER: ${ENABLE_SWAGGER:-false} + SWAGGER_USER: ${SWAGGER_USER:-admin} + SWAGGER_PASSWORD: ${SWAGGER_PASSWORD:-} + STORAGE_DRIVER: ${STORAGE_DRIVER:-cos} + STORAGE_LOCAL_PATH: ./uploads + STORAGE_COS_SECRET_ID: ${STORAGE_COS_SECRET_ID:-} + STORAGE_COS_SECRET_KEY: ${STORAGE_COS_SECRET_KEY:-} + STORAGE_COS_BUCKET: ${STORAGE_COS_BUCKET:-} + STORAGE_COS_REGION: ${STORAGE_COS_REGION:-ap-guangzhou} + STORAGE_COS_DOMAIN: ${STORAGE_COS_DOMAIN:-} + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + networks: + - zhixi-net + + worker: + build: + context: . + dockerfile: Dockerfile.worker + image: zhixi-worker:latest + container_name: zhixi-worker + restart: unless-stopped + environment: + NODE_ENV: production + DATABASE_URL: mysql://zhixi_user:${MYSQL_PASSWORD:-Zhixi@2026!App}@mysql:3306/zhixi + REDIS_HOST: redis + REDIS_PORT: '6379' + REDIS_PASSWORD: '' + REDIS_DB: '0' + AI_PROVIDER: ${AI_PROVIDER:-mock} + AI_DEFAULT_TIER: ${AI_DEFAULT_TIER:-primary} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com} + MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + MINIMAX_BASE_URL: ${MINIMAX_BASE_URL:-https://api.minimaxi.com} + JWT_SECRET: ${JWT_SECRET:-change_me_in_production} + STORAGE_DRIVER: ${STORAGE_DRIVER:-cos} + STORAGE_COS_SECRET_ID: ${STORAGE_COS_SECRET_ID:-} + STORAGE_COS_SECRET_KEY: ${STORAGE_COS_SECRET_KEY:-} + STORAGE_COS_BUCKET: ${STORAGE_COS_BUCKET:-} + STORAGE_COS_REGION: ${STORAGE_COS_REGION:-ap-guangzhou} + STORAGE_COS_DOMAIN: ${STORAGE_COS_DOMAIN:-} + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + networks: + - zhixi-net + + nginx: + image: nginx:1.25-alpine + container_name: zhixi-nginx + restart: unless-stopped + ports: + - '80:80' + - '443:443' + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + depends_on: + - api + networks: + - zhixi-net + +volumes: + mysql_data: + driver: local + redis_data: + driver: local + +networks: + zhixi-net: + driver: bridge diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..ed4081c --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,85 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + client_max_body_size 10m; + + gzip on; + gzip_vary on; + gzip_comp_level 5; + gzip_min_length 256; + gzip_types application/json text/plain text/css application/javascript; + + # API server reverse proxy + server { + listen 80; + server_name _; + + # Health check — bypass rate limit + location /api/health { + proxy_pass http://api:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 10s; + } + + # API + location /api/ { + proxy_pass http://api:3000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 90s; + proxy_connect_timeout 10s; + } + + # Swagger docs + location /api-docs { + proxy_pass http://api:3000/api-docs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api-docs-json { + proxy_pass http://api:3000/api-docs-json; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Default: reject anything else + location / { + return 404; + } + } +} diff --git a/prisma/migrations/20250518000000_add_objectkey_bucket_aiusage_waitlist/migration.sql b/prisma/migrations/20250518000000_add_objectkey_bucket_aiusage_waitlist/migration.sql new file mode 100644 index 0000000..2b7dc59 --- /dev/null +++ b/prisma/migrations/20250518000000_add_objectkey_bucket_aiusage_waitlist/migration.sql @@ -0,0 +1,48 @@ +-- AlterTable: UploadedFile add objectKey + bucket + index +ALTER TABLE `UploadedFile` + ADD COLUMN `objectKey` VARCHAR(500) NULL AFTER `storagePath`, + ADD COLUMN `bucket` VARCHAR(100) NULL AFTER `objectKey`; + +CREATE INDEX `UploadedFile_objectKey_idx` ON `UploadedFile`(`objectKey`); + +-- CreateTable: AiUsageLog +CREATE TABLE `AiUsageLog` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `feature` VARCHAR(64) NOT NULL, + `provider` VARCHAR(32) NOT NULL, + `model` VARCHAR(100) NOT NULL, + `tier` VARCHAR(32) NOT NULL, + `promptKey` VARCHAR(128) NOT NULL, + `promptVersion` VARCHAR(32) NOT NULL, + `inputTokens` INTEGER NOT NULL DEFAULT 0, + `outputTokens` INTEGER NOT NULL DEFAULT 0, + `estimatedCost` DOUBLE NOT NULL DEFAULT 0, + `latencyMs` INTEGER NOT NULL DEFAULT 0, + `success` BOOLEAN NOT NULL DEFAULT true, + `errorMessage` VARCHAR(500) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `AiUsageLog_userId_idx`(`userId`), + INDEX `AiUsageLog_feature_idx`(`feature`), + INDEX `AiUsageLog_createdAt_idx`(`createdAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable: WaitlistEntry +CREATE TABLE `WaitlistEntry` ( + `id` VARCHAR(191) NOT NULL, + `nickname` VARCHAR(100) NOT NULL, + `email` VARCHAR(255) NOT NULL, + `devices` JSON NULL, + `interests` JSON NULL, + `painpoint` TEXT NULL, + `willingBeta` BOOLEAN NOT NULL DEFAULT false, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `WaitlistEntry_email_idx`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `AiUsageLog` ADD CONSTRAINT `AiUsageLog_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/main.ts b/src/main.ts index 63c1d77..6447e45 100644 --- a/src/main.ts +++ b/src/main.ts @@ -87,6 +87,8 @@ async function bootstrap() { console.log('[Swagger] API 文档已启用'); } + app.enableShutdownHooks(); + const port = configService.get('app.port', 3000); await app.listen(port); console.log(`[API] Server running on http://localhost:${port}`); diff --git a/src/worker.main.ts b/src/worker.main.ts new file mode 100644 index 0000000..53cae2b --- /dev/null +++ b/src/worker.main.ts @@ -0,0 +1,12 @@ +import { NestFactory } from '@nestjs/core'; +import { WorkerModule } from './worker.module'; + +async function bootstrap() { + const app = await NestFactory.createApplicationContext(WorkerModule); + + app.enableShutdownHooks(); + + console.log('[Worker] BullMQ workers started — waiting for jobs'); +} + +bootstrap(); diff --git a/src/worker.module.ts b/src/worker.module.ts new file mode 100644 index 0000000..e0abc72 --- /dev/null +++ b/src/worker.module.ts @@ -0,0 +1,69 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; + +import { PrismaModule } from './infrastructure/database/prisma.module'; +import { RedisModule } from './infrastructure/redis/redis.module'; +import { QueueModule } from './infrastructure/queue/queue.module'; +import { AiModule } from './modules/ai/ai.module'; +import { StorageModule } from './infrastructure/storage/storage.module'; +import { LoggerModule } from './infrastructure/logger/logger.module'; + +import { AiAnalysisWorker } from './workers/ai-analysis.worker'; +import { DocumentImportWorker } from './workers/document-import.worker'; +import { NotificationWorker } from './workers/notification.worker'; + +import { AiAnalysisModule } from './modules/ai-analysis/ai-analysis.module'; +import { DocumentImportModule } from './modules/document-import/document-import.module'; +import { KnowledgeItemsModule } from './modules/knowledge-items/knowledge-items.module'; +import { NotificationsModule } from './modules/notifications/notifications.module'; + +import appConfig from './config/app.config'; +import databaseConfig from './config/database.config'; +import redisConfig from './config/redis.config'; +import jwtConfig from './config/jwt.config'; +import aiConfig from './config/ai.config'; +import storageConfig from './config/storage.config'; +import appleConfig from './config/apple.config'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [ + appConfig, + databaseConfig, + redisConfig, + jwtConfig, + aiConfig, + storageConfig, + appleConfig, + ], + }), + JwtModule.registerAsync({ + global: true, + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.secret'), + signOptions: { expiresIn: config.get('jwt.expiresIn', '1h') as any }, + }), + }), + PrismaModule, + RedisModule, + QueueModule, + AiModule, + StorageModule, + LoggerModule, + AiAnalysisModule, + DocumentImportModule, + KnowledgeItemsModule, + NotificationsModule, + ], + providers: [ + AiAnalysisWorker, + DocumentImportWorker, + NotificationWorker, + ], +}) +export class WorkerModule {}