import express from 'express' import process from 'node:process' import { runtimeConfig } from './config/runtime.js' import { runDatabaseMigrations } from './db/migrate.js' import adminRouter from './routes/admin.js' import claimsRouter from './routes/claims.js' import open91Router from './routes/open-91.js' import webhooksRouter from './routes/webhooks.js' import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js' import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfillment-bootstrap-service.js' import { startScheduledJobs, stopScheduledJobs } from './services/scheduler/scheduler-service.js' import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/session/ocr.js' import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js' import { buildSuccessPayload } from './utils/http.js' import { createRequestId, logError, logInfo, logWarn } from './utils/logger.js' import tencentRouter from './routes/tencent.js' const CORE_BOOT_RETRY_DELAY_MS = 5_000 const app = express() const port = Number(runtimeConfig.server.port || 3000) const host = '0.0.0.0' const startupState = createStartupState() app.use((req, res, next) => { const startedAt = Date.now() const requestId = resolveRequestId(req) req.requestId = requestId res.setHeader('X-Request-Id', requestId) res.on('finish', () => { if (shouldSkipAccessLog(req.originalUrl, res.statusCode)) { return } logInfo('[http/access]', 'request completed', { requestId, method: req.method, originalUrl: req.originalUrl, statusCode: res.statusCode, durationMs: Date.now() - startedAt, ip: req.ip, forwardedFor: String(req.headers['x-forwarded-for'] || ''), userAgent: String(req.headers['user-agent'] || ''), referer: String(req.headers.referer || ''), contentLength: Number(res.getHeader('content-length') || 0), actorUserId: req.adminSession?.userId || '', actorUsername: req.adminSession?.username || '', actorRole: req.adminSession?.role || '', }) }) next() }) app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') if (req.method === 'OPTIONS') { res.sendStatus(204) return } next() }) app.use(express.json({ limit: '2mb' })) app.use(express.urlencoded({ extended: true })) app.get('/health', (_req, res) => { res.json(buildSuccessPayload(buildHealthPayload(), startupState.core.ready ? 'ready' : 'starting')) }) app.get('/health/live', (_req, res) => { res.json(buildSuccessPayload({ status: shutdownStarted ? 'shutting_down' : 'alive', pid: process.pid, })) }) app.get('/health/ready', (_req, res) => { if (startupState.core.ready) { res.json(buildSuccessPayload(buildHealthPayload(), 'ready')) return } res.status(503).json({ code: 1, msg: startupState.core.lastError || '服务启动中,请稍后重试', errorCode: 'service_not_ready', time: Math.floor(Date.now() / 1000), data: buildHealthPayload(), }) }) app.use((req, res, next) => { if (startupState.core.ready) { next() return } res.status(503).json({ code: 1, msg: startupState.core.lastError || '服务启动中,请稍后重试', errorCode: 'service_not_ready', time: Math.floor(Date.now() / 1000), data: { startup: { phase: startupState.phase, attemptCount: startupState.core.attemptCount, lastAttemptAt: startupState.core.lastAttemptAt, }, }, }) }) app.use('/api/v1/tencent', tencentRouter) app.use('/api/v1/open/91', open91Router) app.use('/api/v1/webhooks', webhooksRouter) app.use('/api/v1/claim', claimsRouter) app.use('/api/v1/admin', adminRouter) const server = app.listen(port, host, () => { logInfo('[startup]', `order-site-backend listening on http://${host}:${port}`) void bootstrapCoreServices() }) server.on('error', (error) => { logError('[startup]', 'HTTP server failed', error) }) async function bootstrapCoreServices() { if (startupState.core.running || shutdownStarted) { return } startupState.core.running = true while (!shutdownStarted && !startupState.core.ready) { startupState.phase = startupState.core.attemptCount === 0 ? 'starting' : 'retrying' startupState.core.attemptCount += 1 startupState.core.lastAttemptAt = new Date().toISOString() try { logInfo('[startup]', '开始执行核心启动步骤', { attempt: startupState.core.attemptCount, }) await runDatabaseMigrations() await ensureFulfillmentCatalogBootstrapped() await ensureAdminUsersBootstrapped() startupState.core.ready = true startupState.core.lastError = '' startupState.phase = 'ready' startupState.core.readyAt = new Date().toISOString() logInfo('[startup]', '核心启动步骤完成,服务已就绪', { attempt: startupState.core.attemptCount, }) startScheduledJobs() void bootstrapBrowser() void bootstrapOcr() break } catch (error) { const message = formatStartupError(error) startupState.phase = 'retrying' startupState.core.lastError = message logError('[startup]', '核心启动步骤失败,将自动重试', { attempt: startupState.core.attemptCount, retryDelayMs: CORE_BOOT_RETRY_DELAY_MS, error, }) await sleep(CORE_BOOT_RETRY_DELAY_MS) } } startupState.core.running = false } async function bootstrapBrowser() { startupState.browser.status = 'starting' startupState.browser.lastAttemptAt = new Date().toISOString() try { const result = await warmupTencentBrowser() if (!result.warmed) { startupState.browser.status = 'skipped' startupState.browser.message = 'browser prewarm skipped' logInfo('[startup]', 'browser prewarm skipped') return } startupState.browser.status = 'ready' startupState.browser.message = 'browser prewarm ready' logInfo('[startup]', 'browser prewarm ready') } catch (error) { if (isMissingPlaywrightBrowserError(error)) { const installCommand = resolveBrowserInstallCommand() startupState.browser.status = 'degraded' startupState.browser.message = formatErrorMessage(error) logWarn('[startup]', `browser prewarm skipped: ${formatErrorMessage(error)}`) logWarn('[startup]', `请先安装浏览器依赖: ${installCommand}`) if (String(runtimeConfig.browser.chromePath || '').trim()) { logWarn('[startup]', '当前设置了 CHROME_PATH,也请确认该路径指向的浏览器可执行文件真实存在') } return } startupState.browser.status = 'degraded' startupState.browser.message = formatStartupError(error) logError('[startup]', 'browser prewarm failed', error) } } async function bootstrapOcr() { startupState.ocr.status = 'starting' startupState.ocr.lastAttemptAt = new Date().toISOString() try { await warmupLocalOcrWorker() startupState.ocr.status = 'ready' startupState.ocr.message = 'local OCR ready' logInfo('[startup]', 'local OCR ready') } catch (error) { startupState.ocr.status = 'degraded' startupState.ocr.message = formatStartupError(error) logWarn('[startup]', `local OCR skipped: ${formatStartupError(error)}`) } } function buildHealthPayload() { return { phase: startupState.phase, startedAt: startupState.startedAt, core: { ready: startupState.core.ready, running: startupState.core.running, attemptCount: startupState.core.attemptCount, readyAt: startupState.core.readyAt, lastAttemptAt: startupState.core.lastAttemptAt, lastError: startupState.core.lastError, }, browser: { status: startupState.browser.status, message: startupState.browser.message, lastAttemptAt: startupState.browser.lastAttemptAt, }, ocr: { status: startupState.ocr.status, message: startupState.ocr.message, lastAttemptAt: startupState.ocr.lastAttemptAt, }, process: { pid: process.pid, shutdownStarted, lastUnhandledRejection: startupState.process.lastUnhandledRejection, lastUncaughtException: startupState.process.lastUncaughtException, }, } } function createStartupState() { return { phase: 'starting', startedAt: new Date().toISOString(), core: { ready: false, running: false, attemptCount: 0, readyAt: '', lastAttemptAt: '', lastError: '', }, browser: { status: 'pending', message: '', lastAttemptAt: '', }, ocr: { status: 'pending', message: '', lastAttemptAt: '', }, process: { lastUnhandledRejection: null, lastUncaughtException: null, }, } } function formatStartupError(error) { if (error instanceof Error && error.message) { return error.message.split('\n')[0].trim() } return String(error || '未知错误').trim() } function isMissingPlaywrightBrowserError(error) { const message = formatErrorMessage(error).toLowerCase() return ( message.includes("executable doesn't exist") || message.includes('please run the following command to download new browsers') || message.includes('browserType.launch'.toLowerCase()) && message.includes('please run') ) } function formatErrorMessage(error) { if (error instanceof Error && error.message) { return error.message.split('\n')[0].trim() } return String(error || '未知错误').trim() } function resolveBrowserInstallCommand() { if (process.platform === 'linux') { return 'npm run browser:install:linux' } return 'npm run browser:install' } function sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms) }) } function resolveRequestId(req) { const fromHeader = String(req.headers['x-request-id'] || '').trim() return fromHeader || createRequestId('req') } function shouldSkipAccessLog(originalUrl, statusCode) { const pathname = String(originalUrl || '').split('?')[0] return ['/health', '/health/live', '/health/ready'].includes(pathname) && Number(statusCode) < 400 } let shutdownStarted = false async function shutdown(signal) { if (shutdownStarted) { return } shutdownStarted = true startupState.phase = 'shutting_down' logInfo('[shutdown]', `received ${signal}, closing browser sessions and HTTP server`) stopScheduledJobs() try { await closeAllTencentBrowserSessions({ markClosed: true }) } catch (error) { logError('[shutdown]', 'failed to close browser sessions', error) } try { await closeLocalOcrWorker() } catch (error) { logError('[shutdown]', 'failed to close local OCR worker', error) } if (!server.listening) { return } await new Promise((resolve) => { server.close((error) => { if (error) { logError('[shutdown]', 'failed to close HTTP server', error) process.exitCode = 1 } resolve() }) }) } process.on('SIGINT', () => { void shutdown('SIGINT') }) process.on('SIGTERM', () => { void shutdown('SIGTERM') }) process.on('unhandledRejection', (reason) => { startupState.process.lastUnhandledRejection = { time: new Date().toISOString(), message: formatStartupError(reason), } logError('[process]', 'unhandled promise rejection', reason) }) process.on('uncaughtException', (error) => { startupState.process.lastUncaughtException = { time: new Date().toISOString(), message: formatStartupError(error), } logError('[process]', 'uncaught exception captured', error) })