diff --git a/.env.server.example b/.env.server.example index 0a46c1eb..697b9bfa 100644 --- a/.env.server.example +++ b/.env.server.example @@ -9,8 +9,8 @@ CLAIM_TOKEN_TTL_HOURS=24 POSTGRES_DB=order_site POSTGRES_USER=postgres -POSTGRES_PASSWORD=FACCqheAzy51op0RFdbv -DATABASE_URL=postgres://postgres:FACCqheAzy51op0RFdbv@postgres:5432/order_site +POSTGRES_PASSWORD=change-me-postgres-password +DATABASE_URL=postgres://postgres:change-me-postgres-password@postgres:5432/order_site DATABASE_SSL=false DATABASE_MAX_CONNECTIONS=20 @@ -24,6 +24,7 @@ TENCENT_BROWSER_DEVTOOLS=false TENCENT_BROWSER_PREWARM=true TENCENT_BROWSER_KEEP_ALIVE=true TENCENT_BROWSER_SLOW_MO=0 +TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24 TENCENT_SESSION_DEBUG=false CLOUDTENTACLES_BIND_URL_TTL_SECONDS=600 CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS=30 @@ -35,11 +36,11 @@ CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN=z90Syo CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL=http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F CLOUDTENTACLES_BIND_URL_PROBE_REFERER=https://gp.qq.com/ -ADMIN_SESSION_SECRET=xm4BDpbdHJvU4RIlpGqD0nvQTZtIidvQMk6LSP1d +ADMIN_SESSION_SECRET=change-me-long-random-session-secret ADMIN_SESSION_TTL_HOURS=12 -ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"Mzl5iG6pkdrzLs73COiB","role":"admin"},{"username":"operator","password":"d9tvmbPPMRdLySEhORzk","role":"operator"}] +ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}] -AGISO_APP_SECRET=tccxk5c7ppy7xpr43rvastceyydskfha +AGISO_APP_SECRET=change-me-agiso-app-secret AGISO_MESSAGING_ENABLED=true AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail AGISO_TRADE_DETAIL_API_VERSION=1 diff --git a/apps/backend/README.md b/apps/backend/README.md index c12174d1..ce404ff2 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -44,7 +44,7 @@ npm test 当前约定: -- 开发:`npm run dev` 使用 `tsx watch` 直接运行 `src`,并额外监听 `config` +- 开发:`npm run dev` 使用 `tsx watch` 直接运行 `src` - 数据库迁移:`npm run db:migrate` 使用 `tsx` - 生产:`npm run build` 输出 `dist`,`npm run start` 运行 `dist/index.js` - 新增或迁移后端模块优先使用 `.ts` @@ -53,7 +53,7 @@ npm test 当前后端采用 `.env` 单一路径做环境配置: -- 通用默认值:`config/default.cjs` +- 通用默认值:`src/config/defaults.ts` - 本地/部署覆盖:工作区根目录 `.env` 当前最常改的配置有: diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs deleted file mode 100644 index 2fb818a8..00000000 --- a/apps/backend/config/default.cjs +++ /dev/null @@ -1,131 +0,0 @@ -const path = require('node:path') - -module.exports = { - server: { - port: 3000, - }, - - browser: { - chromePath: '', - headless: null, - devtools: null, - keepAlive: null, - prewarm: null, - slowMoMs: null, - }, - - session: { - debug: false, - }, - - ocr: { - baseUrl: '', - }, - - data: { - root: path.resolve(__dirname, '../data'), - }, - - logging: { - level: 'info', - }, - - database: { - url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site', - ssl: false, - maxConnections: 10, - }, - - orders: { - claimBaseUrl: 'http://127.0.0.1:5173/#/claim', - tokenTtlHours: 24, - }, - - admin: { - sessionSecret: '', - sessionTtlHours: 12, - defaultUsers: [], - }, - - platforms: { - agiso: { - appSecret: '', - tradeDetail: { - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail', - apiVersion: '1', - timeoutMs: 5000, - }, - autoDelivery: { - enabled: true, - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - apiVersion: '1', - }, - messaging: { - enabled: false, - sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', - apiVersion: '1', - messageTemplate: '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}', - autoDeliveryMessageTemplate: '您的订单 {platformOrderId} 已完成自动发货,请注意查收。', - shops: {}, - }, - }, - ninetyone: { - userId: '', - secret: '', - version: '1.0', - shopId: '91kaquan', - shopName: '91卡券', - timestampToleranceSeconds: 600, - cardsEncoding: 'aes-256-ecb-base64', - }, - cloudtentacles: { - baseUrl: 'https://123.207.217.176', - timeoutMs: 5000, - sendSmsPath: '/public/verif_code', - loginPath: '/public/login', - userInfoPath: '/user/info', - assetPath: '/user/get_asset', - permissionPath: '/user/get_permission', - categoriesPath: '/categories/get', - skuListPath: '/sku/list', - skuBuyPath: '/sku/buy', - skuUsePath: '/sku/use', - knapsackPath: '/user/get_knapsack', - vnListPath: '/vn/list', - vnAppointPath: '/vn/appoint', - vnGenerateLoginCodePath: '/vn/generate_login_code', - vnVerifCodePath: '/public/vn_verif_code', - vnVerifyLoginCodePath: '/vn/verify_login_code', - vnBindUrlPath: '/vn/bind_url', - vnBindInfoPath: '/vn/bind_info', - vnBackPath: '/vn/back', - bindUrlTtlSeconds: 600, - bindUrlProbeIntervalSeconds: 30, - bindUrlProbeTimeoutMs: 5000, - bindUrlProbeUserAgent: 'Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/SKQ1.211006.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/98.0.4758.102 MQQBrowser/6.2 TBS/046141 Mobile Safari/537.36 V1_AND_SQ_8.8.85_2674_YYB_D QQ/8.8.85.7685 NetType/WIFI WebP/0.3.0 Pixel/1080 StatusBarHeight/108 SimpleUISwitch/0 QQTheme/1000', - bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', - bindUrlProbeChartId: '323794', - bindUrlProbeSubChartId: '323794', - bindUrlProbeIdeToken: 'z90Syo', - bindUrlProbeActivityUrl: 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F', - bindUrlProbeReferer: 'https://gp.qq.com/', - bindUrlProbeExtraCookie: '', - clientSource: 'ct-client', - deviceId: '-', - deviceType: 0, - publicKeyPem: `-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsSzjk+9st2f8IpYqYaM5 -F7K27ZR2IvzxTr2w4zgZAS2+BRdgMvXUOITdFQT+ytYF/r3pIgTkL7MF9UQuylmb -S57DiqGBLNBS7FzKyYzWTOc9yYA3+KrarbvpUn/Bm3UK+KKB17viPy+0pnNAHysJ -pzqadmAtQhI1ZDujJ2sW+FkV/pC8KIFahiyPIHjncy1ZG/BbRF3BDhvDcr6O7scO -GsHstGf9XsZBIITS5LYmlai7OdY4ZIVxUtQbfWzibihO+YDJJzFgt47drX0Ajo85 -PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15 -0wIDAQAB ------END PUBLIC KEY-----`, - }, - }, - - redeem: { - proofMode: 'full', - }, -} diff --git a/apps/backend/package.json b/apps/backend/package.json index a06e0e27..4302ee3b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,7 +11,7 @@ "seed:dev-data": "tsx scripts/seed-dev-data.ts", "build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", "db:migrate": "tsx src/db/migrate.ts", - "dev": "tsx watch --clear-screen=false --include config src/index.ts", + "dev": "tsx watch --clear-screen=false src/index.ts", "test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)", "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node dist/index.js", diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts new file mode 100644 index 00000000..420eb8f7 --- /dev/null +++ b/apps/backend/src/config/defaults.ts @@ -0,0 +1,136 @@ +import path from 'node:path' + +import type { RuntimeConfig } from '../types/runtime-config.js' + +export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { + return { + server: { + port: 3000, + }, + + browser: { + chromePath: '', + headless: null, + devtools: null, + keepAlive: null, + prewarm: null, + slowMoMs: null, + vncResolution: '1600x900x24', + }, + + session: { + debug: false, + }, + + ocr: { + baseUrl: '', + }, + + data: { + root: path.resolve(projectRoot, 'data'), + }, + + logging: { + level: 'info', + }, + + database: { + url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site', + ssl: false, + maxConnections: 10, + }, + + orders: { + claimBaseUrl: 'http://127.0.0.1:5173/#/claim', + tokenTtlHours: 24, + }, + + admin: { + sessionSecret: '', + sessionTtlHours: 12, + defaultUsers: [], + }, + + platforms: { + agiso: { + appSecret: '', + tradeDetail: { + endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail', + apiVersion: '1', + timeoutMs: 5000, + }, + autoDelivery: { + enabled: true, + endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', + apiVersion: '1', + }, + messaging: { + enabled: false, + sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', + apiVersion: '1', + messageTemplate: '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}', + autoDeliveryMessageTemplate: '您的订单 {platformOrderId} 已完成自动发货,请注意查收。', + shops: {}, + }, + }, + ninetyone: { + userId: '', + secret: '', + version: '1.0', + shopId: '91kaquan', + shopName: '91卡券', + timestampToleranceSeconds: 600, + cardsEncoding: 'aes-256-ecb-base64', + }, + cloudtentacles: { + baseUrl: 'https://123.207.217.176', + timeoutMs: 5000, + sendSmsPath: '/public/verif_code', + loginPath: '/public/login', + userInfoPath: '/user/info', + assetPath: '/user/get_asset', + permissionPath: '/user/get_permission', + categoriesPath: '/categories/get', + skuListPath: '/sku/list', + skuBuyPath: '/sku/buy', + skuUsePath: '/sku/use', + knapsackPath: '/user/get_knapsack', + vnListPath: '/vn/list', + vnAppointPath: '/vn/appoint', + vnGenerateLoginCodePath: '/vn/generate_login_code', + vnVerifCodePath: '/public/vn_verif_code', + vnVerifyLoginCodePath: '/vn/verify_login_code', + vnBindUrlPath: '/vn/bind_url', + vnBindInfoPath: '/vn/bind_info', + vnBackPath: '/vn/back', + bindUrlTtlSeconds: 600, + bindUrlProbeIntervalSeconds: 30, + bindUrlProbeTimeoutMs: 5000, + bindUrlProbeUserAgent: 'Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/SKQ1.211006.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/98.0.4758.102 MQQBrowser/6.2 TBS/046141 Mobile Safari/537.36 V1_AND_SQ_8.8.85_2674_YYB_D QQ/8.8.85.7685 NetType/WIFI WebP/0.3.0 Pixel/1080 StatusBarHeight/108 SimpleUISwitch/0 QQTheme/1000', + bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', + bindUrlProbeChartId: '323794', + bindUrlProbeSubChartId: '323794', + bindUrlProbeIdeToken: 'z90Syo', + bindUrlProbeActivityUrl: 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F', + bindUrlProbeReferer: 'https://gp.qq.com/', + bindUrlProbeExtraCookie: '', + clientSource: 'ct-client', + deviceId: '-', + deviceType: 0, + publicKeyPem: `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsSzjk+9st2f8IpYqYaM5 +F7K27ZR2IvzxTr2w4zgZAS2+BRdgMvXUOITdFQT+ytYF/r3pIgTkL7MF9UQuylmb +S57DiqGBLNBS7FzKyYzWTOc9yYA3+KrarbvpUn/Bm3UK+KKB17viPy+0pnNAHysJ +pzqadmAtQhI1ZDujJ2sW+FkV/pC8KIFahiyPIHjncy1ZG/BbRF3BDhvDcr6O7scO +GsHstGf9XsZBIITS5LYmlai7OdY4ZIVxUtQbfWzibihO+YDJJzFgt47drX0Ajo85 +PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15 +0wIDAQAB +-----END PUBLIC KEY-----`, + }, + }, + + redeem: { + proofMode: 'full', + }, + } +} diff --git a/apps/backend/src/config/env-documentation.test.ts b/apps/backend/src/config/env-documentation.test.ts new file mode 100644 index 00000000..dce702ae --- /dev/null +++ b/apps/backend/src/config/env-documentation.test.ts @@ -0,0 +1,81 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { ENV_OVERRIDES } from './env-overrides.js' + +const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const BACKEND_ROOT = path.resolve(CURRENT_DIR, '../..') +const WORKSPACE_ROOT = path.resolve(BACKEND_ROOT, '../..') + +const SPECIAL_RUNTIME_ENV_NAMES = [ + 'APP_BASE_URL', + 'CLAIM_BASE_URL', +] + +const DEPLOYMENT_ONLY_ENV_NAMES = [ + 'APP_DOMAIN', + 'BACKEND_PORT', + 'CADDY_SITE_ADDR', + 'CHOKIDAR_USEPOLLING', + 'NPM_CONFIG_REGISTRY', + 'OCR_IMAGE', + 'OCR_PORT', + 'POSTGRES_DB', + 'POSTGRES_PASSWORD', + 'POSTGRES_PORT', + 'POSTGRES_USER', + 'TENCENT_BROWSER_NOVNC_ENABLED', + 'TENCENT_BROWSER_NOVNC_PORT', + 'TENCENT_BROWSER_VNC_PORT', + 'TZ', + 'VITE_API_TARGET', +] + +const KNOWN_ENV_NAMES = new Set([ + ...ENV_OVERRIDES.map((override) => override.env), + ...SPECIAL_RUNTIME_ENV_NAMES, + ...DEPLOYMENT_ONLY_ENV_NAMES, +]) + +test('env example files only document known runtime or deployment variables', () => { + const envFiles = [ + path.join(WORKSPACE_ROOT, '.env.server.example'), + path.join(WORKSPACE_ROOT, '.env.mac-docker.example'), + ] + + const unknownNames = envFiles.flatMap((filePath) => { + return extractExampleEnvNames(filePath) + .filter((name) => !KNOWN_ENV_NAMES.has(name)) + .map((name) => `${path.basename(filePath)}:${name}`) + }) + + assert.deepEqual(unknownNames, []) +}) + +test('compose environment keys only use known runtime or deployment variables', () => { + const composeFiles = [ + path.join(WORKSPACE_ROOT, 'docker-compose.yml'), + path.join(WORKSPACE_ROOT, 'docker-compose.dev.yml'), + ] + + const unknownNames = composeFiles.flatMap((filePath) => { + return extractComposeEnvironmentKeys(filePath) + .filter((name) => !KNOWN_ENV_NAMES.has(name)) + .map((name) => `${path.basename(filePath)}:${name}`) + }) + + assert.deepEqual(unknownNames, []) +}) + +function extractExampleEnvNames(filePath: string): string[] { + const text = fs.readFileSync(filePath, 'utf8') + return [...text.matchAll(/^([A-Z][A-Z0-9_]*)=/gm)].map((match) => match[1]) +} + +function extractComposeEnvironmentKeys(filePath: string): string[] { + const text = fs.readFileSync(filePath, 'utf8') + return [...text.matchAll(/^\s{6}([A-Z][A-Z0-9_]*):/gm)].map((match) => match[1]) +} diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts new file mode 100644 index 00000000..f63256a5 --- /dev/null +++ b/apps/backend/src/config/env-overrides.test.ts @@ -0,0 +1,191 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' + +import { applyEnvOverrides, ENV_OVERRIDES } from './env-overrides.js' +import type { RuntimeConfig } from '../types/runtime-config.js' + +test('applyEnvOverrides maps typed environment values without mutating base config', () => { + const baseConfig = createRuntimeConfig() + + const config = applyEnvOverrides(baseConfig, { + PORT: '4000', + TENCENT_BROWSER_HEADLESS: 'true', + TENCENT_BROWSER_SLOW_MO: '250', + TENCENT_BROWSER_VNC_RESOLUTION: '1920x1080x24', + DATA_ROOT: './tmp/backend-data', + DATABASE_SSL: 'yes', + ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]', + AGISO_TRADE_DETAIL_TIMEOUT_MS: '7000', + CLOUDTENTACLES_DEVICE_TYPE: '3', + TENCENT_REDEEM_PROOF_MODE: 'basic', + }) + + assert.equal(config.server.port, 4000) + assert.equal(config.browser.headless, true) + assert.equal(config.browser.slowMoMs, 250) + assert.equal(config.browser.vncResolution, '1920x1080x24') + assert.equal(config.data.root, path.resolve('./tmp/backend-data')) + assert.equal(config.database.ssl, true) + assert.deepEqual(config.admin.defaultUsers, [ + { username: 'admin', password: 'secret', role: 'admin' }, + ]) + assert.equal(config.platforms.agiso.tradeDetail.timeoutMs, 7000) + assert.equal(config.platforms.cloudtentacles.deviceType, 3) + assert.equal(config.redeem.proofMode, 'basic') + + assert.equal(baseConfig.server.port, 3000) + assert.equal(baseConfig.browser.headless, null) + assert.deepEqual(baseConfig.admin.defaultUsers, []) +}) + +test('applyEnvOverrides derives claim base url from app base url when explicit claim url is absent', () => { + const config = applyEnvOverrides(createRuntimeConfig(), { + APP_BASE_URL: 'https://example.test///', + }) + + assert.equal(config.orders.claimBaseUrl, 'https://example.test/#/claim') +}) + +test('applyEnvOverrides prefers explicit claim base url over app base url', () => { + const config = applyEnvOverrides(createRuntimeConfig(), { + APP_BASE_URL: 'https://example.test', + CLAIM_BASE_URL: 'https://claim.example.test/custom', + }) + + assert.equal(config.orders.claimBaseUrl, 'https://claim.example.test/custom') +}) + +test('applyEnvOverrides ignores empty or invalid values', () => { + const config = applyEnvOverrides(createRuntimeConfig(), { + PORT: 'not-a-number', + DATABASE_SSL: 'maybe', + ADMIN_DEFAULT_USERS_JSON: '{"username":"admin"}', + LOG_LEVEL: ' ', + }) + + assert.equal(config.server.port, 3000) + assert.equal(config.database.ssl, false) + assert.deepEqual(config.admin.defaultUsers, []) + assert.equal(config.logging.level, 'info') +}) + +test('ENV_OVERRIDES keeps environment variable names unique', () => { + const names = ENV_OVERRIDES.map((override) => override.env) + assert.equal(new Set(names).size, names.length) +}) + +function createRuntimeConfig(): RuntimeConfig { + return { + server: { + port: 3000, + }, + browser: { + chromePath: '', + headless: null, + devtools: null, + keepAlive: null, + prewarm: null, + slowMoMs: null, + vncResolution: '1600x900x24', + }, + session: { + debug: false, + }, + ocr: { + baseUrl: '', + }, + data: { + root: '/tmp/order-site', + }, + logging: { + level: 'info', + }, + database: { + url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site', + ssl: false, + maxConnections: 10, + }, + orders: { + claimBaseUrl: 'http://127.0.0.1:5173/#/claim', + tokenTtlHours: 24, + }, + admin: { + sessionSecret: '', + sessionTtlHours: 12, + defaultUsers: [], + }, + platforms: { + agiso: { + appSecret: '', + tradeDetail: { + endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail', + apiVersion: '1', + timeoutMs: 5000, + }, + autoDelivery: { + enabled: true, + endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', + apiVersion: '1', + }, + messaging: { + enabled: false, + sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', + apiVersion: '1', + messageTemplate: '', + autoDeliveryMessageTemplate: '', + shops: {}, + }, + }, + ninetyone: { + userId: '', + secret: '', + version: '1.0', + shopId: '91kaquan', + shopName: '91卡券', + timestampToleranceSeconds: 600, + cardsEncoding: 'aes-256-ecb-base64', + }, + cloudtentacles: { + baseUrl: 'https://123.207.217.176', + timeoutMs: 5000, + sendSmsPath: '/public/verif_code', + loginPath: '/public/login', + userInfoPath: '/user/info', + assetPath: '/user/get_asset', + permissionPath: '/user/get_permission', + categoriesPath: '/categories/get', + skuListPath: '/sku/list', + skuBuyPath: '/sku/buy', + skuUsePath: '/sku/use', + knapsackPath: '/user/get_knapsack', + vnListPath: '/vn/list', + vnAppointPath: '/vn/appoint', + vnGenerateLoginCodePath: '/vn/generate_login_code', + vnVerifCodePath: '/public/vn_verif_code', + vnVerifyLoginCodePath: '/vn/verify_login_code', + vnBindUrlPath: '/vn/bind_url', + vnBindInfoPath: '/vn/bind_info', + vnBackPath: '/vn/back', + bindUrlTtlSeconds: 600, + bindUrlProbeIntervalSeconds: 30, + bindUrlProbeTimeoutMs: 5000, + bindUrlProbeUserAgent: '', + bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', + bindUrlProbeChartId: '323794', + bindUrlProbeSubChartId: '323794', + bindUrlProbeIdeToken: 'z90Syo', + bindUrlProbeActivityUrl: '', + bindUrlProbeReferer: 'https://gp.qq.com/', + bindUrlProbeExtraCookie: '', + publicKeyPem: '', + clientSource: 'ct-client', + deviceId: '-', + deviceType: 0, + }, + }, + redeem: { + proofMode: 'full', + }, + } +} diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts new file mode 100644 index 00000000..3413a6ed --- /dev/null +++ b/apps/backend/src/config/env-overrides.ts @@ -0,0 +1,178 @@ +import path from 'node:path' +import process from 'node:process' + +import type { AdminDefaultUser, RuntimeConfig } from '../types/runtime-config.js' +import { parseBoolean, parseInteger, parseJsonArray } from './runtime-env.js' +import { deepMerge } from './runtime-merge.js' + +type RuntimeConfigPath = readonly [string, ...string[]] +type RuntimeConfigValue = string | number | boolean | AdminDefaultUser[] +type RuntimeEnv = Record + +type EnvOverride = { + env: string + path: RuntimeConfigPath + read: (rawValue: unknown) => RuntimeConfigValue | null +} + +export const ENV_OVERRIDES: readonly EnvOverride[] = [ + integerEnv('PORT', ['server', 'port']), + stringEnv('CHROME_PATH', ['browser', 'chromePath']), + booleanEnv('TENCENT_BROWSER_HEADLESS', ['browser', 'headless']), + booleanEnv('TENCENT_BROWSER_DEVTOOLS', ['browser', 'devtools']), + booleanEnv('TENCENT_BROWSER_KEEP_ALIVE', ['browser', 'keepAlive']), + booleanEnv('TENCENT_BROWSER_PREWARM', ['browser', 'prewarm']), + integerEnv('TENCENT_BROWSER_SLOW_MO', ['browser', 'slowMoMs']), + stringEnv('TENCENT_BROWSER_VNC_RESOLUTION', ['browser', 'vncResolution']), + booleanEnv('TENCENT_SESSION_DEBUG', ['session', 'debug']), + stringEnv('OCR_BASE_URL', ['ocr', 'baseUrl']), + stringEnv('DATA_ROOT', ['data', 'root'], path.resolve), + stringEnv('LOG_LEVEL', ['logging', 'level']), + stringEnv('DATABASE_URL', ['database', 'url']), + booleanEnv('DATABASE_SSL', ['database', 'ssl']), + integerEnv('DATABASE_MAX_CONNECTIONS', ['database', 'maxConnections']), + integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']), + stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']), + integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionTtlHours']), + adminUsersJsonEnv('ADMIN_DEFAULT_USERS_JSON', ['admin', 'defaultUsers']), + stringEnv('AGISO_APP_SECRET', ['platforms', 'agiso', 'appSecret']), + stringEnv('AGISO_TRADE_DETAIL_ENDPOINT', ['platforms', 'agiso', 'tradeDetail', 'endpoint']), + stringEnv('AGISO_TRADE_DETAIL_API_VERSION', ['platforms', 'agiso', 'tradeDetail', 'apiVersion']), + integerEnv('AGISO_TRADE_DETAIL_TIMEOUT_MS', ['platforms', 'agiso', 'tradeDetail', 'timeoutMs']), + booleanEnv('AGISO_AUTO_DELIVERY_ENABLED', ['platforms', 'agiso', 'autoDelivery', 'enabled']), + stringEnv('AGISO_AUTO_DELIVERY_ENDPOINT', ['platforms', 'agiso', 'autoDelivery', 'endpoint']), + stringEnv('AGISO_AUTO_DELIVERY_API_VERSION', ['platforms', 'agiso', 'autoDelivery', 'apiVersion']), + stringEnv('AGISO_MESSAGE_API_VERSION', ['platforms', 'agiso', 'messaging', 'apiVersion']), + stringEnv('AGISO_SEND_MESSAGE_ENDPOINT', ['platforms', 'agiso', 'messaging', 'sendMessageEndpoint']), + booleanEnv('AGISO_MESSAGING_ENABLED', ['platforms', 'agiso', 'messaging', 'enabled']), + stringEnv('NINETYONE_USER_ID', ['platforms', 'ninetyone', 'userId']), + stringEnv('NINETYONE_SECRET', ['platforms', 'ninetyone', 'secret']), + stringEnv('NINETYONE_VERSION', ['platforms', 'ninetyone', 'version']), + stringEnv('NINETYONE_SHOP_ID', ['platforms', 'ninetyone', 'shopId']), + stringEnv('NINETYONE_SHOP_NAME', ['platforms', 'ninetyone', 'shopName']), + integerEnv('NINETYONE_TIMESTAMP_TOLERANCE_SECONDS', ['platforms', 'ninetyone', 'timestampToleranceSeconds']), + stringEnv('NINETYONE_CARDS_ENCODING', ['platforms', 'ninetyone', 'cardsEncoding']), + stringEnv('CLOUDTENTACLES_BASE_URL', ['platforms', 'cloudtentacles', 'baseUrl']), + integerEnv('CLOUDTENTACLES_TIMEOUT_MS', ['platforms', 'cloudtentacles', 'timeoutMs']), + stringEnv('CLOUDTENTACLES_SEND_SMS_PATH', ['platforms', 'cloudtentacles', 'sendSmsPath']), + stringEnv('CLOUDTENTACLES_LOGIN_PATH', ['platforms', 'cloudtentacles', 'loginPath']), + stringEnv('CLOUDTENTACLES_USER_INFO_PATH', ['platforms', 'cloudtentacles', 'userInfoPath']), + stringEnv('CLOUDTENTACLES_ASSET_PATH', ['platforms', 'cloudtentacles', 'assetPath']), + stringEnv('CLOUDTENTACLES_PERMISSION_PATH', ['platforms', 'cloudtentacles', 'permissionPath']), + stringEnv('CLOUDTENTACLES_CATEGORIES_PATH', ['platforms', 'cloudtentacles', 'categoriesPath']), + stringEnv('CLOUDTENTACLES_SKU_LIST_PATH', ['platforms', 'cloudtentacles', 'skuListPath']), + stringEnv('CLOUDTENTACLES_SKU_BUY_PATH', ['platforms', 'cloudtentacles', 'skuBuyPath']), + stringEnv('CLOUDTENTACLES_KNAPSACK_PATH', ['platforms', 'cloudtentacles', 'knapsackPath']), + stringEnv('CLOUDTENTACLES_VN_LIST_PATH', ['platforms', 'cloudtentacles', 'vnListPath']), + stringEnv('CLOUDTENTACLES_VN_APPOINT_PATH', ['platforms', 'cloudtentacles', 'vnAppointPath']), + stringEnv('CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH', ['platforms', 'cloudtentacles', 'vnGenerateLoginCodePath']), + stringEnv('CLOUDTENTACLES_VN_VERIF_CODE_PATH', ['platforms', 'cloudtentacles', 'vnVerifCodePath']), + stringEnv('CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH', ['platforms', 'cloudtentacles', 'vnVerifyLoginCodePath']), + stringEnv('CLOUDTENTACLES_VN_BIND_URL_PATH', ['platforms', 'cloudtentacles', 'vnBindUrlPath']), + stringEnv('CLOUDTENTACLES_VN_BIND_INFO_PATH', ['platforms', 'cloudtentacles', 'vnBindInfoPath']), + stringEnv('CLOUDTENTACLES_VN_BACK_PATH', ['platforms', 'cloudtentacles', 'vnBackPath']), + integerEnv('CLOUDTENTACLES_BIND_URL_TTL_SECONDS', ['platforms', 'cloudtentacles', 'bindUrlTtlSeconds']), + integerEnv('CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS', ['platforms', 'cloudtentacles', 'bindUrlProbeIntervalSeconds']), + integerEnv('CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS', ['platforms', 'cloudtentacles', 'bindUrlProbeTimeoutMs']), + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT', ['platforms', 'cloudtentacles', 'bindUrlProbeUserAgent']), + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT', ['platforms', 'cloudtentacles', 'bindUrlProbeEndpoint']), + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID', ['platforms', 'cloudtentacles', 'bindUrlProbeChartId']), + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID', ['platforms', 'cloudtentacles', 'bindUrlProbeSubChartId']), + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN', ['platforms', 'cloudtentacles', 'bindUrlProbeIdeToken']), + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL', ['platforms', 'cloudtentacles', 'bindUrlProbeActivityUrl']), + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_REFERER', ['platforms', 'cloudtentacles', 'bindUrlProbeReferer']), + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE', ['platforms', 'cloudtentacles', 'bindUrlProbeExtraCookie']), + stringEnv('CLOUDTENTACLES_PUBLIC_KEY_PEM', ['platforms', 'cloudtentacles', 'publicKeyPem']), + stringEnv('CLOUDTENTACLES_CLIENT_SOURCE', ['platforms', 'cloudtentacles', 'clientSource']), + stringEnv('CLOUDTENTACLES_DEVICE_ID', ['platforms', 'cloudtentacles', 'deviceId']), + integerEnv('CLOUDTENTACLES_DEVICE_TYPE', ['platforms', 'cloudtentacles', 'deviceType']), + stringEnv('TENCENT_REDEEM_PROOF_MODE', ['redeem', 'proofMode']), +] + +export function applyEnvOverrides(baseConfig: RuntimeConfig, env: RuntimeEnv = process.env): RuntimeConfig { + const nextConfig = deepMerge(baseConfig, {}) + + applyClaimBaseUrlOverride(nextConfig, env) + + for (const override of ENV_OVERRIDES) { + const value = override.read(env[override.env]) + if (value !== null) { + setConfigValue(nextConfig, override.path, value) + } + } + + return nextConfig +} + +function applyClaimBaseUrlOverride(config: RuntimeConfig, env: RuntimeEnv): void { + const claimBaseUrl = parseString(env.CLAIM_BASE_URL) + if (claimBaseUrl !== null) { + config.orders.claimBaseUrl = claimBaseUrl + return + } + + const appBaseUrl = parseString(env.APP_BASE_URL) + if (appBaseUrl !== null) { + config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, '')}/#/claim` + } +} + +function stringEnv( + env: string, + configPath: RuntimeConfigPath, + transform: (value: string) => string = (value) => value, +): EnvOverride { + return { + env, + path: configPath, + read(rawValue) { + const value = parseString(rawValue) + return value === null ? null : transform(value) + }, + } +} + +function integerEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { + return { + env, + path: configPath, + read: parseInteger, + } +} + +function booleanEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { + return { + env, + path: configPath, + read: parseBoolean, + } +} + +function adminUsersJsonEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { + return { + env, + path: configPath, + read(rawValue) { + return parseJsonArray(rawValue) + }, + } +} + +function parseString(rawValue: unknown): string | null { + const value = String(rawValue || '').trim() + return value ? value : null +} + +function setConfigValue(config: RuntimeConfig, configPath: RuntimeConfigPath, value: RuntimeConfigValue): void { + let current: Record = config as unknown as Record + + for (const key of configPath.slice(0, -1)) { + const nextValue = current[key] + if (!nextValue || typeof nextValue !== 'object' || Array.isArray(nextValue)) { + current[key] = {} + } + current = current[key] as Record + } + + current[configPath[configPath.length - 1]] = value +} diff --git a/apps/backend/src/config/runtime-loader.ts b/apps/backend/src/config/runtime-loader.ts deleted file mode 100644 index 85b21632..00000000 --- a/apps/backend/src/config/runtime-loader.ts +++ /dev/null @@ -1,15 +0,0 @@ -import fs from 'node:fs' -import { createRequire } from 'node:module' - -import { isPlainObject } from './runtime-merge.js' - -const require = createRequire(import.meta.url) - -export function loadConfig(configPath: string): Record { - if (!fs.existsSync(configPath)) { - return {} - } - - const loaded = require(configPath) - return isPlainObject(loaded) ? loaded : {} -} diff --git a/apps/backend/src/config/runtime-validation.test.ts b/apps/backend/src/config/runtime-validation.test.ts new file mode 100644 index 00000000..6def1efc --- /dev/null +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -0,0 +1,229 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + RuntimeConfigValidationError, + assertRuntimeConfigValid, + validateRuntimeConfig, +} from './runtime-validation.js' +import { deepMerge } from './runtime-merge.js' +import type { RuntimeConfig } from '../types/runtime-config.js' + +test('validateRuntimeConfig allows missing admin credentials outside production', () => { + const issues = validateRuntimeConfig(createRuntimeConfig({ + admin: { + sessionSecret: '', + defaultUsers: [], + }, + }), { + env: { NODE_ENV: 'development' }, + }) + + assert.deepEqual(issues, []) +}) + +test('validateRuntimeConfig requires admin credentials in production', () => { + const issues = validateRuntimeConfig(createRuntimeConfig({ + admin: { + sessionSecret: '', + defaultUsers: [], + }, + }), { + env: { NODE_ENV: 'production' }, + }) + + assert.deepEqual(issues.map((issue) => issue.path), [ + 'admin.sessionSecret', + 'admin.defaultUsers', + ]) +}) + +test('validateRuntimeConfig rejects unsafe production placeholders', () => { + const issues = validateRuntimeConfig(createRuntimeConfig({ + admin: { + sessionSecret: 'change-me-long-random-session-secret', + defaultUsers: [ + { + username: 'admin', + password: 'change-me-admin-password', + role: 'admin', + }, + ], + }, + }), { + env: { NODE_ENV: 'production' }, + }) + + assert.deepEqual(issues.map((issue) => issue.path), [ + 'admin.sessionSecret', + 'admin.defaultUsers[0].password', + ]) +}) + +test('validateRuntimeConfig accepts complete production config', () => { + const issues = validateRuntimeConfig(createRuntimeConfig(), { + env: { NODE_ENV: 'production' }, + }) + + assert.deepEqual(issues, []) +}) + +test('validateRuntimeConfig catches invalid numeric and url values', () => { + const issues = validateRuntimeConfig(createRuntimeConfig({ + server: { + port: 70000, + }, + database: { + maxConnections: 0, + }, + orders: { + claimBaseUrl: 'not-a-url', + tokenTtlHours: 0, + }, + }), { + env: { NODE_ENV: 'development' }, + }) + + assert.deepEqual(issues.map((issue) => issue.path), [ + 'server.port', + 'database.maxConnections', + 'orders.tokenTtlHours', + 'orders.claimBaseUrl', + ]) +}) + +test('assertRuntimeConfigValid throws a readable validation error', () => { + assert.throws(() => { + assertRuntimeConfigValid(createRuntimeConfig({ + server: { + port: 0, + }, + })) + }, (error) => { + assert.equal(error instanceof RuntimeConfigValidationError, true) + assert.match((error as Error).message, /运行时配置校验失败/) + assert.match((error as Error).message, /server\.port/) + return true + }) +}) + +function createRuntimeConfig(overrides: Record = {}): RuntimeConfig { + return deepMerge({ + server: { + port: 3000, + }, + browser: { + chromePath: '', + headless: null, + devtools: null, + keepAlive: null, + prewarm: null, + slowMoMs: null, + vncResolution: '1600x900x24', + }, + session: { + debug: false, + }, + ocr: { + baseUrl: '', + }, + data: { + root: '/tmp/order-site', + }, + logging: { + level: 'info', + }, + database: { + url: 'postgres://postgres:postgres@postgres:5432/order_site', + ssl: false, + maxConnections: 10, + }, + orders: { + claimBaseUrl: 'https://order.example.test/#/claim', + tokenTtlHours: 24, + }, + admin: { + sessionSecret: '0123456789abcdef0123456789abcdef', + sessionTtlHours: 12, + defaultUsers: [ + { + username: 'admin', + password: 'Qp7xT2mN9vR4', + role: 'admin', + }, + ], + }, + platforms: { + agiso: { + appSecret: '', + tradeDetail: { + endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail', + apiVersion: '1', + timeoutMs: 5000, + }, + autoDelivery: { + enabled: true, + endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', + apiVersion: '1', + }, + messaging: { + enabled: false, + sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', + apiVersion: '1', + messageTemplate: '', + autoDeliveryMessageTemplate: '', + shops: {}, + }, + }, + ninetyone: { + userId: '', + secret: '', + version: '1.0', + shopId: '91kaquan', + shopName: '91卡券', + timestampToleranceSeconds: 600, + cardsEncoding: 'aes-256-ecb-base64', + }, + cloudtentacles: { + baseUrl: 'https://123.207.217.176', + timeoutMs: 5000, + sendSmsPath: '/public/verif_code', + loginPath: '/public/login', + userInfoPath: '/user/info', + assetPath: '/user/get_asset', + permissionPath: '/user/get_permission', + categoriesPath: '/categories/get', + skuListPath: '/sku/list', + skuBuyPath: '/sku/buy', + skuUsePath: '/sku/use', + knapsackPath: '/user/get_knapsack', + vnListPath: '/vn/list', + vnAppointPath: '/vn/appoint', + vnGenerateLoginCodePath: '/vn/generate_login_code', + vnVerifCodePath: '/public/vn_verif_code', + vnVerifyLoginCodePath: '/vn/verify_login_code', + vnBindUrlPath: '/vn/bind_url', + vnBindInfoPath: '/vn/bind_info', + vnBackPath: '/vn/back', + bindUrlTtlSeconds: 600, + bindUrlProbeIntervalSeconds: 30, + bindUrlProbeTimeoutMs: 5000, + bindUrlProbeUserAgent: '', + bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', + bindUrlProbeChartId: '323794', + bindUrlProbeSubChartId: '323794', + bindUrlProbeIdeToken: 'z90Syo', + bindUrlProbeActivityUrl: '', + bindUrlProbeReferer: 'https://gp.qq.com/', + bindUrlProbeExtraCookie: '', + publicKeyPem: '', + clientSource: 'ct-client', + deviceId: '-', + deviceType: 0, + }, + }, + redeem: { + proofMode: 'full', + }, + }, overrides) as RuntimeConfig +} diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts new file mode 100644 index 00000000..0e3fcebb --- /dev/null +++ b/apps/backend/src/config/runtime-validation.ts @@ -0,0 +1,214 @@ +import process from 'node:process' + +import type { AdminDefaultUser, RuntimeConfig } from '../types/runtime-config.js' + +type RuntimeEnvironment = { + NODE_ENV?: string + CI?: string +} + +type ValidationOptions = { + env?: RuntimeEnvironment +} + +export type RuntimeConfigValidationIssue = { + path: string + message: string +} + +export class RuntimeConfigValidationError extends Error { + issues: RuntimeConfigValidationIssue[] + + constructor(issues: RuntimeConfigValidationIssue[]) { + super(formatRuntimeConfigValidationMessage(issues)) + this.name = 'RuntimeConfigValidationError' + this.issues = issues + } +} + +export function assertRuntimeConfigValid( + config: RuntimeConfig, + options: ValidationOptions = {}, +): void { + const issues = validateRuntimeConfig(config, options) + if (issues.length > 0) { + throw new RuntimeConfigValidationError(issues) + } +} + +export function validateRuntimeConfig( + config: RuntimeConfig, + { env = process.env }: ValidationOptions = {}, +): RuntimeConfigValidationIssue[] { + const issues: RuntimeConfigValidationIssue[] = [] + const productionLike = isProductionLike(env) + + requireInteger(issues, 'server.port', config.server?.port, { min: 1, max: 65535 }) + requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 }) + requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 }) + requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) + requireInteger(issues, 'platforms.agiso.tradeDetail.timeoutMs', config.platforms?.agiso?.tradeDetail?.timeoutMs, { min: 1 }) + requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 }) + requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 }) + requireInteger( + issues, + 'platforms.cloudtentacles.bindUrlProbeIntervalSeconds', + config.platforms?.cloudtentacles?.bindUrlProbeIntervalSeconds, + { min: 1 }, + ) + requireInteger( + issues, + 'platforms.cloudtentacles.bindUrlProbeTimeoutMs', + config.platforms?.cloudtentacles?.bindUrlProbeTimeoutMs, + { min: 1 }, + ) + + validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) + validateRequiredString(issues, 'data.root', config.data?.root) + + if (productionLike) { + validateRequiredString(issues, 'database.url', config.database?.url) + validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) + validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 }) + validateAdminDefaultUsers(issues, config.admin?.defaultUsers) + } + + return issues +} + +export function isProductionLike(env: RuntimeEnvironment = process.env): boolean { + return env.NODE_ENV === 'production' +} + +function validateAdminDefaultUsers( + issues: RuntimeConfigValidationIssue[], + users: AdminDefaultUser[] | undefined, +): void { + if (!Array.isArray(users) || users.length === 0) { + issues.push({ + path: 'admin.defaultUsers', + message: '生产环境必须配置至少一个后台默认用户', + }) + return + } + + users.forEach((user, index) => { + validateRequiredString(issues, `admin.defaultUsers[${index}].username`, user?.username) + validateSecret(issues, `admin.defaultUsers[${index}].password`, user?.password, { minLength: 8 }) + validateRequiredString(issues, `admin.defaultUsers[${index}].role`, user?.role) + }) +} + +function validateRequiredString( + issues: RuntimeConfigValidationIssue[], + configPath: string, + value: unknown, +): void { + if (!String(value || '').trim()) { + issues.push({ + path: configPath, + message: '必须配置非空字符串', + }) + } +} + +function validateSecret( + issues: RuntimeConfigValidationIssue[], + configPath: string, + value: unknown, + { minLength }: { minLength: number }, +): void { + const text = String(value || '').trim() + if (!text) { + issues.push({ + path: configPath, + message: '必须配置密钥', + }) + return + } + + if (text.length < minLength) { + issues.push({ + path: configPath, + message: `长度不能少于 ${minLength} 个字符`, + }) + } + + if (/change-me|example|password|secret/i.test(text)) { + issues.push({ + path: configPath, + message: '不能使用示例占位值', + }) + } +} + +function validateOptionalHttpUrl( + issues: RuntimeConfigValidationIssue[], + configPath: string, + value: unknown, +): void { + const text = String(value || '').trim() + if (!text) { + return + } + + if (!isHttpUrl(text)) { + issues.push({ + path: configPath, + message: '必须是 http 或 https URL', + }) + } +} + +function validateRequiredHttpUrl( + issues: RuntimeConfigValidationIssue[], + configPath: string, + value: unknown, +): void { + validateRequiredString(issues, configPath, value) + validateOptionalHttpUrl(issues, configPath, value) +} + +function requireInteger( + issues: RuntimeConfigValidationIssue[], + configPath: string, + value: unknown, + { min, max }: { min?: number, max?: number } = {}, +): void { + const numberValue = Number(value) + if (!Number.isInteger(numberValue)) { + issues.push({ + path: configPath, + message: '必须是整数', + }) + return + } + + if (typeof min === 'number' && numberValue < min) { + issues.push({ + path: configPath, + message: `不能小于 ${min}`, + }) + } + + if (typeof max === 'number' && numberValue > max) { + issues.push({ + path: configPath, + message: `不能大于 ${max}`, + }) + } +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +function formatRuntimeConfigValidationMessage(issues: RuntimeConfigValidationIssue[]): string { + const lines = issues.map((issue) => `- ${issue.path}: ${issue.message}`) + return `运行时配置校验失败:\n${lines.join('\n')}` +} diff --git a/apps/backend/src/config/runtime.ts b/apps/backend/src/config/runtime.ts index 8d99c4b7..c4366cfe 100644 --- a/apps/backend/src/config/runtime.ts +++ b/apps/backend/src/config/runtime.ts @@ -1,519 +1,17 @@ import path from "node:path"; -import process from "node:process"; import { fileURLToPath } from "node:url"; -import { loadEnvFiles, parseBoolean, parseInteger, parseJsonArray } from "./runtime-env.js"; -import { loadConfig } from "./runtime-loader.js"; -import { deepMerge } from "./runtime-merge.js"; -import type { AdminDefaultUser, RuntimeConfig } from "../types/runtime-config.js"; +import { createDefaultRuntimeConfig } from "./defaults.js"; +import { applyEnvOverrides } from "./env-overrides.js"; +import { loadEnvFiles } from "./runtime-env.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); export const PROJECT_ROOT = path.resolve(CURRENT_DIR, "../.."); const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, "../.."); -const CONFIG_ROOT = path.join(PROJECT_ROOT, "config"); loadEnvFiles([ path.join(WORKSPACE_ROOT, ".env"), path.join(PROJECT_ROOT, ".env"), ]); -const defaultConfig = loadConfig(path.join(CONFIG_ROOT, "default.cjs")) as RuntimeConfig; - -export const runtimeConfig = applyEnvOverrides(defaultConfig); - -function applyEnvOverrides(baseConfig: RuntimeConfig): RuntimeConfig { - const nextConfig = deepMerge(baseConfig, {}); - - const port = parseInteger(process.env.PORT); - if (port !== null) { - nextConfig.server.port = port; - } - - const chromePath = String(process.env.CHROME_PATH || "").trim(); - if (chromePath) { - nextConfig.browser.chromePath = chromePath; - } - - const browserHeadless = parseBoolean(process.env.TENCENT_BROWSER_HEADLESS); - if (browserHeadless !== null) { - nextConfig.browser.headless = browserHeadless; - } - - const browserDevtools = parseBoolean(process.env.TENCENT_BROWSER_DEVTOOLS); - if (browserDevtools !== null) { - nextConfig.browser.devtools = browserDevtools; - } - - const browserKeepAlive = parseBoolean(process.env.TENCENT_BROWSER_KEEP_ALIVE); - if (browserKeepAlive !== null) { - nextConfig.browser.keepAlive = browserKeepAlive; - } - - const browserPrewarm = parseBoolean(process.env.TENCENT_BROWSER_PREWARM); - if (browserPrewarm !== null) { - nextConfig.browser.prewarm = browserPrewarm; - } - - const browserSlowMoMs = parseInteger(process.env.TENCENT_BROWSER_SLOW_MO); - if (browserSlowMoMs !== null) { - nextConfig.browser.slowMoMs = browserSlowMoMs; - } - - const sessionDebug = parseBoolean(process.env.TENCENT_SESSION_DEBUG); - if (sessionDebug !== null) { - nextConfig.session.debug = sessionDebug; - } - const ocrBaseUrl = String(process.env.OCR_BASE_URL || "").trim(); - if (ocrBaseUrl) { - nextConfig.ocr.baseUrl = ocrBaseUrl; - } - - const dataRoot = String(process.env.DATA_ROOT || "").trim(); - if (dataRoot) { - nextConfig.data.root = path.resolve(dataRoot); - } - - const logLevel = String(process.env.LOG_LEVEL || "").trim(); - if (logLevel) { - nextConfig.logging.level = logLevel; - } - - const databaseUrl = String(process.env.DATABASE_URL || "").trim(); - if (databaseUrl) { - nextConfig.database.url = databaseUrl; - } - - const databaseSsl = parseBoolean(process.env.DATABASE_SSL); - if (databaseSsl !== null) { - nextConfig.database.ssl = databaseSsl; - } - - const databaseMaxConnections = parseInteger( - process.env.DATABASE_MAX_CONNECTIONS - ); - if (databaseMaxConnections !== null) { - nextConfig.database.maxConnections = databaseMaxConnections; - } - - const claimBaseUrl = String(process.env.CLAIM_BASE_URL || "").trim(); - if (claimBaseUrl) { - nextConfig.orders.claimBaseUrl = claimBaseUrl; - } else { - const appBaseUrl = String(process.env.APP_BASE_URL || "").trim(); - if (appBaseUrl) { - nextConfig.orders.claimBaseUrl = `${appBaseUrl.replace( - /\/+$/, - "" - )}/#/claim`; - } - } - - const tokenTtlHours = parseInteger(process.env.CLAIM_TOKEN_TTL_HOURS); - if (tokenTtlHours !== null) { - nextConfig.orders.tokenTtlHours = tokenTtlHours; - } - - const adminSessionSecret = String( - process.env.ADMIN_SESSION_SECRET || "" - ).trim(); - if (adminSessionSecret) { - nextConfig.admin.sessionSecret = adminSessionSecret; - } - - const adminSessionTtlHours = parseInteger( - process.env.ADMIN_SESSION_TTL_HOURS - ); - if (adminSessionTtlHours !== null) { - nextConfig.admin.sessionTtlHours = adminSessionTtlHours; - } - - const adminDefaultUsers = parseJsonArray( - process.env.ADMIN_DEFAULT_USERS_JSON - ); - if (adminDefaultUsers) { - nextConfig.admin.defaultUsers = adminDefaultUsers; - } - - const agisoAppSecret = String(process.env.AGISO_APP_SECRET || "").trim(); - if (agisoAppSecret) { - nextConfig.platforms.agiso.appSecret = agisoAppSecret; - } - - const agisoTradeDetailEndpoint = String( - process.env.AGISO_TRADE_DETAIL_ENDPOINT || "" - ).trim(); - if (agisoTradeDetailEndpoint) { - nextConfig.platforms.agiso.tradeDetail.endpoint = agisoTradeDetailEndpoint; - } - - const agisoTradeDetailApiVersion = String( - process.env.AGISO_TRADE_DETAIL_API_VERSION || "" - ).trim(); - if (agisoTradeDetailApiVersion) { - nextConfig.platforms.agiso.tradeDetail.apiVersion = - agisoTradeDetailApiVersion; - } - - const agisoTradeDetailTimeoutMs = parseInteger( - process.env.AGISO_TRADE_DETAIL_TIMEOUT_MS - ); - if (agisoTradeDetailTimeoutMs !== null) { - nextConfig.platforms.agiso.tradeDetail.timeoutMs = - agisoTradeDetailTimeoutMs; - } - - const agisoAutoDeliveryEnabled = parseBoolean( - process.env.AGISO_AUTO_DELIVERY_ENABLED - ); - if (agisoAutoDeliveryEnabled !== null) { - nextConfig.platforms.agiso.autoDelivery.enabled = agisoAutoDeliveryEnabled; - } - - const agisoAutoDeliveryEndpoint = String( - process.env.AGISO_AUTO_DELIVERY_ENDPOINT || "" - ).trim(); - if (agisoAutoDeliveryEndpoint) { - nextConfig.platforms.agiso.autoDelivery.endpoint = - agisoAutoDeliveryEndpoint; - } - - const agisoAutoDeliveryApiVersion = String( - process.env.AGISO_AUTO_DELIVERY_API_VERSION || "" - ).trim(); - if (agisoAutoDeliveryApiVersion) { - nextConfig.platforms.agiso.autoDelivery.apiVersion = - agisoAutoDeliveryApiVersion; - } - - const agisoMessageApiVersion = String( - process.env.AGISO_MESSAGE_API_VERSION || "" - ).trim(); - if (agisoMessageApiVersion) { - nextConfig.platforms.agiso.messaging.apiVersion = agisoMessageApiVersion; - } - - const agisoSendMessageEndpoint = String( - process.env.AGISO_SEND_MESSAGE_ENDPOINT || "" - ).trim(); - if (agisoSendMessageEndpoint) { - nextConfig.platforms.agiso.messaging.sendMessageEndpoint = - agisoSendMessageEndpoint; - } - - const agisoMessagingEnabled = parseBoolean( - process.env.AGISO_MESSAGING_ENABLED - ); - if (agisoMessagingEnabled !== null) { - nextConfig.platforms.agiso.messaging.enabled = agisoMessagingEnabled; - } - - const ninetyoneUserId = String(process.env.NINETYONE_USER_ID || "").trim(); - if (ninetyoneUserId) { - nextConfig.platforms.ninetyone.userId = ninetyoneUserId; - } - - const ninetyoneSecret = String(process.env.NINETYONE_SECRET || "").trim(); - if (ninetyoneSecret) { - nextConfig.platforms.ninetyone.secret = ninetyoneSecret; - } - - const ninetyoneVersion = String(process.env.NINETYONE_VERSION || "").trim(); - if (ninetyoneVersion) { - nextConfig.platforms.ninetyone.version = ninetyoneVersion; - } - - const ninetyoneShopId = String(process.env.NINETYONE_SHOP_ID || "").trim(); - if (ninetyoneShopId) { - nextConfig.platforms.ninetyone.shopId = ninetyoneShopId; - } - - const ninetyoneShopName = String( - process.env.NINETYONE_SHOP_NAME || "" - ).trim(); - if (ninetyoneShopName) { - nextConfig.platforms.ninetyone.shopName = ninetyoneShopName; - } - - const ninetyoneTimestampToleranceSeconds = parseInteger( - process.env.NINETYONE_TIMESTAMP_TOLERANCE_SECONDS - ); - if (ninetyoneTimestampToleranceSeconds !== null) { - nextConfig.platforms.ninetyone.timestampToleranceSeconds = - ninetyoneTimestampToleranceSeconds; - } - - const ninetyoneCardsEncoding = String( - process.env.NINETYONE_CARDS_ENCODING || "" - ).trim(); - if (ninetyoneCardsEncoding) { - nextConfig.platforms.ninetyone.cardsEncoding = ninetyoneCardsEncoding; - } - - const cloudtentaclesBaseUrl = String( - process.env.CLOUDTENTACLES_BASE_URL || "" - ).trim(); - if (cloudtentaclesBaseUrl) { - nextConfig.platforms.cloudtentacles.baseUrl = cloudtentaclesBaseUrl; - } - - const cloudtentaclesTimeoutMs = parseInteger( - process.env.CLOUDTENTACLES_TIMEOUT_MS - ); - if (cloudtentaclesTimeoutMs !== null) { - nextConfig.platforms.cloudtentacles.timeoutMs = cloudtentaclesTimeoutMs; - } - - const cloudtentaclesSendSmsPath = String( - process.env.CLOUDTENTACLES_SEND_SMS_PATH || "" - ).trim(); - if (cloudtentaclesSendSmsPath) { - nextConfig.platforms.cloudtentacles.sendSmsPath = cloudtentaclesSendSmsPath; - } - - const cloudtentaclesLoginPath = String( - process.env.CLOUDTENTACLES_LOGIN_PATH || "" - ).trim(); - if (cloudtentaclesLoginPath) { - nextConfig.platforms.cloudtentacles.loginPath = cloudtentaclesLoginPath; - } - - const cloudtentaclesUserInfoPath = String( - process.env.CLOUDTENTACLES_USER_INFO_PATH || "" - ).trim(); - if (cloudtentaclesUserInfoPath) { - nextConfig.platforms.cloudtentacles.userInfoPath = - cloudtentaclesUserInfoPath; - } - - const cloudtentaclesAssetPath = String( - process.env.CLOUDTENTACLES_ASSET_PATH || "" - ).trim(); - if (cloudtentaclesAssetPath) { - nextConfig.platforms.cloudtentacles.assetPath = cloudtentaclesAssetPath; - } - - const cloudtentaclesPermissionPath = String( - process.env.CLOUDTENTACLES_PERMISSION_PATH || "" - ).trim(); - if (cloudtentaclesPermissionPath) { - nextConfig.platforms.cloudtentacles.permissionPath = - cloudtentaclesPermissionPath; - } - - const cloudtentaclesCategoriesPath = String( - process.env.CLOUDTENTACLES_CATEGORIES_PATH || "" - ).trim(); - if (cloudtentaclesCategoriesPath) { - nextConfig.platforms.cloudtentacles.categoriesPath = - cloudtentaclesCategoriesPath; - } - - const cloudtentaclesSkuListPath = String( - process.env.CLOUDTENTACLES_SKU_LIST_PATH || "" - ).trim(); - if (cloudtentaclesSkuListPath) { - nextConfig.platforms.cloudtentacles.skuListPath = cloudtentaclesSkuListPath; - } - - const cloudtentaclesSkuBuyPath = String( - process.env.CLOUDTENTACLES_SKU_BUY_PATH || "" - ).trim(); - if (cloudtentaclesSkuBuyPath) { - nextConfig.platforms.cloudtentacles.skuBuyPath = cloudtentaclesSkuBuyPath; - } - - const cloudtentaclesKnapsackPath = String( - process.env.CLOUDTENTACLES_KNAPSACK_PATH || "" - ).trim(); - if (cloudtentaclesKnapsackPath) { - nextConfig.platforms.cloudtentacles.knapsackPath = - cloudtentaclesKnapsackPath; - } - - const cloudtentaclesVnListPath = String( - process.env.CLOUDTENTACLES_VN_LIST_PATH || "" - ).trim(); - if (cloudtentaclesVnListPath) { - nextConfig.platforms.cloudtentacles.vnListPath = cloudtentaclesVnListPath; - } - - const cloudtentaclesVnAppointPath = String( - process.env.CLOUDTENTACLES_VN_APPOINT_PATH || "" - ).trim(); - if (cloudtentaclesVnAppointPath) { - nextConfig.platforms.cloudtentacles.vnAppointPath = - cloudtentaclesVnAppointPath; - } - - const cloudtentaclesVnGenerateLoginCodePath = String( - process.env.CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH || "" - ).trim(); - if (cloudtentaclesVnGenerateLoginCodePath) { - nextConfig.platforms.cloudtentacles.vnGenerateLoginCodePath = - cloudtentaclesVnGenerateLoginCodePath; - } - - const cloudtentaclesVnVerifCodePath = String( - process.env.CLOUDTENTACLES_VN_VERIF_CODE_PATH || "" - ).trim(); - if (cloudtentaclesVnVerifCodePath) { - nextConfig.platforms.cloudtentacles.vnVerifCodePath = - cloudtentaclesVnVerifCodePath; - } - - const cloudtentaclesVnVerifyLoginCodePath = String( - process.env.CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH || "" - ).trim(); - if (cloudtentaclesVnVerifyLoginCodePath) { - nextConfig.platforms.cloudtentacles.vnVerifyLoginCodePath = - cloudtentaclesVnVerifyLoginCodePath; - } - - const cloudtentaclesVnBindUrlPath = String( - process.env.CLOUDTENTACLES_VN_BIND_URL_PATH || "" - ).trim(); - if (cloudtentaclesVnBindUrlPath) { - nextConfig.platforms.cloudtentacles.vnBindUrlPath = - cloudtentaclesVnBindUrlPath; - } - - const cloudtentaclesVnBindInfoPath = String( - process.env.CLOUDTENTACLES_VN_BIND_INFO_PATH || "" - ).trim(); - if (cloudtentaclesVnBindInfoPath) { - nextConfig.platforms.cloudtentacles.vnBindInfoPath = - cloudtentaclesVnBindInfoPath; - } - - const cloudtentaclesVnBackPath = String( - process.env.CLOUDTENTACLES_VN_BACK_PATH || "" - ).trim(); - if (cloudtentaclesVnBackPath) { - nextConfig.platforms.cloudtentacles.vnBackPath = cloudtentaclesVnBackPath; - } - - const cloudtentaclesBindUrlTtlSeconds = parseInteger( - process.env.CLOUDTENTACLES_BIND_URL_TTL_SECONDS - ); - if (cloudtentaclesBindUrlTtlSeconds !== null) { - nextConfig.platforms.cloudtentacles.bindUrlTtlSeconds = - cloudtentaclesBindUrlTtlSeconds; - } - - const cloudtentaclesBindUrlProbeIntervalSeconds = parseInteger( - process.env.CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS - ); - if (cloudtentaclesBindUrlProbeIntervalSeconds !== null) { - nextConfig.platforms.cloudtentacles.bindUrlProbeIntervalSeconds = - cloudtentaclesBindUrlProbeIntervalSeconds; - } - - const cloudtentaclesBindUrlProbeTimeoutMs = parseInteger( - process.env.CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS - ); - if (cloudtentaclesBindUrlProbeTimeoutMs !== null) { - nextConfig.platforms.cloudtentacles.bindUrlProbeTimeoutMs = - cloudtentaclesBindUrlProbeTimeoutMs; - } - - const cloudtentaclesBindUrlProbeUserAgent = String( - process.env.CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT || "" - ).trim(); - if (cloudtentaclesBindUrlProbeUserAgent) { - nextConfig.platforms.cloudtentacles.bindUrlProbeUserAgent = - cloudtentaclesBindUrlProbeUserAgent; - } - - const cloudtentaclesBindUrlProbeEndpoint = String( - process.env.CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT || "" - ).trim(); - if (cloudtentaclesBindUrlProbeEndpoint) { - nextConfig.platforms.cloudtentacles.bindUrlProbeEndpoint = - cloudtentaclesBindUrlProbeEndpoint; - } - - const cloudtentaclesBindUrlProbeChartId = String( - process.env.CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID || "" - ).trim(); - if (cloudtentaclesBindUrlProbeChartId) { - nextConfig.platforms.cloudtentacles.bindUrlProbeChartId = - cloudtentaclesBindUrlProbeChartId; - } - - const cloudtentaclesBindUrlProbeSubChartId = String( - process.env.CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID || "" - ).trim(); - if (cloudtentaclesBindUrlProbeSubChartId) { - nextConfig.platforms.cloudtentacles.bindUrlProbeSubChartId = - cloudtentaclesBindUrlProbeSubChartId; - } - - const cloudtentaclesBindUrlProbeIdeToken = String( - process.env.CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN || "" - ).trim(); - if (cloudtentaclesBindUrlProbeIdeToken) { - nextConfig.platforms.cloudtentacles.bindUrlProbeIdeToken = - cloudtentaclesBindUrlProbeIdeToken; - } - - const cloudtentaclesBindUrlProbeActivityUrl = String( - process.env.CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL || "" - ).trim(); - if (cloudtentaclesBindUrlProbeActivityUrl) { - nextConfig.platforms.cloudtentacles.bindUrlProbeActivityUrl = - cloudtentaclesBindUrlProbeActivityUrl; - } - - const cloudtentaclesBindUrlProbeReferer = String( - process.env.CLOUDTENTACLES_BIND_URL_PROBE_REFERER || "" - ).trim(); - if (cloudtentaclesBindUrlProbeReferer) { - nextConfig.platforms.cloudtentacles.bindUrlProbeReferer = - cloudtentaclesBindUrlProbeReferer; - } - - const cloudtentaclesBindUrlProbeExtraCookie = String( - process.env.CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE || "" - ).trim(); - if (cloudtentaclesBindUrlProbeExtraCookie) { - nextConfig.platforms.cloudtentacles.bindUrlProbeExtraCookie = - cloudtentaclesBindUrlProbeExtraCookie; - } - - const cloudtentaclesPublicKeyPem = String( - process.env.CLOUDTENTACLES_PUBLIC_KEY_PEM || "" - ).trim(); - if (cloudtentaclesPublicKeyPem) { - nextConfig.platforms.cloudtentacles.publicKeyPem = - cloudtentaclesPublicKeyPem; - } - - const cloudtentaclesClientSource = String( - process.env.CLOUDTENTACLES_CLIENT_SOURCE || "" - ).trim(); - if (cloudtentaclesClientSource) { - nextConfig.platforms.cloudtentacles.clientSource = - cloudtentaclesClientSource; - } - - const cloudtentaclesDeviceId = String( - process.env.CLOUDTENTACLES_DEVICE_ID || "" - ).trim(); - if (cloudtentaclesDeviceId) { - nextConfig.platforms.cloudtentacles.deviceId = cloudtentaclesDeviceId; - } - - const cloudtentaclesDeviceType = parseInteger( - process.env.CLOUDTENTACLES_DEVICE_TYPE - ); - if (cloudtentaclesDeviceType !== null) { - nextConfig.platforms.cloudtentacles.deviceType = cloudtentaclesDeviceType; - } - - const proofMode = String(process.env.TENCENT_REDEEM_PROOF_MODE || "").trim(); - if (proofMode) { - nextConfig.redeem.proofMode = proofMode; - } - - return nextConfig; -} +export const runtimeConfig = applyEnvOverrides(createDefaultRuntimeConfig(PROJECT_ROOT)); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 997d7d33..3a0e6933 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -2,6 +2,7 @@ import process from "node:process"; import { createApp } from "./app.js"; import { runtimeConfig } from "./config/runtime.js"; +import { assertRuntimeConfigValid } from "./config/runtime-validation.js"; import { bootstrapCoreServices } from "./startup/bootstrap.js"; import { createShutdownController } from "./startup/shutdown.js"; import { createStartupState, formatStartupError } from "./startup/state.js"; @@ -13,6 +14,13 @@ const startupState = createStartupState(); let shutdownController: ReturnType | null = null; +try { + assertRuntimeConfigValid(runtimeConfig); +} catch (error) { + logError("[startup]", "运行时配置校验失败,服务停止启动", error); + process.exit(1); +} + const app = createApp({ startupState, isShutdownStarted: () => shutdownController?.isShutdownStarted() || false, diff --git a/apps/backend/src/services/session/session-browser-config.ts b/apps/backend/src/services/session/session-browser-config.ts index d0ea749b..2504431c 100644 --- a/apps/backend/src/services/session/session-browser-config.ts +++ b/apps/backend/src/services/session/session-browser-config.ts @@ -88,7 +88,7 @@ export function buildChromiumLaunchOptions( return options } -export function resolveDefaultViewport(rawValue = process.env.TENCENT_BROWSER_VNC_RESOLUTION): ViewportSize { +export function resolveDefaultViewport(rawValue?: unknown): ViewportSize { const fromVncResolution = parseViewportSpec(rawValue) if (fromVncResolution) { return fromVncResolution diff --git a/apps/backend/src/services/session/session-service-config.ts b/apps/backend/src/services/session/session-service-config.ts index 80c0e88e..2adb7d4b 100644 --- a/apps/backend/src/services/session/session-service-config.ts +++ b/apps/backend/src/services/session/session-service-config.ts @@ -1,15 +1,11 @@ import path from 'node:path' -import { fileURLToPath } from 'node:url' import { runtimeConfig } from '../../config/runtime.js' import { resolveDefaultViewport } from './session-browser-config.js' -const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) -const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..') - -export const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions') +export const BROWSER_SESSION_DATA_ROOT = path.join(runtimeConfig.data.root, 'browser-sessions') export const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html' export const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim() export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000 -export const DEFAULT_VIEWPORT = resolveDefaultViewport() +export const DEFAULT_VIEWPORT = resolveDefaultViewport(runtimeConfig.browser.vncResolution) export const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) diff --git a/apps/backend/src/services/session/session-service-store.ts b/apps/backend/src/services/session/session-service-store.ts index 9306d401..49e28e0c 100644 --- a/apps/backend/src/services/session/session-service-store.ts +++ b/apps/backend/src/services/session/session-service-store.ts @@ -3,7 +3,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { - DATA_ROOT, + BROWSER_SESSION_DATA_ROOT, REDEEM_SUCCESS_AUTO_CLOSE_MS, } from './session-service-config.js' import { SessionServiceError } from './session-service-runtime.js' @@ -61,7 +61,7 @@ export async function registerTencentBrowserSession({ page, }: RegisterSessionInput): Promise { const sessionId = createSessionId() - const sessionDir = path.join(DATA_ROOT, sessionId) + const sessionDir = path.join(BROWSER_SESSION_DATA_ROOT, sessionId) await fs.mkdir(sessionDir, { recursive: true }) const now = new Date().toISOString() diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index 6bd27145..4ab7097c 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -29,6 +29,7 @@ export type RuntimeConfig = { keepAlive: boolean | null prewarm: boolean | null slowMoMs: number | null + vncResolution: string } session: { debug: boolean diff --git a/deploy/docker/backend.Dockerfile b/deploy/docker/backend.Dockerfile index 268aec3d..9804f9b6 100644 --- a/deploy/docker/backend.Dockerfile +++ b/deploy/docker/backend.Dockerfile @@ -90,7 +90,6 @@ COPY --from=builder /app/node_modules ./node_modules # Compiled application COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/dist ./dist -COPY --from=builder /app/config ./config # Playwright browsers COPY --from=builder /ms-playwright /ms-playwright diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a2e3f92e..6dcbb5ae 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -62,6 +62,7 @@ services: DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site} DATABASE_SSL: ${DATABASE_SSL:-false} DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10} + DATA_ROOT: ${DATA_ROOT:-/app/data} OCR_BASE_URL: ${OCR_BASE_URL:-http://ocr-worker:8100} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} TENCENT_REDEEM_PROOF_MODE: ${TENCENT_REDEEM_PROOF_MODE:-full} @@ -79,7 +80,6 @@ services: ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} AGISO_APP_SECRET: ${AGISO_APP_SECRET:-} AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false} - AGISO_MESSAGE_APP_SECRET: ${AGISO_MESSAGE_APP_SECRET:-} volumes: - ./apps/backend:/app - backend_node_modules:/app/node_modules diff --git a/docker-compose.yml b/docker-compose.yml index 22e4ad42..c23e27dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,7 @@ services: DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site} DATABASE_SSL: ${DATABASE_SSL:-false} DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-20} + DATA_ROOT: ${DATA_ROOT:-/app/data} OCR_BASE_URL: ${OCR_BASE_URL:-http://ocr-worker:8100} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} TENCENT_REDEEM_PROOF_MODE: ${TENCENT_REDEEM_PROOF_MODE:-full} @@ -67,7 +68,6 @@ services: ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} AGISO_APP_SECRET: ${AGISO_APP_SECRET:-} AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false} - AGISO_MESSAGE_APP_SECRET: ${AGISO_MESSAGE_APP_SECRET:-} volumes: - ./deploy/data/backend:/app/data diff --git a/docs/91卡券/6.开发设计.md b/docs/91卡券/6.开发设计.md index 111b7e3f..88cfc65f 100644 --- a/docs/91卡券/6.开发设计.md +++ b/docs/91卡券/6.开发设计.md @@ -288,7 +288,7 @@ Node 侧实现等价为: 需要修改: -- `apps/backend/config/default.cjs` +- `apps/backend/src/config/defaults.ts` - `apps/backend/src/config/runtime.js` - `apps/backend/src/types/runtime-config.js` - `apps/backend/src/index.js` diff --git a/docs/backend-typescript-migration-plan.md b/docs/backend-typescript-migration-plan.md index 7dd41edb..747c287e 100644 --- a/docs/backend-typescript-migration-plan.md +++ b/docs/backend-typescript-migration-plan.md @@ -199,7 +199,7 @@ 1. 新增 `tsconfig.build.json` 2. `npm run build` 输出 `dist`,并复制数据库 migration SQL -3. `npm run dev` 改为 `tsx watch --clear-screen=false --include config src/index.ts` +3. `npm run dev` 改为 `tsx watch --clear-screen=false src/index.ts` 4. `npm test` 改为 `node --import tsx --test ...` 5. `npm run db:migrate` 改为 `tsx src/db/migrate.ts` 6. `npm run start` 改为 `node dist/index.js` @@ -240,7 +240,6 @@ 20. `runtime.js` 已拆分并迁移到 `.ts`: - `src/config/runtime.ts` - `src/config/runtime-env.ts` - - `src/config/runtime-loader.ts` - `src/config/runtime-merge.ts` 21. env 文件加载、env 值解析、默认配置加载、深合并逻辑已从 runtime 主入口分离 22. Docker 内再次验证通过: diff --git a/docs/cloudtentacles发货平台接入设计.md b/docs/cloudtentacles发货平台接入设计.md index f9d4caf8..6abbdbee 100644 --- a/docs/cloudtentacles发货平台接入设计.md +++ b/docs/cloudtentacles发货平台接入设计.md @@ -409,7 +409,7 @@ MD5(password).toString(Hex) 建议在: -- `apps/backend/config/default.cjs` +- `apps/backend/src/config/defaults.ts` - `apps/backend/src/types/runtime-config.js` - `apps/backend/src/config/runtime.js`