feat: P2 infrastructure — Docker Compose, shutdown hooks, Prisma migration
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 1m1s

- 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 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-18 10:50:59 +08:00
parent 82fcaa1f2f
commit 33f1cc1859
7 changed files with 393 additions and 0 deletions

29
Dockerfile.worker Normal file
View File

@ -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"]

148
docker-compose.yml Normal file
View File

@ -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

85
nginx/nginx.conf Normal file
View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -87,6 +87,8 @@ async function bootstrap() {
console.log('[Swagger] API 文档已启用'); console.log('[Swagger] API 文档已启用');
} }
app.enableShutdownHooks();
const port = configService.get<number>('app.port', 3000); const port = configService.get<number>('app.port', 3000);
await app.listen(port); await app.listen(port);
console.log(`[API] Server running on http://localhost:${port}`); console.log(`[API] Server running on http://localhost:${port}`);

12
src/worker.main.ts Normal file
View File

@ -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();

69
src/worker.module.ts Normal file
View File

@ -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<string>('jwt.secret'),
signOptions: { expiresIn: config.get<string>('jwt.expiresIn', '1h') as any },
}),
}),
PrismaModule,
RedisModule,
QueueModule,
AiModule,
StorageModule,
LoggerModule,
AiAnalysisModule,
DocumentImportModule,
KnowledgeItemsModule,
NotificationsModule,
],
providers: [
AiAnalysisWorker,
DocumentImportWorker,
NotificationWorker,
],
})
export class WorkerModule {}