DEPLOY
prerequisites
前置条件
当前部署方案为单个 Node 进程配合持久化数据目录。
- 在目标机器上安装 Node.js 20+ 和 pnpm 10+。
- 将代码部署到一个具有写权限的应用目录。
- 为 ./data/ 目录设置持久化存储。
build
构建项目
从 GitHub 上下载 Open CRH Tracker 代码并构建
如果你的网络环境不佳,请你在下载代码前先通过
git config --global http.proxy <proxy_address>
git config --global https.proxy <proxy_address>设置 Git 的网络代理,或者启用 TUN 模式。
在确保网络环境没有问题后,请执行以下代码以从 GitHub 上下载项目。
git clone https://github.com/lihugang/OpenCRHTracker.git --depth=1
cd OpenCRHTracker随后,你需要通过 pnpm 安装程序需要的依赖,执行:
pnpm install安装成功后,执行
pnpm build以生成构建。
默认情况下,构建产物会按当前运行时依赖关系携带需要的二进制依赖。如果你准备在一台机器上构建、再上传到另一台 Node 或 libc 环境可能不同的服务器,可以改用下面这个可选命令,让这些二进制依赖不再被复制进 .output/server/node_modules。
NUXT_EXTERNALIZE_NATIVE_DEPS=1 pnpm build启用这个环境变量后,目标服务器在启动前需要先在项目根目录安装依赖,例如执行 pnpm install --frozen-lockfile,以便在服务器本地安装适配当前环境的二进制依赖。
config
config.json 配置指南
服务器启动时会读取 data/config.json 下的配置文件,并校验配置合法性。
配置文件加载规则
- 开发环境优先读取 data/config.dev.json,找不到时回退到 data/config.json。
- 生产环境优先读取 data/config.prod.json,找不到时回退到 data/config.json。
- 启动时会校验配置文件合法性,字段缺失、时间格式错误、前缀范围重叠、分页上限非法等问题都会直接阻止服务启动。
部署前先确定当前环境实际会读取哪份配置文件,再进行修改。生产环境的配置修改不会主动热更新,修改后请重启 Node 进程或者管理员面板手动重载。
完整示例:assets/json/config.example.json
{
"$schema": "../assets/json/configScheme.json",
"spider": {
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/7.0.17(0x17001126) NetType/WIFI Language/zh_CN",
"params": {
"eKey": "OpenCRHTracker",
"jsonpCallback": "OpenCRHTracker",
"routeProbeCarCode": "CR400AF-C-2214"
},
"rateLimit": {
"query": {
"minIntervalMs": 1500
},
"search": {
"minIntervalMs": 8000
},
"stationBoard": {
"minIntervalMs": 8000
}
},
"scheduleProbe": {
"dailyTimeHHmm": "0000",
"retryAttempts": 3,
"maxBatchSize": 200,
"checkpointFlushEvery": 20,
"refresh": {
"batchSize": 20,
"ttlHours": 24,
"generateIntervalHours": 24
},
"probe": {
"defaultRetry": 5,
"overlapRetryDelaySeconds": 3600,
"latestExecutionTimeHHmm": "2350"
},
"coupling": {
"statusResetTimeHHmm": "0000",
"detectDelaySeconds": 900,
"detectCooldownSeconds": 3600
},
"prefixRules": [
{
"prefix": "G",
"minNo": 1,
"maxNo": 9999
},
{
"prefix": "D",
"minNo": 1,
"maxNo": 9999
},
{
"prefix": "C",
"minNo": 1,
"maxNo": 9999
},
{
"prefix": "S",
"minNo": 5500,
"maxNo": 5600
}
]
}
},
"data": {
"assets": {
"EMUList": {
"file": "data/emu_list.json",
"provider": "https://allocation.crhdata.top/api/v1/allocation/export.json",
"refresh": {
"enabled": true,
"refreshAt": "0000"
}
},
"QRCode": {
"file": "data/qrcode.jsonl",
"provider": "https://storage.lihugang.top/open_crh_tracker/initialized_data/qrcode.jsonl",
"refresh": {
"enabled": true,
"refreshAt": "0000"
}
},
"stationCoord": {
"file": "data/station_coord.jsonl",
"provider": "https://storage.lihugang.top/open_crh_tracker/initialized_data/station_coord.jsonl",
"refresh": {
"enabled": true,
"refreshAt": "0000"
}
},
"trainStyleMapping": {
"file": "data/train_style_mapping.json",
"provider": "https://storage.lihugang.top/open_crh_tracker/initialized_data/train_style_mapping.json",
"refresh": {
"enabled": true,
"refreshAt": "0000"
}
},
"schedule": {
"file": "data/schedule.json",
"provider": "https://storage.lihugang.top/open_crh_tracker/initialized_data/schedule.json"
},
"qrcodeDetection": {
"file": "data/qrcode_detection.json",
"provider": "https://storage.lihugang.top/open_crh_tracker/initialized_data/qrcode_detection.json",
"refresh": {
"enabled": true,
"refreshAt": "0000"
}
}
},
"databases": {
"task": "data/task.db",
"EMUTracked": "data/emu.db",
"users": "data/users.db",
"feedback": "data/feedback.db",
"trainProvenance": "data/train-provenance.db",
"timetableHistory": "data/timetable-history.db"
},
"runtime": {
"adminTraffic": {
"file": "data/runtime/admin-traffic.json",
"flushIntervalMinutes": 30
},
"adminServerMetrics": {
"file": "data/runtime/admin-server-metrics.json",
"flushIntervalMinutes": 10,
"sampleIntervalSeconds": 60
},
"trainProvenance": {
"enabled": true,
"retentionDays": 7
}
}
},
"user": {
"saltLength": 16,
"apiKeyPrefixes": {
"webapp": "ocrh_webapp_",
"api": "ocrh_api_",
"oauth": "ocrh_oauth_"
},
"adminUserIds": [],
"apiKeyBytes": 24,
"apiKeyTtlSeconds": 2592000,
"apiKeyMaxLifetimeSeconds": 157680000,
"signKey": "OpenCRHTracker",
"scrypt": {
"keyLength": 64,
"cost": 16384,
"blockSize": 8,
"parallelization": 1
},
"apiKeyNameLength": {
"minLength": 1,
"maxLength": 64
},
"favorites": {
"maxEntries": 10
},
"pushSubscriptions": {
"maxDevices": 5,
"maxEventSubscriptions": 50,
"syncTimeoutSeconds": 30
},
"push": {
"vapidPublicKey": "",
"vapidPrivateKey": "",
"vapidEmail": ""
}
},
"api": {
"versionPrefix": "/api/v1",
"apiKeyHeader": "authorization",
"authCookieName": "token",
"clientIpHeaders": [
"cf-connecting-ip",
"x-forwarded-for",
"x-real-ip"
],
"authRateLimit": {
"login": {
"maxRequests": 10,
"windowSeconds": 1800
},
"register": {
"maxRequests": 3,
"windowSeconds": 86400
},
"oauthAuthorize": {
"maxRequests": 20,
"windowSeconds": 1200
},
"oauthToken": {
"maxRequests": 20,
"windowSeconds": 1200
}
},
"authCache": {
"userRecord": {
"maxEntries": 1024,
"defaultTtlSeconds": 1800
},
"apiKeyRecord": {
"maxEntries": 4096,
"defaultTtlSeconds": 21600
},
"userProfile": {
"maxEntries": 256,
"defaultTtlSeconds": 21600
}
},
"payload": {
"maxStringLength": 16384
},
"feedback": {
"validation": {
"createBody": {
"minLength": 2,
"maxLength": 12000
},
"replyBody": {
"minLength": 2,
"maxLength": 2000
},
"title": {
"minLength": 4,
"maxLength": 80
}
}
},
"headers": {
"remain": "x-api-remain",
"cost": "x-api-cost",
"retryAfter": "Retry-After"
},
"cache": {
"currentDayMaxAgeSeconds": 300,
"historicalMaxAgeSeconds": 31536000,
"searchIndexMaxAgeSeconds": 1800,
"sitemapMaxAgeSeconds": 86400,
"timetableMaxAgeSeconds": 21600
},
"pagination": {
"defaultLimit": 20,
"maxLimit": 200
},
"timestampUnit": "seconds",
"debug": {
"enableEchoError": true
},
"permissions": {
"anonymousScopes": [
"api.config.read",
"api.search.read",
"api.records.daily.read",
"api.history.train.read",
"api.history.emu.read",
"api.timetable.train.current.read",
"api.timetable.train.circulation.image.read",
"api.timetable.train.history.read",
"api.exports.daily.read",
"api.feedback.read",
"api.feedback.create",
"api.timetable.station.read"
],
"issuedKeyDefaultScopes": [
"api.config.read",
"api.search.read",
"api.records.daily.read",
"api.history.train.read",
"api.history.emu.read",
"api.timetable.train.current.read",
"api.timetable.train.circulation.image.read",
"api.timetable.train.history.read",
"api.exports.daily.read",
"api.feedback.read",
"api.feedback.create",
"api.feedback.reply",
"api.auth.me.read",
"api.auth.logout",
"api.auth.password.update",
"api.auth.settings.read",
"api.auth.settings.write",
"api.auth.authorizations.read",
"api.auth.authorizations.revoke",
"api.auth.api-keys.read",
"api.auth.api-keys.create",
"api.auth.oauth-clients.write",
"api.auth.oauth-clients.delete",
"api.auth.api-keys.revoke",
"api.auth.favorites.read",
"api.auth.favorites.write",
"api.auth.subscriptions.read",
"api.auth.subscriptions.write",
"api.timetable.station.read"
],
"creatableKeyMaxScopes": [
"api.auth.me.read",
"api.records.daily.read",
"api.history.train.read",
"api.history.emu.read",
"api.timetable.train.current.read",
"api.timetable.train.circulation.image.read",
"api.timetable.train.history.read",
"api.exports.daily.read",
"api.timetable.station.read"
]
}
},
"oauth": {
"issuer": "https://example.com",
"authorizationCodeTtlSeconds": 300,
"accessTokenTtlSeconds": 3600,
"idTokenTtlSeconds": 3600,
"loginContinuationTtlSeconds": 600,
"subjectSalt": "change-me-oauth-subject-salt",
"pkce": {
"allowedMethods": [
"S256"
]
},
"discovery": {
"enabled": true,
"externalBaseUrl": "https://example.com"
},
"idTokenSigning": {
"kid": "default-rs256",
"privateKeyPem": "-----BEGIN PRIVATE KEY-----\\nchange-me\\n-----END PRIVATE KEY-----",
"alg": "RS256"
}
},
"services": {
"simpleLatexContainer": {
"baseUrl": "http://127.0.0.1:8080",
"apiKey": "change-me"
}
},
"task": {
"startup": {
"disabledExecutors": []
},
"apiKeyCleanup": {
"retentionDays": 7,
"dailyTimeHHmm": "0000"
},
"dailyExport": {
"dailyTimeHHmm": "0000"
},
"referenceModel": {
"windowDays": 14,
"batchSize": 1000,
"threshold": 0.3,
"dailyTimesHHmm": [
"0300",
"0900",
"1500",
"2100"
]
},
"circulation": {
"windowDays": 14,
"batchSize": 2000,
"threshold": 0.8,
"dailyTimesHHmm": [
"0200"
],
"stationBoard": {
"maxAttempts": 5,
"retryDelaySeconds": 1800
}
},
"scheduler": {
"pollIntervalMs": 180000,
"maxTasksPerQuery": 65535,
"idle": {
"maxTasksPerTick": 256,
"emaAlpha": 0.3
}
}
},
"logging": {
"retentionDays": 5
},
"quota": {
"anonymousMaxTokens": 25,
"userMaxTokens": 1000,
"refillAmount": 5,
"refillIntervalSeconds": 300,
"resetToMaxOnRestart": true,
"consumeTokens": true
},
"cost": {
"fixed": {
"health": 0,
"authMe": 1,
"authLogout": 1,
"debugEchoError": 0,
"authIssueApiKey": 5,
"authCreateOauthClient": 500,
"authDeleteOauthClient": 5,
"authListApiKeys": 1,
"authRevokeApiKey": 1,
"authListAuthorizations": 1,
"authRevokeAuthorization": 1,
"authListSubscriptions": 1,
"authUpsertSubscription": 1,
"authUpdateSubscription": 1,
"authDeleteSubscription": 1,
"searchIndex": 1,
"timetableTrainCurrent": 1,
"trainCirculationImageCacheHit": 2,
"trainCirculationImage": 20,
"trainCirculationImageFailure": 2,
"timetableTrainHistory": 1,
"exportDailyIndex": 2,
"exportDaily": 50,
"authChangePassword": 5
},
"perRecord": {
"historyEmu": {
"unitCost": 0.05,
"rounding": "ceil"
},
"historyTrain": {
"unitCost": 0.05,
"rounding": "ceil"
},
"recordsDaily": {
"unitCost": 0.05,
"rounding": "ceil"
},
"timetableTrainHistory": {
"unitCost": 0.05,
"rounding": "ceil"
},
"timetableStation": {
"unitCost": 0.05,
"rounding": "ceil"
}
}
}
}如果需要启用管理端的来源时间线和重联扫描结果查看,还需要补充一组独立的 provenance 数据库与运行时配置。
增量示例:来源追踪与历史时刻表积累配置
{
"data": {
"databases": {
"trainProvenance": "data/train-provenance.db",
"timetableHistory": "data/timetable-history.db"
},
"runtime": {
"trainProvenance": {
"enabled": true,
"retentionDays": 7
}
}
}
}- `data.databases.trainProvenance`:来源事件数据库文件路径,用于管理端“来源时间线”和“重联扫描结果”查询。
- `data.databases.timetableHistory`:内部历史时刻表积累数据库文件路径,用于保存规范化 stops 内容、sha256 hash 和按 service date 压缩后的 coverage 段。
- `data.runtime.trainProvenance.enabled`:是否启用来源事件记录,默认值为 true;关闭后不再为任务写入新的来源事件,相关管理端查询会返回禁用状态。
- `data.runtime.trainProvenance.retentionDays`:来源事件保留天数,默认值为 7,最小值为 1;服务启动时会按该值清理过期记录。
spider:抓取 12306 数据
这一组决定 12306 抓取行为和探测频率。
spider.userAgentstring 必填 抓取请求使用的 User-Agent。
- 建议保持一个稳定、能被上游接受的移动端 UA。
spider.paramsobject 必填 抓取接口固定参数,包含 eKey、jsonpCallback 和 routeProbeCarCode。
spider.rateLimit.query.minIntervalMsinteger 必填 查询车次、车组号和畅行码接口的最小调用间隔,单位毫秒。
spider.rateLimit.search.minIntervalMsinteger 必填 通过 12306 搜索接口检索启用车次号的最小调用间隔,单位毫秒。
spider.rateLimit.stationBoard.minIntervalMsinteger 必填 调用车站大屏和车站时刻相关 12306 接口的最小调用间隔,单位毫秒。
spider.scheduleProbe.dailyTimeHHmmstring(HHmm) 必填 每天生成车次探测任务的时间点,北京时间。
spider.scheduleProbe.retryAttempts / maxBatchSize / checkpointFlushEveryinteger 必填 控制车次探测任务的失败重试次数、单批处理上限和检查点落盘频率。
spider.scheduleProbe.refreshobject 必填 控制时刻表刷新任务的批次、TTL 和生成间隔。
spider.scheduleProbe.probeobject 必填 探测重试次数与 12306 接口数据异常的重试策略。
spider.scheduleProbe.probe.latestExecutionTimeHHmmstring(HHmm) 必填 控制首轮探测任务派发允许执行到的最晚时间;设置为 0000 表示关闭这个限制。
- 只影响任务派发的执行时间,不代表列车真实发车时间。
spider.scheduleProbe.couplingobject 必填 担当关系探测的状态重置、延迟和冷却参数。
- statusResetTimeHHmm 也必须是 HHmm 字符串。
spider.scheduleProbe.prefixRulesarray<object> 必填 允许探测的车次前缀与号段范围。
- prefix 必须是大写字母。
- 同一 prefix 的号段不能重叠。
- 数组不能为空。
data:数据库与静态资产文件
这一组决定数据库文件、schedule 文件和初始化资产的实际落盘位置。
data.assets.EMUListobject 必填 动车组清单 allocation export JSON 文件路径、下载地址与刷新时间。
- 默认文件建议为 data/emu_list.json。
- refresh.enabled=true 时 provider 必填。
- refresh.refreshAt 必须是 HHmm 字符串。
- 管理员页面支持本地重载和远程刷新,刷新后会立即影响畅行码探测和车组别名解析。
data.assets.QRCodeobject 必填 铁路畅行码资产文件路径、下载地址与刷新时间。
- refresh.enabled=true 时 provider 必填。
- refresh.refreshAt 必须是 HHmm 字符串。
data.assets.qrcodeDetectionobject 必填 固定车组畅行码检测计划文件路径、下载地址与刷新时间。
- 默认文件建议为 data/qrcode_detection.json。
- refresh.enabled=true 时 provider 必填。
- refresh.refreshAt 必须是 HHmm 字符串。
- 管理员页面支持本地重载和远程刷新,刷新后会重新校验并同步未来派发任务。
data.assets.trainStyleMappingobject 必填 参考车型回退使用的 trainStyle 映射文件路径、下载地址与刷新时间。
- 默认文件建议为 data/train_style_mapping.json。
- refresh.enabled=true 时 provider 必填。
- refresh.refreshAt 必须是 HHmm 字符串。
- 管理员页面支持本地重载和远程刷新,刷新后会立即影响 referenceModels 的 schedule 回退映射结果。
data.assets.stationCoordobject 必填 车站坐标回退文件路径、下载地址与刷新时间。
- 默认文件建议为 data/stationCoord.jsonl。
- refresh.enabled=true 时 provider 必填。
- refresh.refreshAt 必须是 HHmm 字符串。
- 管理员页面支持本地重载和远程刷新,刷新后会立即影响后续线路时刻表下载任务。
data.assets.scheduleobject 必填 时刻表文件路径与来源地址。
- file 路径必须可读写;provider 建议保持可用。
- schedule.json 会在旧版格式下自动升级,无需手工删除旧文件。
- 当前格式会持久化完整经停站、当前站车次与检票口信息。
data.databases.taskstring 必填 任务调度数据库路径。
data.databases.EMUTrackedstring 必填 担当历史与日记录数据库路径。
data.databases.usersstring 必填 用户、登录态和 API Key 数据库路径。
data.databases.feedbackstring 必填 反馈与回复数据数据库路径。
- 所有数据库路径都建议指向持久化磁盘。
data.databases.timetableHistorystring 必填 内部历史时刻表积累数据库路径。
- 数据库会保存规范化后的 stops JSON 内容、sha256 hash 和按 service date 压缩的 coverage 段。
- 只有 build_today_schedule 的 enrich 成功或 refresh_route_batch 成功落盘后,才会把确认过的车次组写入这个库。
- 仅从昨天复用的 route 信息不会计为新的历史确认。
data.runtime:运行时统计文件
这一组决定管理员流量统计与服务器监控的落盘位置、采样间隔和定时写盘间隔。
data.runtime.adminTraffic.filestring 必填 管理员流量统计文件路径。
- 服务启动时会优先尝试从该文件恢复统计窗口。
- 文件父目录必须可写,建议放在持久化磁盘。
data.runtime.adminTraffic.flushIntervalMinutesinteger 必填 管理员流量统计的定时落盘间隔,单位分钟,默认值为 30。
- 仅在内存状态有变化时写盘。
data.runtime.adminServerMetrics.filestring 必填 服务器监控统计文件路径。
- 管理员服务器监控页的 CPU、内存、系统负载、SSR/API 时长窗口与 Top 5 慢路径聚合会从这里恢复。
- 文件父目录必须可写,建议放在持久化磁盘。
data.runtime.adminServerMetrics.flushIntervalMinutesinteger 必填 服务器监控统计的定时落盘间隔,单位分钟,默认值为 10。
- 仅在内存状态有变化时写盘。
data.runtime.adminServerMetrics.sampleIntervalSecondsinteger 必填 服务器监控后台采样间隔,单位秒,默认值为 60。
- CPU、内存和系统负载会按这个周期采样。
- SSR/API 时长与路径级延迟聚合仍按请求完成时实时入桶。
user:用户与 API Key 安全参数
这一组决定密码派生策略、API Key 生命周期和登录签名密钥。
user.saltLengthinteger 必填 密码盐长度。
user.apiKeyPrefixesobject 必填 分别定义站内登录态签名、普通 API Key 和 OAuth access token 的前缀。
- 当前必须同时提供 webapp、api、oauth 三个前缀。
- 三个前缀都不能为空,且彼此不能重复。
user.apiKeyBytesinteger 必填 生成 API Key 时的随机字节数。
user.apiKeyTtlSecondsinteger 必填 默认签发的 API Key 生命周期,单位秒。
user.apiKeyMaxLifetimeSecondsinteger允许签发的最大 API Key 生命周期上限。
- 省略时默认 157680000 秒。
- 必须不小于 apiKeyTtlSeconds。
user.apiKeyNameLengthobject 必填 API Key 名称长度限制,包含 minLength 和 maxLength。
- 前端签发表单和服务端接口会共用这组限制。
- maxLength 必须大于等于 minLength。
user.adminUserIdsarray<string>管理员用户 ID 列表,命中的 webapp 登录用户会额外获得 api.admin 权限。
- 可以留空;留空表示不通过配置文件授予管理员。
- 生产环境建议通过 OCRH_ADMIN_USERS 覆盖,格式为逗号分隔的用户 ID 列表。
- 当 OCRH_ADMIN_USERS 非空时,会覆盖 user.adminUserIds。
user.favorites.maxEntriesinteger 必填 单个用户允许同步保存的最大收藏数。
- 当前前端收藏能力依赖这个上限,超限时接口会直接报错。
- 本次默认值为 10。
user.pushSubscriptions.maxDevices / user.pushSubscriptions.maxEventSubscriptions / user.pushSubscriptions.syncTimeoutSecondsinteger 必填 已存储 PushSubscription 端点的设备数量上限、预留的事件订阅规则数量上限,以及当前设备同步超时时间(秒)。
- maxDevices 按每个用户已存储的 PushSubscription 端点数量计数。
- 同一台物理设备在使用不同浏览器、不同用户配置或不同 PWA 安装时,可能会占用多条记录。
- maxEventSubscriptions 为后续事件订阅规则预留,当前代码尚未实际强制这一上限。
- syncTimeoutSeconds 控制控制台等待权限弹窗、Service Worker 就绪和浏览器订阅调用的最长时间;默认值为 30 秒。
user.push.vapidPublicKey / user.push.vapidPrivateKey / user.push.vapidEmailstring 必填 Web Push 所需的 VAPID 公钥、私钥和联系邮箱。
- 生产环境建议通过 OCRH_VAPID_PUBLIC_KEY、OCRH_VAPID_PRIVATE_KEY 和 OCRH_VAPID_EMAIL 覆盖,而不是把真实值直接写进配置文件。
- vapidEmail 只填写纯邮箱地址,例如 [email protected];程序会自动补上 mailto: 前缀。
- Apple 的 Safari / iOS Web Push 对 VAPID subject 更严格,邮箱缺失、非法或使用本地占位值时都可能导致推送被拒绝。
user.signKeystring 必填 登录签名密钥。
- 生产环境建议通过 OCRH_SIGN_KEY 覆盖,而不是把真实密钥直接写进配置文件。
- 修改后现有相关登录态可能失效。
user.scryptobject 必填 密码派生算法参数,包含 keyLength、cost、blockSize、parallelization。
oauth:OAuth 2.0 / OpenID Connect 授权服务器配置
这一组决定站点对外暴露的 OAuth 2.0 与 OIDC 行为,包括 issuer、授权码与 token 生命周期、PKCE 策略、discovery 文档地址以及 id_token 签名密钥。
oauth.issuerstring(URL) 必填 OAuth/OIDC 对外声明的 issuer,用于写入 discovery 文档、id_token 的 iss claim,以及第三方客户端校验授权服务器身份。
- 必须以 http:// 或 https:// 开头。
- 生产环境应填写第三方应用实际可访问到的外部地址,而不是容器内部地址或仅本机可见的回环地址。
- 它应与对外公开的授权服务器域名保持稳定;修改后,依赖旧 issuer 的客户端通常需要重新配置。
oauth.authorizationCodeTtlSecondsinteger(seconds) 必填 authorization code 的有效期,单位秒;从用户完成授权开始计时,超过时限后 /oauth/token 必须拒绝换取 token。
- 必须为正整数。
- 建议保持较短时长,减少授权码泄露后的可利用窗口。
oauth.accessTokenTtlSecondsinteger(seconds) 必填 OAuth access token 的有效期,单位秒;会写入复用的签名 API Key 记录,并影响现有 Bearer Token 资源访问链路的过期判断。
- 必须为正整数。
- access token 会直接作为现有 /api/v1/* 接口的 Bearer Token 使用,因此过期时间会影响第三方应用访问所有受 scope 保护资源的可用窗口。
- 暂不支持 refresh token,过期后需要重新走授权流程。
oauth.idTokenTtlSecondsinteger(seconds) 必填 OIDC id_token 的有效期,单位秒;用于控制第三方客户端持有身份断言的时间窗口。
- 必须为正整数。
- id_token 与 access token 分离签发,不复用现有 HMAC API Key 格式。
- 时长不宜过长,否则客户端长期缓存旧身份断言时,撤销或策略变更的收敛会更慢。
oauth.loginContinuationTtlSecondsinteger(seconds) 必填 未登录用户访问 /oauth/authorize 时,系统为“登录后继续原授权流程”保存上下文的有效期,单位秒。
- 必须为正整数。
- 这个值控制的是授权流程恢复窗口,不是 Cookie 登录态本身的生命周期。
- 值过短可能导致用户刚完成站内登录就无法返回原 OAuth 授权确认页。
oauth.subjectSaltstring 必填 生成 OIDC subject 标识时使用的服务端盐值,用于把站内用户 ID 稳定映射为对外暴露的 sub。
- 不能为空。
- 应使用独立、不可预测的随机字符串,避免直接暴露站内用户主键或让第三方推导真实用户 ID。
- 修改后,同一用户在 OIDC 中看到的 sub 会发生变化,已依赖旧 sub 建立映射关系的客户端可能需要重新关联账号。
oauth.pkce.allowedMethodsarray<"S256"> 必填 允许客户端在授权请求中使用的 PKCE code_challenge_method 列表。
- 当前实现要求该数组必须精确为 ["S256"],不接受 plain,也不接受额外方法。
- 这是对 public client 的强制安全约束,所有客户端都必须带 PKCE。
oauth.discovery.enabledboolean 必填 是否启用标准 OIDC discovery 文档与关联元数据暴露。
- 开启后,第三方客户端可通过 /.well-known/openid-configuration 自动发现授权端点、token 端点、userinfo 端点和 JWKS 地址。
- 如果关闭 discovery,协议端点实现仍可存在,但接入方需要手工配置所有地址和签名信息来源。
oauth.discovery.externalBaseUrlstring(URL) 必填 discovery 与 JWKS 文档中生成公开 URL 时使用的外部基础地址。
- 必须以 http:// 或 https:// 开头;服务启动时会自动去掉末尾多余的 /。
- 通常应与 oauth.issuer 指向同一对外域名;若站点通过网关、反代或单独公开 OAuth 子路径,这里可以明确指定 discovery 文档里应返回的基准地址。
- 如果填写内网地址或错误前缀,标准 OIDC 客户端即使能拿到 discovery 文档,也会因为端点 URL 不可达而接入失败。
oauth.idTokenSigning.kidstring 必填 JWKS 中发布的当前签名密钥标识,以及 id_token JWT header 里的 kid。
- 不能为空。
- 生产环境建议通过 OCRH_OAUTH_ID_TOKEN_SIGNING_KID 覆盖,而不是把真实运行值直接写进配置文件。
- 第三方客户端会用它在 JWKS 中定位对应公钥;后续做密钥轮换时,应保证不同密钥使用不同 kid。
oauth.idTokenSigning.privateKeyPemstring(PEM private key) 必填 用于签发 OIDC id_token 的 RSA 私钥,PEM 格式保存。
- 配置值必须看起来像 PEM 私钥,启动时会检查是否包含 BEGIN 片段。
- 生产环境建议通过 OCRH_OAUTH_ID_TOKEN_SIGNING_PRIVATE_KEY 覆盖,而不是把真实私钥直接写进配置文件。
- 生产环境应使用真实私钥并妥善保密;泄露后,攻击者可伪造看似合法的 id_token。
- 如果写在 JSON 中,多行内容需要按 JSON 字符串格式转义换行,例如使用 \n。
- 如果通过环境变量提供,可直接传入多行 PEM,也可传入包含 \n 的单行字符串,服务启动时会自动还原换行。
oauth.idTokenSigning.alg"RS256" 必填 id_token 使用的 JWT 签名算法标识。
- 当前实现只接受 RS256;配置为其他值会直接阻止服务启动。
- 该值应与私钥类型和 JWKS 中暴露的公钥参数保持一致。
api:接口基础行为、鉴权与权限范围
这一组决定 API 路径、请求头、Cookie、缓存、分页以及公开权限边界。
api.versionPrefixstring 必填 API 统一前缀,例如 /api/v1。
api.apiKeyHeaderstring 必填 API Key 所使用的请求头名称。
- 如果使用 authorization,文档和调试器会按 Bearer 形式发送。
api.authCookieNamestring 必填 浏览器登录态使用的 Cookie 名称。
api.clientIpHeadersarray<string>按顺序决定服务端从哪些请求头读取客户端 IP。
- 未配置时默认依次读取 cf-connecting-ip、x-forwarded-for、x-real-ip。
- 当命中 x-forwarded-for 时,只会取逗号分隔后的第一个地址。
- 所有配置头都取不到值时,会回退到 socket.remoteAddress。
- 使用 Cloudflare、Nginx 或其他反向代理时,应确保真实客户端 IP 会被透传到这些头之一。
api.authRateLimitobject 必填 登录、注册、OAuth 授权流程与 OAuth 令牌接口的限流配置。
api.authRateLimit.oauthAuthorizeobject 必填 OAuth 授权流程的限流配置,对应 GET /api/v1/oauth/authorize/context 与 POST /oauth/authorize。
- 这两个入口共用同一个限流桶,便于限制授权页探测和频繁同意/拒绝操作。
- 当前默认建议值为 20 分钟 20 次。
api.authRateLimit.oauthTokenobject 必填 OAuth 令牌交换接口的限流配置,对应 POST /oauth/token。
- 该接口用于将授权码交换为访问令牌,当前默认建议值为 20 分钟 20 次。
- 限流命中后会直接拒绝请求;来源校验失败、请求体无效或授权码无效时也会计入这一额度。
api.authCacheobject 必填 用户记录、用户资料和 API Key 记录缓存的容量与 TTL。
api.authCache.userProfileobject 必填 user profile 数据 JSON 的服务端缓存容量与 TTL。
- 收藏接口读写成功后会直接回写这一层缓存,而不是简单删除缓存。
- 本次推荐值为 maxEntries=256,defaultTtlSeconds=21600。
api.payload.maxStringLengthinteger 必填 请求体允许的最大字符串长度。
api.feedback.validationobject 必填 反馈接口后端校验使用的长度限制配置,分别控制创建反馈正文、回复正文和管理员修改标题时的最小/最大长度。
- createBody 对应 POST /api/v1/feedback/topics 的 body 长度范围。
- replyBody 对应 POST /api/v1/feedback/topics/[id]/messages 的 body 长度范围。
- title 对应 PATCH /api/v1/feedback/topics/[id] 的 title 长度范围。
api.headersobject 必填 额度剩余、成本和重试时间的响应头名称。
api.cacheobject 必填 当前日、历史、搜索索引、sitemap 和 timetable 接口成功响应的缓存时长。
- sitemapMaxAgeSeconds 用于控制 /sitemap.xml 响应的 Cache-Control max-age。
- timetableMaxAgeSeconds 用于控制 /api/v1/timetable/train/*/current、/api/v1/timetable/train/*/history/* 和 /api/v1/timetable/station/* 成功响应的 Cache-Control max-age。
api.paginationobject 必填 分页默认大小和最大上限。
- maxLimit 必须不小于 defaultLimit。
api.timestampUnitstring 必填 时间戳单位。
- 当前代码只支持 seconds。
api.debug.enableEchoErrorboolean 必填 是否允许调试接口回显错误。
api.permissions.anonymousScopesarray<string> 必填 匿名访问允许拥有的 scopes。
- 这组配置直接决定公开可匿名调用的接口范围。
- 如果要公开车次详情页时刻表弹窗,需要包含 api.timetable.train.current.read。
- 如果要公开列车交路运行图图片接口,需要包含 api.timetable.train.circulation.image.read。
- 如果要公开车站页时刻表接口,需要包含 api.timetable.station.read。
api.permissions.issuedKeyDefaultScopesarray<string> 必填 新签发 API Key 的默认权限集合。
- 如需默认允许当前车次时刻表接口,请包含 api.timetable.train.current.read;如需默认允许历史车次时刻表接口,请同时包含 api.timetable.train.history.read。
- 如需默认允许列车交路运行图图片接口,请包含 api.timetable.train.circulation.image.read。
- 如需默认允许车站时刻表接口,请包含 api.timetable.station.read。
- 如需让 Web 端收藏功能开箱即用,请包含 api.auth.favorites.read 和 api.auth.favorites.write。
- 如需让 Web 端用户删除自己创建的 OAuth 客户端,请包含 api.auth.oauth-clients.delete;已有登录会话可能需要重新登录后才会拿到新增 scope。
api.permissions.creatableKeyMaxScopesarray<string> 必填 前端可签发 API Key 时允许选择的最大权限范围。
- 若希望外部调用方可勾选当前车次时刻表接口,这里也要包含 api.timetable.train.current.read;若希望可勾选历史车次时刻表接口,这里也要包含 api.timetable.train.history.read。
- 若希望外部调用方可勾选列车交路运行图图片接口,这里也要包含 api.timetable.train.circulation.image.read。
- 若希望外部调用方可勾选车站时刻表接口,这里也要包含 api.timetable.station.read。
services:外部微服务依赖
这一组声明本服务依赖的外部渲染微服务地址和鉴权参数。
services.simpleLatexContainer.baseUrlstring 必填 simple-latex-container 服务的基础地址,用于生成单组车底运行图。
- 必须以 http:// 或 https:// 开头。
- 程序会自动去掉结尾多余的 /。
services.simpleLatexContainer.apiKeystring 必填 调用 simple-latex-container 的 Bearer API Key。
- 生产环境建议通过 OCRH_SIMPLE_LATEX_CONTAINER_API_KEY 覆盖,而不是把真实密钥直接写进配置文件。
- 如果未设置环境变量,生产环境启动时会输出警告。
task:后台任务与调度器
这一组决定启动时禁用的执行器、定时任务时间以及轮询调度行为。
task.startup.disabledExecutorsarray<string>启动时跳过的执行器列表。
- 只能使用 build_today_schedule、generate_route_refresh_tasks、dispatch_daily_probe_tasks、clear_daily_probe_status、cleanup_revoked_api_keys、export_daily_records、rebuild_reference_model_index、rebuild_train_circulation_index。
- 为空数组表示全部启用。
task.apiKeyCleanup.retentionDaysinteger 必填 吊销 API Key 的保留天数。
task.apiKeyCleanup.dailyTimeHHmmstring(HHmm) 必填 每日执行 API Key 清理任务的时间。
task.dailyExport.dailyTimeHHmmstring(HHmm) 必填 每日导出任务的执行时间。
task.referenceModelobject 必填 参考车型索引重建任务的配置项。
- windowDays 用于设置历史窗口天数,当前默认值为 14。
- batchSize 用于设置扫描 daily_emu_routes 时的分页批大小,当前默认值为 1000。
- threshold 是 weightedShare 的阈值,必须大于 0 且小于等于 1。
- 当历史窗口内完全没有命中记录时,系统会回退到 schedule.json 的 trainStyle,并再经过 train_style_mapping.json 映射后返回 weightedShare=0 的参考车型。
- dailyTimesHHmm 用于手动填写多个 HHmm 时刻,控制 rebuild_reference_model_index 在一天内多次重建。
task.circulationobject 必填 列车交路推断索引重建任务的配置项。
- windowDays 用于设置交路推断历史窗口天数,当前默认值为 14。
- batchSize 用于流式扫描 daily_emu_routes 时的分页批大小,当前默认值为 2000。
- threshold 是归一化边权重的阈值,必须大于 0 且小于等于 1,当前默认值为 0.8。
- dailyTimesHHmm 用于配置 rebuild_train_circulation_index 的每日重建时刻,默认值为 ["0200"],结果会并入列车时刻表接口的 circulation 字段。
- stationBoard 可选;省略时默认使用 maxAttempts=5、retryDelaySeconds=1800。
task.circulation.stationBoardobject列车交路推断在补查车站大屏数据时使用的重试参数。
- maxAttempts 是最大尝试次数,最小值为 1,默认值为 5。
- retryDelaySeconds 是重试前等待秒数,最小值为 0,默认值为 1800。
task.scheduler.pollIntervalMsinteger 必填 调度器轮询间隔,单位毫秒。
task.scheduler.maxTasksPerQueryinteger 必填 单次查询最多取回的任务数。
task.scheduler.idle.maxTasksPerTickinteger 必填 空闲模式下每个 tick 最多拉起的任务数。
task.scheduler.idle.emaAlphanumber 必填 空闲调度使用的 EMA 系数。
- 必须大于 0 且小于等于 1。
logging:日志文件
这一组决定 logs/ 目录下按日滚动日志的保留时间。
logging.retentionDaysinteger 必填 应用日志的总保留天数,默认值为 5。
- 保留天数包含当前当天正在写入的日志文件。
- 日志文件位于 logs/ 目录,可按运维策略另行备份。
quota:额度与补充策略
这一组决定匿名用户和登录用户的额度桶大小,以及额度恢复方式。
quota.anonymousMaxTokensinteger 必填 匿名访问的额度桶上限。
quota.userMaxTokensinteger 必填 登录用户的额度桶上限。
quota.refillAmountinteger 必填 每个补充周期恢复的额度数量。
quota.refillIntervalSecondsinteger 必填 额度补充周期,单位秒。
quota.resetToMaxOnRestartboolean 必填 服务重启后是否把额度恢复到上限。
quota.consumeTokensboolean 必填 是否实际扣减额度。
- 关闭后可以保留成本计算,但不会真正消耗额度。
cost:接口耗额策略
这一组决定每个接口、以及按记录数量计费接口的额度成本。
cost.fixed.health / authMe / authLogout / debugEchoErrorinteger 必填 健康检查、身份相关和调试接口的固定成本。
cost.fixed.authChangePassword / authIssueApiKey / authCreateOauthClient / authDeleteOauthClient / authListApiKeys / authRevokeApiKeyinteger 必填 API Key 管理接口与 OAuth 客户端创建、删除接口的固定成本。
cost.fixed.searchIndex / timetableTrainCurrent / trainCirculationImage / trainCirculationImageCacheHit / trainCirculationImageFailure / timetableTrainHistory / exportDailyIndex / exportDailyinteger 必填 搜索、当前时刻表、运行图图片与导出接口的固定成本。
- trainCirculationImageCacheHit 控制运行图图片缓存命中成本,默认 2。
- trainCirculationImageFailure 控制运行图图片失败成本,默认 2。
cost.perRecord.historyEmuobject 必填 按车组历史查询的按记录计费规则。
- 当前 rounding 只支持 ceil。
cost.perRecord.historyTrainobject 必填 按车次历史查询的按记录计费规则。
- 当前 rounding 只支持 ceil。
cost.perRecord.recordsDailyobject 必填 每日记录查询的按记录计费规则。
- 当前 rounding 只支持 ceil。
cost.perRecord.timetableStationobject 必填 车站时刻表分页查询的按记录计费规则。
- 当前 rounding 只支持 ceil。
修改配置后的建议
- 修改 data.databases 或 data.assets 路径前先备份旧文件,再验证新路径具备正确的读写权限。
- 修改 user.signKey、api.apiKeyHeader、api.authCookieName 等身份相关字段后,应该视为一次完整重启变更并安排验证。
- 修改 user.adminUserIds 或 OCRH_ADMIN_USERS 后需要重启 Node 进程,新的登录会话才会按最新名单授予管理员权限。
- 修改 user.push 或 OCRH_VAPID_PUBLIC_KEY / OCRH_VAPID_PRIVATE_KEY / OCRH_VAPID_EMAIL 后需要重启 Node 进程,并建议立即用一台 Apple Safari PWA 设备验证推送是否正常。
- 修改 oauth.idTokenSigning 或 OCRH_OAUTH_ID_TOKEN_SIGNING_KID / OCRH_OAUTH_ID_TOKEN_SIGNING_PRIVATE_KEY 后需要重启 Node 进程,并建议立即验证 JWKS 输出与 id_token 签名是否正常。
- 升级到支持 OAuth 客户端删除的版本后,既有私有部署需要在 cost.fixed 中补充 authDeleteOauthClient,并按需在 api.permissions.issuedKeyDefaultScopes 中补充 api.auth.oauth-clients.delete。
- 提高 api.permissions.anonymousScopes、quota 或 cost 前,先确认你准备公开暴露的接口范围和限额策略。
- 修改 spider.scheduleProbe.prefixRules、task.referenceModel.dailyTimesHHmm、task.circulation.dailyTimesHHmm 或 task.scheduler 参数后,重启后应观察首轮任务执行是否符合预期。
- 修改 spider.rateLimit.stationBoard 或 task.circulation.stationBoard 后,重启后应关注车站大屏相关抓取、重试与交路推断任务是否按预期工作。
run
启动服务
构建完成和确认配置文件无误后,可启动服务。
请先设置登录签名秘钥,该秘钥将被用于签名用户的 token,切勿泄露。
export OCRH_SIGN_KEY=<xxxxxxxx>如果启用了 OAuth/OIDC,请同时设置 id_token 签名所需的 kid 和 RSA 私钥。私钥既可以直接传入多行 PEM,也可以传入包含 \n 的单行字符串。
export OCRH_OAUTH_ID_TOKEN_SIGNING_KID=default-rs256
export OCRH_OAUTH_ID_TOKEN_SIGNING_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"如果需要启用 Web Push,请同时设置 VAPID 公私钥和联系邮箱;vapidEmail 只填写纯邮箱地址,不要带 mailto: 前缀。
export OCRH_VAPID_PUBLIC_KEY=<base64url-public-key>
export OCRH_VAPID_PRIVATE_KEY=<base64url-private-key>
export [email protected]如果需要授予管理员权限,请设置管理员用户 ID 列表;多个用户使用英文逗号分隔。
export OCRH_ADMIN_USERS=<user-id-1>,<user-id-2>如果需要启用单组车底运行图图片接口,请在配置文件中为 LaTeX 渲染微服务设置 URL 地址(`services.simpleLatexContainer.baseUrl`),并在环境变量中设置 API Key。使用的 LaTeX 渲染微服务为 https://github.com/lihugang/simple-latex-container。
export OCRH_SIMPLE_LATEX_CONTAINER_API_KEY=<simple-latex-container-api-key>然后设置服务器监听端口,默认为 3000
export NITRO_PORT=<port>如果你是通过 NUXT_EXTERNALIZE_NATIVE_DEPS=1 生成构建产物,请先在目标机器的项目根目录执行 pnpm install --frozen-lockfile,再启动服务。默认 pnpm build 构建不需要额外增加这一步。
启动服务
nohup node .output/server/index.mjs>log.log 2>&1 &应用会在 logs/ 目录下按天滚动写入日志,同时按 data.runtime 配置把管理员流量统计和服务器监控统计分别写入独立文件。
emu-list
emu_list.json
为畅行码探测、车组别名解析和重联判断提供动车组基础清单。
动车组清单文件默认建议放在 data/emu_list.json,实际路径由 data/config.json 中的 data.assets.EMUList.file 决定。默认远程来源为 https://allocation.crhdata.top/api/v1/allocation/export.json,文件内容使用 allocation export JSON 范式。
{
"schema_version": 1,
"railway_bureaus": [],
"train_depots": [],
"emu_depots": [],
"trainset_models": [],
"coach_layouts": [],
"emu_trainsets": []
}- 管理员页面“配置文件”支持对该文件执行本地重载和远程刷新,刷新后会同步固定车组畅行码检测依赖。
station-coord
stationCoord.jsonl
为线路时刻表下载任务提供 12306 缺失车站坐标时的回退数据源。
车站坐标回退文件默认建议放在 data/stationCoord.jsonl,实际路径由 data/config.json 中的 data.assets.stationCoord.file 决定。仅当 12306 路线查询返回的某个停站缺少 lat/lon 时,线路时刻表下载任务才会读取本文件按站名回退坐标。
{
"stationName": "祁门南",
"latitude": 29.834343,
"longtitude": 117.6942173
}stationNamestring 必填 车站名,按线路查询返回的站名字面值匹配。
latitudenumber 必填 纬度。
longtitudenumber 必填 经度。字段名保持为 longtitude 以兼容现有文件。
- 文件格式为 JSONL,每行一个 JSON 对象。
- 管理员页面“配置文件”支持对该文件执行本地重载和远程刷新。
- 重载或刷新后,只会影响后续线路时刻表下载或线路刷新任务;不会自动回填当前已有的 schedule.json。
- 如果同名车站在文件中出现多次,当前实现会取第一条记录,并在日志中输出告警;部署时应尽量避免同名冲突。
qrcode-detection
qrcode_detection.json
为固定车组畅行码检测配置每日派发时间和目标车组列表。
固定车组畅行码检测计划文件默认建议放在 data/qrcode_detection.json,实际路径由 data/config.json 中的 data.assets.qrcodeDetection.file 决定。加载或刷新该文件时,会同时结合本地动车组配属清单和畅行码映射做校验。
{
"$schema": "../assets/json/qrcodeDetectionScheme.json",
"detectedAt": ["0630", "0830", "1030"],
"emu": ["CR400AF-AS-1106", "CR400AF-AS-1107"]
}$schemastring可选的 schema 引用,建议填写 ../assets/json/qrcodeDetectionScheme.json。
detectedAtarray<string(HHmm)> 必填 每日派发时间列表。每个 HHmm 都会保持一条未来待执行的派发任务。
emuarray<string> 必填 每个检测时间都要执行的车组编号列表。加载时会校验这些车组是否存在于 EMUList 中,并检查畅行码映射是否缺失。
- 文件路径不再写死,实际使用 data.assets.qrcodeDetection.file。
- 管理员页面“配置文件”支持对该文件执行本地重载和远程刷新。
- 重载或刷新该文件后,会重新校验内容并同步未来的 dispatch_qrcode_detection_tasks 派发任务。
- 重载或刷新 EMUList、QRCode 资产后,也会重新校验本文件并同步这些未来派发任务。
train-style-mapping
train_style_mapping.json
为 referenceModels 的 schedule 回退提供 trainStyle 到规范车型名的映射。
车型映射文件默认建议放在 data/train_style_mapping.json,实际路径由 data/config.json 中的 data.assets.trainStyleMapping.file 决定。仅当 referenceModels 在历史窗口内完全没有命中任何运行记录、需要从 schedule.json 的 trainStyle 回退时,系统才会读取本文件做映射。
{
"$schema": "../assets/json/trainStyleMappingScheme.json",
"CR400AF_578": "CR400AF",
"CR400AF-A": "CR400AF-A",
"ZL200J": "LCR200J3-B"
}$schemastring可选的 schema 引用,建议填写 ../assets/json/trainStyleMappingScheme.json。
<trainStyle>string 必填 键是 schedule.json 中保存的原始 trainStyle,值是期望输出到 referenceModels 的规范车型名。
- 文件格式为单个 JSON 对象,除 $schema 外其余键值都必须是非空字符串。
- 管理员页面“配置文件”支持对该文件执行本地重载和远程刷新。
- 未命中映射时,系统会保留原始 trainStyle,并记录 warning 日志。
- 通过该文件回退得到的 referenceModels 会返回 weightedShare=0,表示它不是历史推断结果。
operations
运维建议
把 data 目录视为需要持久化和备份的运行时数据目录。
更新代码前建议先停服务、备份 data 目录和当前生效的配置文件,再替换代码、重新构建并启动。
请确保 data.runtime.adminTraffic.file 与 data.runtime.adminServerMetrics.file 的父目录可写;管理员流量统计按 30 分钟默认周期落盘,服务器监控按 10 分钟默认周期落盘并每 60 秒采样一次,同时持久化 SSR/API 时长与路径级延迟聚合。
当前运行时统计文件按单实例设计;如果部署多实例,请避免多个进程同时写同一份 runtime 文件。