diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..102ac42 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Local defaults. Railway should override HOST, PORT, and QUICKLEARN_DATA_DIR. +HOST=127.0.0.1 +PORT=4173 +QUICKLEARN_DATA_DIR=.data diff --git a/CHANGELOG.md b/CHANGELOG.md index 730ff51..030b06a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. The format - Consent-based, minimal private lead storage with validation, anti-spam controls, and no automatic charge. - Identity-free offer-view and form-open events with a local conversion report. - A local paid-sale ledger command so reports distinguish purchase intent from confirmed revenue. +- Railway deployment configuration with public binding, health checks, persistent commercial data, and post-deploy smoke instructions. ## [1.0.0] - 2026-08-11 diff --git a/README.md b/README.md index 0576511..dbbdad9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ npm run ci 当前轻量版的内置主题、澄清和追问使用本地知识与规则,未知名词通过中文维基百科补充公开资料,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze` 和 `/api/ask` 后接模型服务。 +## Railway 部署 + +仓库内的 `railway.json` 使用 Railpack 构建、`npm start` 启动和 `/health` 健康检查。部署时设置 `HOST=0.0.0.0`、`QUICKLEARN_DATA_DIR=/data`,并把私有持久化 Volume 挂载到 `/data`;公网监听缺少持久化目录变量时健康检查和商业 API 会返回 `503`。完整配置、验证、备份和回滚步骤见 [docs/RAILWAY.md](docs/RAILWAY.md)。 + ## 首批付费验证 成功分析后会显示定制学习包入口,商品为学习路线、10 张闪卡、5 道带解释的测验和一个实战任务,早期验证价为 `¥29/份`。用户提交主题、目标和联系方式后,由运营者在 24 小时内人工确认范围与付款方式;系统本身不会收款。 diff --git a/docs/RAILWAY.md b/docs/RAILWAY.md new file mode 100644 index 0000000..e253c71 --- /dev/null +++ b/docs/RAILWAY.md @@ -0,0 +1,48 @@ +# Railway 部署 + +## 费用与选择 + +2026-08-12 核对 Railway 官方价格页:Free 计划在 30 天 `$5` 额度试用后为 `$1/月`,提供 `0.5 GB` Volume;Hobby 为 `$5/月` 最低用量,包含等额月度用量额度并提供最高 `5 GB` 存储。首批验证使用 Free 和 Railway 赠送域名,不购买自定义域名;出现真实付款后再评估 Hobby。 + +价格和套餐可能变化,开通前以 [Railway Pricing](https://railway.com/pricing) 为准。 + +## 首次部署 + +1. 在 Railway 登录并选择 `Deploy from GitHub repo`,授权 `fly1d/quicklearn-agent`。 +2. 确认部署来源为 `main`,构建配置读取仓库根目录的 `railway.json`。 +3. 给 Web Service 添加 Volume,挂载路径必须为 `/data`。 +4. 设置变量 `HOST=0.0.0.0` 和 `QUICKLEARN_DATA_DIR=/data`。 +5. 生成 Railway Domain,等待 `/health` 返回 `{"status":"ok"}`。 +6. 在 Railway 设置用量告警和硬限制,避免超出验证预算。 + +`PORT` 由 Railway 自动注入,不要写死。`.env.example` 只表示本地默认值,不应上传任何部署令牌或联系方式。 + +Volume 和环境变量完成前,不提交真实联系方式。Railway 的 redeploy 会保留 Volume,但移除 Volume 或删除项目会删除运行数据。 + +应用在非本机地址监听时会强制要求 `QUICKLEARN_DATA_DIR`:缺少该变量时 `/health` 返回 `503`,购买意向和漏斗事件也拒绝写入,Railway 不会把可能丢数据的版本标记为健康。这个检查不能证明 Volume 已正确挂载,因此上线 Smoke 仍必须执行重启后持久化验证。 + +## 上线 Smoke + +把公网地址代入下面命令: + +```bash +QUICKLEARN_URL=https://your-service.up.railway.app npm run deploy:smoke +``` + +该检查只执行免费分析、健康检查和敏感路径拦截,不写入购买意向。随后手工登记一条明确标记为测试的意向,重启服务,确认记录仍存在后立即删除测试记录。 + +## 数据查看与备份 + +使用 Railway Shell 运行: + +```bash +npm run commercial:report +``` + +每天有新意向时导出 `/data` 三个 JSONL 文件到只有运营者能访问的加密存储。备份包含联系方式,不要上传 GitHub、聊天群或公开网盘。按月测试一次恢复:在临时私有目录解压备份并运行报表,确认记录可解析。 + +不要依赖 Railway Deploy Logs 作为线索备份,应用不会把联系方式写入日志。若使用 Railway Volume Backup 功能,也要按套餐页面确认当前是否可用以及保留期。 + +## 回滚 + +代码故障优先在 Railway Deployment 历史中回滚到上一成功版本,Volume 不做回滚。若停止服务,先导出履约仍需要的数据,再关闭公网域名;履约完成或用户要求删除后,清理对应记录。需要迁移平台时,先复制 Volume 数据并校验,再切换域名。 diff --git a/package.json b/package.json index 23735d7..f6e4ad7 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,13 @@ "scripts": { "start": "node server.mjs", "dev": "node --watch server.mjs", - "lint": "node --check server.mjs && node --check url-fetch.mjs && node --check wikipedia.mjs && node --check app.js && node --check scripts/commercial-report.mjs && node --check scripts/commercial-mark-paid.mjs", + "lint": "node --check server.mjs && node --check url-fetch.mjs && node --check wikipedia.mjs && node --check app.js && node --check scripts/commercial-report.mjs && node --check scripts/commercial-mark-paid.mjs && node --check scripts/deploy-smoke.mjs", "test": "node --test test/*.test.mjs", "smoke": "npm test", "ci": "npm run lint && npm run smoke", "commercial:report": "node scripts/commercial-report.mjs", - "commercial:mark-paid": "node scripts/commercial-mark-paid.mjs" + "commercial:mark-paid": "node scripts/commercial-mark-paid.mjs", + "deploy:smoke": "node scripts/deploy-smoke.mjs" }, "engines": { "node": ">=18" diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..9cab3e4 --- /dev/null +++ b/railway.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "RAILPACK" + }, + "deploy": { + "startCommand": "npm start", + "healthcheckPath": "/health", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 5 + } +} diff --git a/scripts/commercial-mark-paid.mjs b/scripts/commercial-mark-paid.mjs index e927f0a..23da386 100644 --- a/scripts/commercial-mark-paid.mjs +++ b/scripts/commercial-mark-paid.mjs @@ -2,8 +2,9 @@ import { appendFile, chmod, mkdir, readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; const reference = process.argv[2]?.trim().toUpperCase(); -const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(process.cwd(), ".data", "paid-pack-interests.jsonl"); -const saleFile = process.env.QUICKLEARN_SALE_FILE || join(process.cwd(), ".data", "paid-pack-sales.jsonl"); +const dataDirectory = process.env.QUICKLEARN_DATA_DIR || join(process.cwd(), ".data"); +const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(dataDirectory, "paid-pack-interests.jsonl"); +const saleFile = process.env.QUICKLEARN_SALE_FILE || join(dataDirectory, "paid-pack-sales.jsonl"); if (!/^QL-[A-F0-9]{8}$/.test(reference || "")) { console.error("用法: npm run commercial:mark-paid -- QL-XXXXXXXX"); diff --git a/scripts/commercial-report.mjs b/scripts/commercial-report.mjs index 43c9ae7..dab6af8 100644 --- a/scripts/commercial-report.mjs +++ b/scripts/commercial-report.mjs @@ -1,9 +1,10 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; -const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(process.cwd(), ".data", "paid-pack-interests.jsonl"); -const eventFile = process.env.QUICKLEARN_EVENT_FILE || join(process.cwd(), ".data", "paid-pack-events.jsonl"); -const saleFile = process.env.QUICKLEARN_SALE_FILE || join(process.cwd(), ".data", "paid-pack-sales.jsonl"); +const dataDirectory = process.env.QUICKLEARN_DATA_DIR || join(process.cwd(), ".data"); +const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(dataDirectory, "paid-pack-interests.jsonl"); +const eventFile = process.env.QUICKLEARN_EVENT_FILE || join(dataDirectory, "paid-pack-events.jsonl"); +const saleFile = process.env.QUICKLEARN_SALE_FILE || join(dataDirectory, "paid-pack-sales.jsonl"); async function readJsonLines(path) { try { diff --git a/scripts/deploy-smoke.mjs b/scripts/deploy-smoke.mjs new file mode 100644 index 0000000..af61f93 --- /dev/null +++ b/scripts/deploy-smoke.mjs @@ -0,0 +1,26 @@ +const baseUrl = process.env.QUICKLEARN_URL?.replace(/\/$/, ""); +const parsedUrl = baseUrl ? new URL(baseUrl) : null; +const isLocal = parsedUrl && ["127.0.0.1", "localhost", "[::1]"].includes(parsedUrl.hostname); + +if (!parsedUrl || (parsedUrl.protocol !== "https:" && !(isLocal && parsedUrl.protocol === "http:"))) { + console.error("请通过 QUICKLEARN_URL 提供 HTTPS 公网地址;HTTP 仅允许本机 Smoke"); + process.exitCode = 1; +} else { + const health = await fetch(`${baseUrl}/health`); + if (!health.ok || (await health.json()).status !== "ok") throw new Error("健康检查失败"); + + const analysis = await fetch(`${baseUrl}/api/analyze`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: "MCP" }) + }); + const result = await analysis.json(); + if (!analysis.ok || result.title !== "MCP") throw new Error("公网分析失败"); + + for (const path of ["/.data/paid-pack-interests.jsonl", "/server.mjs", "/.git/config"]) { + const response = await fetch(`${baseUrl}${path}`); + if (response.status !== 404) throw new Error(`敏感路径未被拦截: ${path}`); + } + + console.log(`公网 Smoke 通过: ${baseUrl}`); +} diff --git a/server.mjs b/server.mjs index 74758f4..beae25c 100644 --- a/server.mjs +++ b/server.mjs @@ -8,8 +8,12 @@ import { lookupWikipedia } from "./wikipedia.mjs"; const root = fileURLToPath(new URL(".", import.meta.url)); const port = Number(process.env.PORT || 4173); -const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(root, ".data", "paid-pack-interests.jsonl"); -const commercialEventFile = process.env.QUICKLEARN_EVENT_FILE || join(root, ".data", "paid-pack-events.jsonl"); +const host = process.env.HOST || "127.0.0.1"; +const dataDirectory = process.env.QUICKLEARN_DATA_DIR || join(root, ".data"); +const publicBinding = !["127.0.0.1", "localhost", "::1"].includes(host); +const commercialStorageReady = !publicBinding || Boolean(process.env.QUICKLEARN_DATA_DIR); +const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(dataDirectory, "paid-pack-interests.jsonl"); +const commercialEventFile = process.env.QUICKLEARN_EVENT_FILE || join(dataDirectory, "paid-pack-events.jsonl"); let leadRequests = []; let commercialEventRequests = []; const leadRateWindowMs = 10 * 60 * 1000; @@ -430,6 +434,7 @@ function withinRateLimit(requests, limit) { } async function handlePaidPackInterest(req, res) { + if (!commercialStorageReady) return sendJson(res, 503, { error: "商业数据存储尚未配置" }); let body = ""; for await (const chunk of req) { body += chunk; @@ -480,6 +485,7 @@ async function handlePaidPackInterest(req, res) { } async function handleCommercialEvent(req, res) { + if (!commercialStorageReady) return sendJson(res, 503, { error: "商业数据存储尚未配置" }); let body = ""; for await (const chunk of req) { body += chunk; @@ -503,6 +509,11 @@ async function handleCommercialEvent(req, res) { } const server = http.createServer(async (req, res) => { + if (req.method === "GET" && req.url === "/health") { + return commercialStorageReady + ? sendJson(res, 200, { status: "ok" }) + : sendJson(res, 503, { status: "not_ready" }); + } if (req.method === "POST" && req.url === "/api/analyze") return handleAnalyze(req, res); if (req.method === "POST" && req.url === "/api/ask") return handleAsk(req, res); if (req.method === "POST" && req.url === "/api/paid-pack-interest") return handlePaidPackInterest(req, res); @@ -527,6 +538,21 @@ const server = http.createServer(async (req, res) => { } }); -server.listen(port, "127.0.0.1", () => { - console.log(`QuickLearn is running at http://127.0.0.1:${port}`); +server.listen(port, host, () => { + console.log(`QuickLearn is running at http://${host}:${port}`); }); + +function shutdown(signal) { + console.log(`${signal} received, closing HTTP server`); + server.close((error) => { + if (error) { + console.error("Unable to close HTTP server", error); + process.exit(1); + } + }); + server.closeIdleConnections?.(); + setTimeout(() => server.closeAllConnections?.(), 7_500).unref(); +} + +process.once("SIGTERM", () => shutdown("SIGTERM")); +process.once("SIGINT", () => shutdown("SIGINT")); diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index f49983f..0d7d52d 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -7,6 +7,7 @@ import { join } from "node:path"; const port = 43000 + (process.pid % 1000); const baseUrl = `http://127.0.0.1:${port}`; +const unconfiguredPort = port + 1000; let server; let testDirectory; let leadFile; @@ -42,7 +43,7 @@ before(async () => { commercialEventFile = join(testDirectory, "paid-pack-events.jsonl"); server = spawn(process.execPath, ["server.mjs"], { cwd: new URL("..", import.meta.url), - env: { ...process.env, PORT: String(port), QUICKLEARN_LEAD_FILE: leadFile, QUICKLEARN_EVENT_FILE: commercialEventFile }, + env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), QUICKLEARN_LEAD_FILE: leadFile, QUICKLEARN_EVENT_FILE: commercialEventFile }, stdio: ["ignore", "pipe", "pipe"] }); await waitForServer(); @@ -62,6 +63,43 @@ test("serves the compact learning window", async () => { assert.match(html, /id="followUpForm"/); }); +test("serves a minimal deployment health check", async () => { + const response = await fetch(`${baseUrl}/health`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { status: "ok" }); +}); + +test("rejects public deployment without persistent commercial storage", async () => { + const unconfiguredServer = spawn(process.execPath, ["server.mjs"], { + cwd: new URL("..", import.meta.url), + env: { ...process.env, HOST: "0.0.0.0", PORT: String(unconfiguredPort), QUICKLEARN_DATA_DIR: "" }, + stdio: ["ignore", "pipe", "pipe"] + }); + try { + let health; + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + health = await fetch(`http://127.0.0.1:${unconfiguredPort}/health`); + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + assert.equal(health?.status, 503); + assert.deepEqual(await health.json(), { status: "not_ready" }); + + const lead = await fetch(`http://127.0.0.1:${unconfiguredPort}/api/paid-pack-interest`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ topic: "MCP", goal: "完成部署验证", contact: "test@example.com", consent: true }) + }); + assert.equal(lead.status, 503); + assert.match((await lead.json()).error, /存储/); + } finally { + if (!unconfiguredServer.killed) unconfiguredServer.kill("SIGTERM"); + } +}); + test("analyzes a precise term", async () => { const result = await post("/api/analyze", { input: "MCP" }); assert.equal(result.needsClarification, undefined);