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

以生成构建。

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 进程。

完整示例:data/config.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
            }
        },
        "scheduleProbe": {
            "dailyTimeHHmm": "0000",
            "retryAttempts": 3,
            "maxBatchSize": 200,
            "checkpointFlushEvery": 20,
            "refresh": {
                "batchSize": 20,
                "ttlHours": 24,
                "generateIntervalHours": 24
            },
            "probe": {
                "defaultRetry": 5,
                "overlapRetryDelaySeconds": 3600
            },
            "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.jsonl",
                "provider": "https://storage.lihugang.top/open_crh_tracker/initialized_data/emu_list.jsonl",
                "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"
                }
            },
            "schedule": {
                "file": "data/schedule.json",
                "provider": "https://storage.lihugang.top/open_crh_tracker/initialized_data/schedule.json"
            }
        },
        "databases": {
            "task": "data/task.db",
            "EMUTracked": "data/emu.db",
            "users": "data/users.db",
            "feedback": "data/feedback.db"
        },
        "runtime": {
            "adminTraffic": {
                "file": "data/runtime/admin-traffic.json",
                "flushIntervalMinutes": 30
            },
            "adminServerMetrics": {
                "file": "data/runtime/admin-server-metrics.json",
                "flushIntervalMinutes": 10,
                "sampleIntervalSeconds": 60
            },
            "requestMetrics12306": {
                "file": "data/runtime/12306-request-metrics.json",
                "retentionDays": 3,
                "flushIntervalMinutes": 10
            }
        }
    },
    "user": {
        "saltLength": 16,
        "apiKeyPrefixes": {
            "webapp": "ocrh_webapp_",
            "api": "ocrh_api_"
        },
        "apiKeyBytes": 24,
        "apiKeyTtlSeconds": 2592000,
        "apiKeyMaxLifetimeSeconds": 157680000,
        "apiKeyNameLength": {
            "minLength": 1,
            "maxLength": 64
        },
        "adminUserIds": [
            "admin-user-id"
        ],
        "favorites": {
            "maxEntries": 10
        },
        "pushSubscriptions": {
            "maxDevices": 5,
            "maxEventSubscriptions": 50,
            "syncTimeoutSeconds": 30
        },
        "push": {
            "vapidPublicKey": "replace-with-base64url-vapid-public-key",
            "vapidPrivateKey": "replace-with-base64url-vapid-private-key",
            "vapidEmail": "[email protected]"
        },
        "signKey": "replace-with-strong-random-secret",
        "scrypt": {
            "keyLength": 64,
            "cost": 16384,
            "blockSize": 8,
            "parallelization": 1
        }
    },
    "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
            }
        },
        "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.read",
                "api.timetable.station.read",
                "api.exports.daily.read",
                "api.feedback.read",
                "api.feedback.create"
            ],
            "issuedKeyDefaultScopes": [
                "api.config.read",
                "api.search.read",
                "api.records.daily.read",
                "api.history.train.read",
                "api.history.emu.read",
                "api.timetable.train.read",
                "api.timetable.station.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.api-keys.read",
                "api.auth.api-keys.create",
                "api.auth.api-keys.revoke",
                "api.auth.favorites.read",
                "api.auth.favorites.write",
                "api.auth.subscriptions.read",
                "api.auth.subscriptions.write"
            ],
            "creatableKeyMaxScopes": [
                "api.auth.me.read",
                "api.records.daily.read",
                "api.history.train.read",
                "api.history.emu.read",
                "api.timetable.train.read",
                "api.timetable.station.read",
                "api.exports.daily.read"
            ]
        }
    },
    "task": {
        "startup": {
            "disabledExecutors": []
        },
        "apiKeyCleanup": {
            "retentionDays": 7,
            "dailyTimeHHmm": "0000"
        },
        "dailyExport": {
            "dailyTimeHHmm": "0000"
        },
        "referenceModel": {
            "windowDays": 14,
            "batchSize": 1000,
            "threshold": 0.3,
            "dailyTimesHHmm": ["0300", "0900", "1500", "2100"]
        },
        "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,
            "authChangePassword": 5,
            "debugEchoError": 0,
            "authIssueApiKey": 5,
            "authListApiKeys": 1,
            "authRevokeApiKey": 1,
            "searchIndex": 1,
            "timetableTrain": 1,
            "exportDailyIndex": 2,
            "exportDaily": 50
        },
        "perRecord": {
            "historyEmu": {
                "unitCost": 0.05,
                "rounding": "ceil"
            },
            "historyTrain": {
                "unitCost": 0.05,
                "rounding": "ceil"
            },
            "recordsDaily": {
                "unitCost": 0.05,
                "rounding": "ceil"
            },
            "timetableStation": {
                "unitCost": 0.05,
                "rounding": "ceil"
            }
        }
    }
}

spider:抓取 12306 数据

这一组决定 12306 抓取行为和探测频率。

spider.userAgentstring 必填

抓取请求使用的 User-Agent。

  • 建议保持一个稳定、能被上游接受的移动端 UA。
spider.paramsobject 必填

抓取接口固定参数,包含 eKey、jsonpCallback 和 routeProbeCarCode。

spider.rateLimit.query.minIntervalMsinteger 必填

查询车次、车组号和畅行码接口的最小调用间隔,单位毫秒。

spider.rateLimit.search.minIntervalMsinteger 必填

通过 12306 搜索接口检索启用车次号的最小调用间隔,单位毫秒。

spider.scheduleProbe.dailyTimeHHmmstring(HHmm) 必填

每天生成车次探测任务的时间点,北京时间。

spider.scheduleProbe.retryAttempts / maxBatchSize / checkpointFlushEveryinteger 必填

控制车次探测任务的失败重试次数、单批处理上限和检查点落盘频率。

spider.scheduleProbe.refreshobject 必填

控制时刻表刷新任务的批次、TTL 和生成间隔。

spider.scheduleProbe.probeobject 必填

探测重试次数与 12306 接口数据异常的重试策略。

spider.scheduleProbe.couplingobject 必填

担当关系探测的状态重置、延迟和冷却参数。

  • statusResetTimeHHmm 也必须是 HHmm 字符串。
spider.scheduleProbe.prefixRulesarray<object> 必填

允许探测的车次前缀与号段范围。

  • prefix 必须是大写字母。
  • 同一 prefix 的号段不能重叠。
  • 数组不能为空。

data:数据库与静态资产文件

这一组决定数据库文件、schedule 文件和初始化资产的实际落盘位置。

data.assets.EMUListobject 必填

EMU 列表文件路径、下载地址与刷新时间。

  • refresh.enabled=true 时 provider 必填。
  • refresh.refreshAt 必须是 HHmm 字符串。
data.assets.QRCodeobject 必填

铁路畅行码资产文件路径、下载地址与刷新时间。

  • 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.runtime:运行时统计文件

这一组决定管理员流量统计、服务器监控与 12306 请求计数的落盘位置、保留天数和定时写盘间隔。

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 时长与路径级延迟聚合仍按请求完成时实时入桶。
data.runtime.requestMetrics12306.filestring 必填

12306 请求计数文件路径。

  • 管理员被动告警页的请求曲线直接读取该文件恢复的数据。
  • 12306 请求计数不再通过日志回放恢复。
data.runtime.requestMetrics12306.retentionDaysinteger 必填

12306 请求计数保留天数,默认值为 3。

  • 系统只保留最近 N 天的半小时桶。
  • 超出保留窗口后,管理员页不再提供对应日期的请求曲线。
data.runtime.requestMetrics12306.flushIntervalMinutesinteger 必填

12306 请求计数的定时落盘间隔,单位分钟,默认值为 10。

  • 间隔越短,异常崩溃时潜在丢失的数据窗口越小。

user:用户与 API Key 安全参数

这一组决定密码派生策略、API Key 生命周期和登录签名密钥。

user.saltLengthinteger 必填

密码盐长度。

user.apiKeyPrefixesobject 必填

分别定义 webapp 和 API Key 的前缀。

  • 新部署请使用 webapp/api 两个前缀。
  • 两个前缀不能重复。
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。

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 必填

登录与注册接口的限流配置。

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/* 和 /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.read。
  • 如果要公开车站页时刻表接口,需要包含 api.timetable.station.read。
api.permissions.issuedKeyDefaultScopesarray<string> 必填

新签发 API Key 的默认权限集合。

  • 如需默认允许当前车次时刻表接口,请包含 api.timetable.train.read。
  • 如需默认允许车站时刻表接口,请包含 api.timetable.station.read。
  • 如需让 Web 端收藏功能开箱即用,请包含 api.auth.favorites.read 和 api.auth.favorites.write。
api.permissions.creatableKeyMaxScopesarray<string> 必填

前端可签发 API Key 时允许选择的最大权限范围。

  • 若希望外部调用方可勾选当前车次时刻表接口,这里也要包含 api.timetable.train.read。
  • 若希望外部调用方可勾选车站时刻表接口,这里也要包含 api.timetable.station.read。

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。
  • 为空数组表示全部启用。
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。
  • dailyTimesHHmm 用于手动填写多个 HHmm 时刻,控制 rebuild_reference_model_index 在一天内多次重建。
task.scheduler.pollIntervalMsinteger 必填

调度器轮询间隔,单位毫秒。

task.scheduler.maxTasksPerQueryinteger 必填

单次查询最多取回的任务数。

task.scheduler.idle.maxTasksPerTickinteger 必填

空闲模式下每个 tick 最多拉起的任务数。

task.scheduler.idle.emaAlphanumber 必填

空闲调度使用的 EMA 系数。

  • 必须大于 0 且小于等于 1。

logging:日志文件

这一组决定 logs/ 目录下按日滚动日志的保留时间;12306 请求计数已独立落盘,不再写入日志。

logging.retentionDaysinteger 必填

应用日志的总保留天数,默认值为 5。

  • 保留天数包含当前当天正在写入的日志文件。
  • 日志文件位于 logs/ 目录,可按运维策略另行备份。
  • 12306 请求计数不会写入日志,日志中仅继续保留 warning、error 等异常信息。

quota:额度与补充策略

这一组决定匿名用户和登录用户的额度桶大小,以及额度恢复方式。

quota.anonymousMaxTokensinteger 必填

匿名访问的额度桶上限。

quota.userMaxTokensinteger 必填

登录用户的额度桶上限。

quota.refillAmountinteger 必填

每个补充周期恢复的额度数量。

quota.refillIntervalSecondsinteger 必填

额度补充周期,单位秒。

quota.resetToMaxOnRestartboolean 必填

服务重启后是否把额度恢复到上限。

quota.consumeTokensboolean 必填

是否实际扣减额度。

  • 关闭后可以保留成本计算,但不会真正消耗额度。

cost:接口耗额策略

这一组决定每个接口、以及按记录数量计费接口的额度成本。

cost.fixed.health / authMe / authLogout / debugEchoErrorinteger 必填

健康检查、身份相关和调试接口的固定成本。

cost.fixed.authChangePassword / authIssueApiKey / authListApiKeys / authRevokeApiKeyinteger 必填

API Key 管理接口的固定成本。

cost.fixed.searchIndex / timetableTrain / exportDailyIndex / exportDailyinteger 必填

搜索、当前时刻表与导出接口的固定成本。

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 设备验证推送是否正常。
  • 提高 api.permissions.anonymousScopes、quota 或 cost 前,先确认你准备公开暴露的接口范围和限额策略。
  • 修改 spider.scheduleProbe.prefixRules、task.referenceModel.dailyTimesHHmm 或 task.scheduler 参数后,重启后应观察首轮任务执行是否符合预期。

run

启动服务

构建完成和确认配置文件无误后,可启动服务。

请先设置登录签名秘钥,该秘钥将被用于签名用户的 token,切勿泄露。

export OCRH_SIGN_KEY=<xxxxxxxx>

如果需要启用 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>

然后设置服务器监听端口,默认为 3000

export NITRO_PORT=<port>

启动服务

nohup node .output/server/index.mjs>log.log 2>&1 &

应用会在 logs/ 目录下按天滚动写入日志,同时按 data.runtime 配置把管理员流量统计、服务器监控统计和 12306 请求计数分别写入独立文件。12306 请求计数不再进入日志。

operations

运维建议

把 data 目录视为需要持久化和备份的运行时数据目录。

更新代码前建议先停服务、备份 data 目录和当前生效的配置文件,再替换代码、重新构建并启动。

请确保 data.runtime.adminTraffic.file、data.runtime.adminServerMetrics.file 与 data.runtime.requestMetrics12306.file 的父目录可写;管理员流量统计按 30 分钟默认周期落盘,服务器监控按 10 分钟默认周期落盘并每 60 秒采样一次,同时持久化 SSR/API 时长与路径级延迟聚合,12306 请求计数按 10 分钟默认周期落盘并按 retentionDays 自动裁剪。

当前运行时统计文件按单实例设计;如果部署多实例,请避免多个进程同时写同一份 runtime 文件。