235 lines
7.3 KiB
JavaScript
235 lines
7.3 KiB
JavaScript
require('dotenv').config()
|
|
const express = require('express')
|
|
const rateLimit = require('express-rate-limit')
|
|
const cors = require('cors')
|
|
const helmet = require('helmet')
|
|
const { startSettlementWorker, stopSettlementWorker } = require('./settlementWorker')
|
|
const { startProviderHealthMonitor, stopProviderHealthMonitor } = require('./providerHealthMonitor')
|
|
const {
|
|
startStaleTaskCleanup,
|
|
startTaskEventListener,
|
|
startPollerRecovery,
|
|
stopStaleTaskCleanup,
|
|
stopTaskEventListener,
|
|
stopPollerRecovery,
|
|
stopAllPollers,
|
|
} = require('./aiTaskWorker')
|
|
const { ensureDatabase } = require('./dbSetup')
|
|
const { assertRuntimeSecurityConfig } = require('./securityConfig')
|
|
const { loadPriceCache } = require('./pricing')
|
|
|
|
assertRuntimeSecurityConfig()
|
|
|
|
const routes = require('./routes')
|
|
|
|
const PORT = Number(process.env.PORT) || 3600
|
|
const HOST = process.env.HOST || '0.0.0.0'
|
|
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
|
|
let server = null
|
|
let staleLeaseCleanupTimer = null
|
|
|
|
// CORS: in production, require explicit allowlist; in dev, allow all with credentials
|
|
function buildCorsOptions() {
|
|
const raw = process.env.CORS_ORIGINS || ''
|
|
if (IS_PRODUCTION) {
|
|
if (!raw || raw === '*') {
|
|
console.warn('[security] CORS_ORIGINS not set in production, defaulting to same-origin only')
|
|
return { origin: false, credentials: true }
|
|
}
|
|
return {
|
|
origin: raw.split(',').map((s) => s.trim()),
|
|
credentials: true,
|
|
}
|
|
}
|
|
// Development: allow any origin for local testing
|
|
return { origin: true, credentials: true }
|
|
}
|
|
|
|
async function main() {
|
|
const { createdDefaultAdmin } = await ensureDatabase()
|
|
if (createdDefaultAdmin) {
|
|
console.log('[db] Created default admin user: admin (password sourced from configuration)')
|
|
}
|
|
|
|
await loadPriceCache()
|
|
|
|
const app = express()
|
|
|
|
// Trust single-hop Nginx reverse proxy (X-Forwarded-For, X-Real-IP)
|
|
app.set('trust proxy', 1)
|
|
|
|
// Security headers via helmet
|
|
app.use(helmet({
|
|
contentSecurityPolicy: false, // Disable CSP for now (SPA needs inline scripts in dev)
|
|
crossOriginEmbedderPolicy: false, // Allow OSS images/videos
|
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
}))
|
|
|
|
// CORS
|
|
app.use(cors(buildCorsOptions()))
|
|
|
|
// Rate limiting: global (300 req/min per IP)
|
|
const globalLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 300,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: '请求过于频繁,请稍后再试' },
|
|
})
|
|
app.use('/api', globalLimiter)
|
|
|
|
// Rate limiting: auth endpoints (stricter — 10 req/min per IP)
|
|
const authLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: '登录尝试过于频繁,请1分钟后再试' },
|
|
})
|
|
app.use('/api/auth/login', authLimiter)
|
|
app.use('/api/auth/register', authLimiter)
|
|
app.use('/api/auth/sms', authLimiter)
|
|
|
|
// Rate limiting: AI generation endpoints (20 req/min per IP)
|
|
const aiGenerationLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 20,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'AI生成请求过于频繁,请稍后再试' },
|
|
})
|
|
app.use('/api/ai/image', aiGenerationLimiter)
|
|
app.use('/api/ai/video', aiGenerationLimiter)
|
|
|
|
// Rate limiting: AI chat endpoint (60 req/min per IP — ecommerce flows need ~7 sequential calls)
|
|
const aiChatLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 60,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'AI对话请求过于频繁,请稍后再试' },
|
|
})
|
|
app.use('/api/ai/chat', aiChatLimiter)
|
|
|
|
|
|
// Skip JSON body-parser for binary upload routes (busboy handles multipart parsing)
|
|
app.use('/api/oss/upload-binary', (req, res, next) => { req._body = true; next(); })
|
|
app.use("/api/files/extract-text", (req, res, next) => { req._body = true; next(); })
|
|
// JSON body limit: 5MB globally (upload routes override locally)
|
|
app.use('/api/oss/upload', express.json({ limit: '200mb' }))
|
|
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' }))
|
|
|
|
app.use('/api', routes)
|
|
app.use('/api', (req, res) => {
|
|
res.status(404).json({
|
|
error: 'API route not found',
|
|
path: req.originalUrl
|
|
})
|
|
})
|
|
app.use('/api', (err, req, res, next) => {
|
|
if (res.headersSent) return next(err)
|
|
const status = Number(err.status || err.statusCode || 500)
|
|
const safeStatus = status >= 400 && status < 600 ? status : 500
|
|
// Log full error internally, but don't leak details to client
|
|
console.error('[api] error:', err)
|
|
const message = safeStatus >= 500
|
|
? '服务器内部错误,请稍后重试'
|
|
: (err.message || '请求处理失败')
|
|
res.status(safeStatus).json({
|
|
error: message,
|
|
code: err.code || undefined,
|
|
})
|
|
})
|
|
|
|
// Periodic stale lease cleanup (every 5 min)
|
|
const { cleanStaleLeases } = require('./keyManager')
|
|
staleLeaseCleanupTimer = setInterval(() => {
|
|
cleanStaleLeases().then((cleaned) => {
|
|
if (cleaned > 0) console.log(`[cleanup] Released ${cleaned} stale lease(s)`)
|
|
}).catch((err) => {
|
|
console.error('[cleanup] error:', err)
|
|
})
|
|
}, 5 * 60 * 1000)
|
|
if (staleLeaseCleanupTimer.unref) staleLeaseCleanupTimer.unref()
|
|
|
|
startSettlementWorker()
|
|
startProviderHealthMonitor()
|
|
|
|
await startTaskEventListener()
|
|
startPollerRecovery()
|
|
startStaleTaskCleanup()
|
|
|
|
server = app.listen(PORT, HOST, () => {
|
|
console.log(`OmniAI Key Server running at http://${HOST}:${PORT}`)
|
|
console.log(`Health check: http://${HOST}:${PORT}/api/health`)
|
|
})
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('[startup] Fatal error:', err)
|
|
process.exit(1)
|
|
})
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error('[fatal] Unhandled promise rejection:', reason)
|
|
process.exitCode = 1
|
|
setTimeout(() => process.exit(1), 5000).unref()
|
|
})
|
|
|
|
process.on('uncaughtException', (err) => {
|
|
console.error('[fatal] Uncaught exception:', err)
|
|
process.exit(1)
|
|
})
|
|
|
|
|
|
// ── Graceful shutdown ───────────────────────────────────────────────────
|
|
let shuttingDown = false
|
|
|
|
async function shutdownRuntimeState() {
|
|
if (staleLeaseCleanupTimer) {
|
|
clearInterval(staleLeaseCleanupTimer)
|
|
staleLeaseCleanupTimer = null
|
|
}
|
|
stopSettlementWorker()
|
|
stopProviderHealthMonitor()
|
|
stopPollerRecovery()
|
|
stopStaleTaskCleanup()
|
|
await Promise.allSettled([stopTaskEventListener(), stopAllPollers()])
|
|
}
|
|
|
|
function closeServer() {
|
|
if (!server || !server.listening) return Promise.resolve()
|
|
return new Promise((resolve) => {
|
|
server.close(() => {
|
|
console.log('[shutdown] Server closed, cleaning up...')
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
async function gracefulShutdown(signal) {
|
|
if (shuttingDown) return
|
|
shuttingDown = true
|
|
console.log('[shutdown] Received ' + signal + ', draining connections...')
|
|
|
|
setTimeout(() => {
|
|
console.error('[shutdown] Forced exit after timeout')
|
|
process.exit(1)
|
|
}, 15000).unref()
|
|
|
|
try {
|
|
await shutdownRuntimeState()
|
|
await closeServer()
|
|
const { pool } = require('./db')
|
|
await pool.end()
|
|
console.log('[shutdown] Database pool closed')
|
|
process.exit(0)
|
|
} catch (err) {
|
|
console.error('[shutdown] error:', err)
|
|
process.exit(0)
|
|
}
|
|
}
|
|
|
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
|