DEPLOY

部署 Open CRH Tracker

DEPLOY

要求服务器已安装 Node.js 20 以上版本及 pnpm 10 以上版本。

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 下的配置文件,并校验配置合法性。

在修改 config.json 前,请确保你已经阅读了《数据抓取流程》并了解 Open CRH Tracker 的工作原理。

配置文件加载规则

  • 开发环境优先读取 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 文件。