Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 小时内人工确认范围与付款方式;系统本身不会收款。
Expand Down
48 changes: 48 additions & 0 deletions docs/RAILWAY.md
Original file line number Diff line number Diff line change
@@ -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 数据并校验,再切换域名。
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions railway.json
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 3 additions & 2 deletions scripts/commercial-mark-paid.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 4 additions & 3 deletions scripts/commercial-report.mjs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions scripts/deploy-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
34 changes: 30 additions & 4 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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"));
40 changes: 39 additions & 1 deletion test/smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down