feat: P2 infrastructure — Docker Compose, shutdown hooks, Prisma migration
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 1m1s
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:
parent
82fcaa1f2f
commit
33f1cc1859
29
Dockerfile.worker
Normal file
29
Dockerfile.worker
Normal 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
148
docker-compose.yml
Normal 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
85
nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -87,6 +87,8 @@ async function bootstrap() {
|
||||
console.log('[Swagger] API 文档已启用');
|
||||
}
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const port = configService.get<number>('app.port', 3000);
|
||||
await app.listen(port);
|
||||
console.log(`[API] Server running on http://localhost:${port}`);
|
||||
|
||||
12
src/worker.main.ts
Normal file
12
src/worker.main.ts
Normal 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
69
src/worker.module.ts
Normal 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 {}
|
||||
Loading…
x
Reference in New Issue
Block a user