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 文档已启用');
|
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
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