From fcf58e148d4e37bd4bb36f886e0e0848773db048 Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:07:49 +0800 Subject: [PATCH] feat: validate paid custom learning packs --- .gitignore | 1 + CHANGELOG.md | 7 +++ README.md | 26 ++++++++ app.js | 101 +++++++++++++++++++++++++++++ docs/FIRST-SALE.md | 40 ++++++++++++ package.json | 6 +- scripts/commercial-mark-paid.mjs | 35 +++++++++++ scripts/commercial-report.mjs | 37 +++++++++++ server.mjs | 105 ++++++++++++++++++++++++++++++- styles.css | 40 +++++++++++- test/smoke.test.mjs | 71 ++++++++++++++++++++- 11 files changed, 462 insertions(+), 7 deletions(-) create mode 100644 docs/FIRST-SALE.md create mode 100644 scripts/commercial-mark-paid.mjs create mode 100644 scripts/commercial-report.mjs diff --git a/.gitignore b/.gitignore index b4cf725..4d746df 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules/ coverage/ test-results/ playwright-report/ +.data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4447b74..730ff51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +### Added + +- A CNY 29 concierge learning-pack offer for validating purchase intent before building automated billing. +- 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. + ## [1.0.0] - 2026-08-11 ### Added diff --git a/README.md b/README.md index 6ea674d..0576511 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - 公开网页提取标题、摘要、章节与正文要点。 - 围绕当前主题继续询问用法、示例和误区;百科主题优先依据已取得的来源摘录回答。 - 使用闪卡复习关键概念,并通过一道即时反馈的小测验巩固理解。 +- 用 `¥29/份` 的首批定制学习包验证真实付费意愿,提交只登记意向,不会自动扣款。 - 浮窗可以隐藏为右下角入口并随时恢复。 ## 运行 @@ -36,3 +37,28 @@ npm run ci - 希望跨应用悬浮、置顶、托盘常驻或用全局快捷键唤起:需要桌面客户端,建议使用 Tauri 封装当前界面。 当前轻量版的内置主题、澄清和追问使用本地知识与规则,未知名词通过中文维基百科补充公开资料,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze` 和 `/api/ask` 后接模型服务。 + +## 首批付费验证 + +成功分析后会显示定制学习包入口,商品为学习路线、10 张闪卡、5 道带解释的测验和一个实战任务,早期验证价为 `¥29/份`。用户提交主题、目标和联系方式后,由运营者在 24 小时内人工确认范围与付款方式;系统本身不会收款。 + +购买意向默认保存在未纳入 Git 的 `.data/paid-pack-interests.jsonl`,也可以通过 `QUICKLEARN_LEAD_FILE` 指定私有持久化路径。记录只包含编号、商品版本、价格、主题、目标、联系方式和提交时间,不保存 IP、User-Agent、网页正文、来源摘录或完整分析上下文。运营者应限制文件访问,仅为履约联系使用,并在意向失效或用户要求时删除对应记录。 + +HTTP 服务只公开界面所需的三个静态文件,不允许通过 URL 读取 `.data`、服务端源码或 Git 元数据。正式部署仍应把 `QUICKLEARN_LEAD_FILE` 和 `QUICKLEARN_EVENT_FILE` 指向有备份且不在公开目录中的私有持久化卷。 + +商品展示与表单打开事件保存在 `.data/paid-pack-events.jsonl`,每条只有事件名和时间,不使用 Cookie 或身份标识。查看漏斗和待跟进意向: + +```bash +npm run commercial:report +``` + +确认实际到账后,使用意向编号记录首笔收入,再查看更新后的转化漏斗: + +```bash +npm run commercial:mark-paid -- QL-XXXXXXXX +npm run commercial:report +``` + +付款记录默认写入 `.data/paid-pack-sales.jsonl`,只包含意向编号、商品版本、金额和到账时间,不重复保存联系方式。 + +首批用户获取、人工收款、交付和验证阈值见 [docs/FIRST-SALE.md](docs/FIRST-SALE.md)。 diff --git a/app.js b/app.js index 7ab0f58..4474ece 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,7 @@ const state = { current: null, lastInput: "", asking: false, + offerSubmitting: false, study: { mode: null, cardIndex: 0, cardRevealed: false, quizAnswer: null } }; @@ -23,6 +24,15 @@ function showToast(message) { showToast.timer = setTimeout(() => toast.classList.remove("show"), 1900); } +function recordCommercialEvent(event) { + fetch("/api/commercial-event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event }), + keepalive: true + }).catch(() => {}); +} + function showView(name) { $("#startView").hidden = name !== "start"; $("#loadingView").hidden = name !== "loading"; @@ -35,6 +45,7 @@ function showView(name) { function reset() { state.current = null; state.lastInput = ""; + state.offerSubmitting = false; resetStudy(); $("#mainInput").value = ""; $("#conversation").innerHTML = ""; @@ -109,9 +120,18 @@ function renderSummary(result) { ${quickQuestions} ${studyActions ? `
${studyActions}
` : ""} + `; showView("conversation"); icons(); + recordCommercialEvent("offer_view"); } function resetStudy() { @@ -150,6 +170,7 @@ function showStudy(mode) { } function renderStudyPanel() { + $("#offerPanel")?.remove(); $("#studyPanel")?.remove(); if (!state.study.mode) return; @@ -208,6 +229,76 @@ function renderStudyPanel() { scrollToBottom(); } +function showOffer() { + if (!state.current) return; + recordCommercialEvent("offer_open"); + resetStudy(); + $("#studyPanel")?.remove(); + $("#offerPanel")?.remove(); + $("#conversation").insertAdjacentHTML("beforeend", ` +
+
+
定制学习包¥29 / 份
+ +
+
+ 24 小时内人工确认并交付 + +
+
+ + + + + + +

提交只登记意向,不会自动扣款;确认需求后再决定是否付款。

+
+
`); + icons(); + scrollToBottom(); +} + +async function submitOffer(form) { + if (state.offerSubmitting) return; + state.offerSubmitting = true; + const submit = form.querySelector("button[type='submit']"); + submit.disabled = true; + submit.querySelector("span").textContent = "正在登记"; + const data = new FormData(form); + try { + const response = await fetch("/api/paid-pack-interest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + topic: data.get("topic"), + goal: data.get("goal"), + contact: data.get("contact"), + website: data.get("website"), + consent: data.get("consent") === "on" + }) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "暂时无法登记"); + $("#offerPanel").innerHTML = ` +
+ + 购买意向已登记 +

编号 ${escapeHtml(result.reference)}。我们会通过你留下的联系方式确认范围和付款方式。

+ 本次没有自动扣款。 + +
`; + icons(); + } catch (error) { + showToast(error.message); + submit.disabled = false; + submit.querySelector("span").textContent = "登记购买意向"; + } finally { + state.offerSubmitting = false; + scrollToBottom(); + } +} + async function ask(question) { const value = question.trim(); if (!value || !state.current || state.asking) return; @@ -256,6 +347,12 @@ $("#followUpForm").addEventListener("submit", (event) => { ask($("#followUpInput").value); }); +document.addEventListener("submit", (event) => { + if (!event.target.matches("#offerForm")) return; + event.preventDefault(); + submitOffer(event.target); +}); + document.addEventListener("click", (event) => { const example = event.target.closest("[data-example]"); const clarify = event.target.closest("[data-clarify]"); @@ -266,6 +363,8 @@ document.addEventListener("click", (event) => { const cardNav = event.target.closest("[data-card-nav]"); const quizOption = event.target.closest("[data-quiz-option]"); const quizReset = event.target.closest("[data-quiz-reset]"); + const offerOpen = event.target.closest("[data-offer-open]"); + const offerClose = event.target.closest("[data-offer-close]"); if (example) analyze(example.dataset.example); if (clarify) analyze(clarify.dataset.clarify); if (question) ask(question.dataset.question); @@ -291,6 +390,8 @@ document.addEventListener("click", (event) => { state.study.quizAnswer = null; renderStudyPanel(); } + if (offerOpen) showOffer(); + if (offerClose) $("#offerPanel")?.remove(); }); $("#newButton").addEventListener("click", reset); diff --git a/docs/FIRST-SALE.md b/docs/FIRST-SALE.md new file mode 100644 index 0000000..ed101ce --- /dev/null +++ b/docs/FIRST-SALE.md @@ -0,0 +1,40 @@ +# 第一笔收入执行手册 + +## 当前商品 + +- 用户:正在快速学习 AI 工具或开发技术的中文开发者、独立开发者和转行学习者。 +- 场景:看过零散资料,但不知道先学什么、怎样确认自己已经会用。 +- 商品:围绕一个具体目标制作学习路线、10 张闪卡、5 道带解释的测验和 1 个实战任务。 +- 验证价:`¥29/份`,24 小时内人工确认和交付。 +- 边界:登记意向不等于付款;确认主题、目标和交付时间后,再通过双方认可的方式收款。 + +## 首批 20 位用户 + +不要先投广告。每天寻找 5 位正在公开提问 MCP、AI 智能体、React 或 Docker 入门问题的人,优先选择问题具体、近期正在实践的人。可以来自 GitHub Issue、技术社群或自己的真实联系人,但不要批量骚扰,也不要抓取私人联系方式。 + +首次沟通只发一条短消息: + +> 我在做一个“把技术主题变成可执行学习包”的小工具。你刚才提到想学「主题」,我可以先免费给你看 30 秒总结;如果方向对,再用 ¥29 做成针对你目标的路线、练习和测验。链接:你的公开访问地址 + +对没有回应的人不连续追发。对打开表单的人,先确认三个问题:要解决什么任务、已有基础、希望何时完成。范围确认后再收款。 + +## 24 小时交付 + +1. 用 `npm run commercial:report` 查看新意向并联系用户。 +2. 复述用户目标和交付清单,请对方确认。 +3. 通过双方认可的收款方式收取 `¥29`,保留付款凭证,并运行 `npm run commercial:mark-paid -- QL-XXXXXXXX` 记录到账。 +4. 制作一份短而具体的学习包,所有内容围绕用户目标。 +5. 交付后询问两个问题:哪一部分最有用,是否愿意以同样价格再买另一个主题。 +6. 在意向失效、履约结束不再需要,或用户要求时,删除其联系方式记录。 + +## 七天判断标准 + +每天记录商品展示、表单打开、购买意向、实际付款和按时交付。前 100 次有效商品展示后再判断: + +- 打开率低于 5%:商品名称或结果不够有吸引力,先改定位与文案。 +- 打开率至少 5%,意向率低于 15%:价格、信任或表单阻力有问题。 +- 至少 3 个有效意向但无人付款:先访谈,不接支付系统。 +- 出现 1 笔真实付款:完成第一桶金验证,继续手工卖到 10 单。 +- 完成 10 单且重复需求明显:再建设账号、自动支付和规模化生成。 + +不要把 GitHub Star、访问量或表单点击当收入。当前阶段唯一的核心结果是真实付款和完成交付。 diff --git a/package.json b/package.json index fe55368..23735d7 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,12 @@ "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", + "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", "test": "node --test test/*.test.mjs", "smoke": "npm test", - "ci": "npm run lint && npm run smoke" + "ci": "npm run lint && npm run smoke", + "commercial:report": "node scripts/commercial-report.mjs", + "commercial:mark-paid": "node scripts/commercial-mark-paid.mjs" }, "engines": { "node": ">=18" diff --git a/scripts/commercial-mark-paid.mjs b/scripts/commercial-mark-paid.mjs new file mode 100644 index 0000000..e927f0a --- /dev/null +++ b/scripts/commercial-mark-paid.mjs @@ -0,0 +1,35 @@ +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"); + +if (!/^QL-[A-F0-9]{8}$/.test(reference || "")) { + console.error("用法: npm run commercial:mark-paid -- QL-XXXXXXXX"); + process.exitCode = 1; +} else { + const readLines = async (path) => { + try { + return (await readFile(path, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line)); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + }; + const [leads, sales] = await Promise.all([readLines(leadFile), readLines(saleFile)]); + const lead = leads.find((item) => item.reference === reference); + if (!lead) { + console.error(`未找到意向编号 ${reference}`); + process.exitCode = 1; + } else if (sales.some((item) => item.reference === reference)) { + console.error(`${reference} 已标记付款`); + process.exitCode = 1; + } else { + const sale = { reference, offer: lead.offer, amountCny: lead.amountCny, paidAt: new Date().toISOString() }; + await mkdir(dirname(saleFile), { recursive: true, mode: 0o700 }); + await appendFile(saleFile, `${JSON.stringify(sale)}\n`, { encoding: "utf8", mode: 0o600 }); + await chmod(saleFile, 0o600); + console.log(`已记录 ${reference} 付款 ¥${sale.amountCny}`); + } +} diff --git a/scripts/commercial-report.mjs b/scripts/commercial-report.mjs new file mode 100644 index 0000000..43c9ae7 --- /dev/null +++ b/scripts/commercial-report.mjs @@ -0,0 +1,37 @@ +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"); + +async function readJsonLines(path) { + try { + return (await readFile(path, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } +} + +const [leads, events, sales] = await Promise.all([readJsonLines(leadFile), readJsonLines(eventFile), readJsonLines(saleFile)]); +const views = events.filter(({ event }) => event === "offer_view").length; +const opens = events.filter(({ event }) => event === "offer_open").length; +const revenue = sales.reduce((total, sale) => total + (Number(sale.amountCny) || 0), 0); +const percent = (value, total) => total ? `${((value / total) * 100).toFixed(1)}%` : "n/a"; + +console.log(`商品展示: ${views}`); +console.log(`打开表单: ${opens} (${percent(opens, views)})`); +console.log(`购买意向: ${leads.length} (${percent(leads.length, opens)})`); +console.log(`实际付款: ${sales.length} (${percent(sales.length, leads.length)})`); +console.log(`确认收入: ¥${revenue}`); + +if (leads.length) { + console.log("\n待跟进意向:"); + for (const lead of leads) { + console.log(`${lead.reference}\t${lead.createdAt}\t${lead.topic}\t${lead.contact}\t${lead.goal}`); + } +} diff --git a/server.mjs b/server.mjs index 2cfbee4..74758f4 100644 --- a/server.mjs +++ b/server.mjs @@ -1,12 +1,27 @@ import http from "node:http"; -import { readFile } from "node:fs/promises"; -import { extname, join, normalize } from "node:path"; +import { appendFile, chmod, mkdir, readFile } from "node:fs/promises"; +import { dirname, extname, join, normalize } from "node:path"; import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; import { fetchPage } from "./url-fetch.mjs"; 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"); +let leadRequests = []; +let commercialEventRequests = []; +const leadRateWindowMs = 10 * 60 * 1000; +const leadRateLimit = 30; +const commercialEvents = new Set(["offer_view", "offer_open"]); +const publicFiles = new Set(["/index.html", "/app.js", "/styles.css"]); + +function cleanLeadText(value) { + return typeof value === "string" + ? value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim() + : ""; +} const mimeTypes = { ".html": "text/html; charset=utf-8", @@ -407,12 +422,98 @@ async function handleAsk(req, res) { } } +function withinRateLimit(requests, limit) { + const now = Date.now(); + const recent = requests.filter((time) => now - time < leadRateWindowMs); + recent.push(now); + return { recent, limited: recent.length > limit }; +} + +async function handlePaidPackInterest(req, res) { + let body = ""; + for await (const chunk of req) { + body += chunk; + if (body.length > 5_000) return sendJson(res, 413, { error: "提交内容过长" }); + } + const leadRate = withinRateLimit(leadRequests, leadRateLimit); + leadRequests = leadRate.recent; + if (leadRate.limited) return sendJson(res, 429, { error: "提交过于频繁,请稍后再试" }); + + try { + const { topic, goal, contact, consent, website } = JSON.parse(body || "{}"); + if (typeof website === "string" && website.trim()) { + return sendJson(res, 201, { received: true, charged: false, reference: "QL-RECEIVED" }); + } + if (consent !== true) return sendJson(res, 400, { error: "请先同意我们使用联系方式确认定制需求" }); + const cleanTopic = cleanLeadText(topic); + const cleanGoal = cleanLeadText(goal); + const cleanContact = cleanLeadText(contact); + if (cleanTopic.length < 1 || cleanTopic.length > 160) { + return sendJson(res, 400, { error: "学习主题无效" }); + } + if (cleanGoal.length < 5 || cleanGoal.length > 600) { + return sendJson(res, 400, { error: "请用 5 到 600 个字符说明学习目标" }); + } + if (cleanContact.length < 3 || cleanContact.length > 120) { + return sendJson(res, 400, { error: "请填写可用的微信号、邮箱或手机号" }); + } + + const reference = `QL-${randomUUID().split("-")[0].toUpperCase()}`; + const record = { + reference, + offer: "custom-learning-pack-v1", + amountCny: 29, + topic: cleanTopic, + goal: cleanGoal, + contact: cleanContact, + createdAt: new Date().toISOString() + }; + await mkdir(dirname(leadFile), { recursive: true, mode: 0o700 }); + await appendFile(leadFile, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 }); + await chmod(leadFile, 0o600); + sendJson(res, 201, { received: true, charged: false, reference }); + } catch (error) { + if (error instanceof SyntaxError) return sendJson(res, 400, { error: "提交格式无效" }); + console.error("Unable to store paid-pack interest", error); + sendJson(res, 503, { error: "暂时无法登记,请稍后再试" }); + } +} + +async function handleCommercialEvent(req, res) { + let body = ""; + for await (const chunk of req) { + body += chunk; + if (body.length > 1_000) return sendJson(res, 413, { error: "事件内容过长" }); + } + const eventRate = withinRateLimit(commercialEventRequests, 2_000); + commercialEventRequests = eventRate.recent; + if (eventRate.limited) return sendJson(res, 204, {}); + + try { + const { event } = JSON.parse(body || "{}"); + if (!commercialEvents.has(event)) return sendJson(res, 400, { error: "未知事件" }); + await mkdir(dirname(commercialEventFile), { recursive: true, mode: 0o700 }); + await appendFile(commercialEventFile, `${JSON.stringify({ event, createdAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600 }); + await chmod(commercialEventFile, 0o600); + sendJson(res, 204, {}); + } catch (error) { + if (!(error instanceof SyntaxError)) console.error("Unable to store commercial event", error); + sendJson(res, error instanceof SyntaxError ? 400 : 503, { error: "事件记录失败" }); + } +} + const server = http.createServer(async (req, res) => { 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); + if (req.method === "POST" && req.url === "/api/commercial-event") return handleCommercialEvent(req, res); if (req.method !== "GET") return sendJson(res, 405, { error: "Method not allowed" }); const requestPath = req.url === "/" ? "/index.html" : req.url.split("?")[0]; + if (!publicFiles.has(requestPath)) { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + return res.end("Not found"); + } const safePath = normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, ""); const filePath = join(root, safePath); if (!filePath.startsWith(root)) return sendJson(res, 403, { error: "Forbidden" }); diff --git a/styles.css b/styles.css index dcae8f5..daaa01c 100644 --- a/styles.css +++ b/styles.css @@ -16,7 +16,7 @@ * { box-sizing: border-box; } html, body { min-width: 100%; min-height: 100%; margin: 0; } body { min-height: 100vh; overflow: hidden; color: var(--ink); background: var(--page); } -button, input { font: inherit; letter-spacing: 0; } +button, input, textarea { font: inherit; letter-spacing: 0; } button { color: inherit; cursor: pointer; } [hidden] { display: none !important; } @@ -159,6 +159,42 @@ input::placeholder { color: #9ca29b; } .quiz-feedback.is-wrong strong { color: #ad5546; } .quiz-feedback p { margin: 5px 0 0; overflow-wrap: anywhere; color: #606660; font-size: 9px; line-height: 1.6; } .study-action:focus-visible, .study-icon-button:focus-visible, .reveal-button:focus-visible, .quiz-option:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } +.paid-offer { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px; border: 1px solid #cbd1c7; border-left: 3px solid var(--lime); border-radius: 7px; background: #f7f9f4; } +.paid-offer-copy { min-width: 0; } +.paid-offer-copy > span { display: block; margin-bottom: 4px; color: #697266; font-size: 8px; font-weight: 700; } +.paid-offer-copy > strong { display: block; overflow-wrap: anywhere; font-size: 11px; line-height: 1.45; } +.paid-offer-copy > small { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--muted); font-size: 8px; line-height: 1.5; } +.paid-offer > button { min-width: 106px; min-height: 37px; display: grid; grid-template-columns: minmax(0, 1fr) auto 13px; align-items: center; gap: 5px; padding: 7px 9px; border: 0; border-radius: 5px; color: #20251e; background: var(--lime); font-size: 9px; } +.paid-offer > button:hover { background: #d8f86b; } +.paid-offer > button strong { font-size: 10px; } +.paid-offer > button svg { width: 12px; } +.offer-panel { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: white; } +.offer-details { padding: 15px 15px 13px; border-bottom: 1px solid var(--line); background: #f7f9f4; } +.offer-details > strong { font-size: 12px; } +.offer-details ul { display: grid; gap: 5px; margin: 9px 0 0; padding: 0; list-style: none; } +.offer-details li { position: relative; padding-left: 13px; color: #60675f; font-size: 9px; line-height: 1.5; } +.offer-details li::before { content: ""; position: absolute; top: 5px; left: 0; width: 5px; height: 5px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 1px #9eae64; } +.offer-form { display: grid; gap: 11px; padding: 15px; } +.offer-form > label:not(.offer-consent):not(.offer-honeypot) { display: grid; gap: 5px; color: #525852; font-size: 9px; font-weight: 600; } +.offer-form input:not([type="checkbox"]), .offer-form textarea { min-width: 0; width: 100%; padding: 9px 10px; border: 1px solid #d5d9d2; border-radius: 5px; outline: 0; color: var(--ink); background: white; font-size: 10px; font-weight: 400; } +.offer-form input:not([type="checkbox"]) { height: 36px; } +.offer-form textarea { min-height: 72px; resize: vertical; line-height: 1.55; } +.offer-form input:focus, .offer-form textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(99,114,223,.1); } +.offer-consent { display: grid; grid-template-columns: 15px minmax(0, 1fr); align-items: start; gap: 7px; color: #666d65; font-size: 8px; line-height: 1.5; } +.offer-consent input { width: 14px; height: 14px; margin: 0; accent-color: var(--blue); } +.offer-honeypot { display: none !important; } +.offer-submit { min-height: 38px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 12px; border: 0; border-radius: 5px; color: #20251e; background: var(--lime); font-size: 10px; } +.offer-submit:hover:not(:disabled) { background: #d8f86b; } +.offer-submit:disabled { cursor: wait; opacity: .65; } +.offer-form > p { margin: -3px 0 0; color: #81877f; font-size: 8px; line-height: 1.5; text-align: center; } +.offer-success { min-height: 260px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 28px 22px; text-align: center; } +.offer-success > span { width: 38px; height: 38px; display: grid; place-items: center; margin-bottom: 14px; border-radius: 50%; color: #2c693c; background: #e8f6eb; } +.offer-success > span svg { width: 18px; } +.offer-success > strong { font-size: 14px; } +.offer-success > p { margin: 8px 0 0; color: #5f665f; font-size: 10px; line-height: 1.65; } +.offer-success > small { margin-top: 7px; color: #858b84; font-size: 8px; } +.offer-success > button { height: 31px; margin-top: 18px; padding: 0 12px; border: 1px solid var(--line); border-radius: 5px; background: white; font-size: 9px; } +.paid-offer > button:focus-visible, .offer-submit:focus-visible, .offer-success > button:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } .user-message { justify-self: end; max-width: 82%; padding: 9px 12px; border-radius: 7px 7px 2px 7px; color: white; background: var(--ink); font-size: 11px; line-height: 1.55; } .follow-answer { display: grid; gap: 8px; padding: 13px 14px; border: 1px solid var(--line); border-radius: 7px; background: white; } .follow-answer strong { font-size: 12px; } @@ -187,6 +223,8 @@ input::placeholder { color: #9ca29b; } .learning-widget { inset: 0; width: 100%; height: 100%; border: 0; border-radius: 0; } .start-view { padding-inline: 22px; } .conversation { padding-inline: 15px; } + .paid-offer { grid-template-columns: 1fr; } + .paid-offer > button { width: 100%; } .launcher { right: 16px; bottom: 16px; } } diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index 02e07cd..f49983f 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -1,10 +1,16 @@ import assert from "node:assert/strict"; import { after, before, test } from "node:test"; import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const port = 43000 + (process.pid % 1000); const baseUrl = `http://127.0.0.1:${port}`; let server; +let testDirectory; +let leadFile; +let commercialEventFile; async function waitForServer() { for (let attempt = 0; attempt < 40; attempt += 1) { @@ -31,16 +37,20 @@ async function post(path, body) { } before(async () => { + testDirectory = await mkdtemp(join(tmpdir(), "quicklearn-smoke-")); + leadFile = join(testDirectory, "paid-pack-interests.jsonl"); + 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) }, + env: { ...process.env, PORT: String(port), QUICKLEARN_LEAD_FILE: leadFile, QUICKLEARN_EVENT_FILE: commercialEventFile }, stdio: ["ignore", "pipe", "pipe"] }); await waitForServer(); }); -after(() => { +after(async () => { if (server && !server.killed) server.kill("SIGTERM"); + await rm(testDirectory, { recursive: true, force: true }); }); test("serves the compact learning window", async () => { @@ -78,6 +88,63 @@ test("serves the flashcard and quiz interactions", async () => { assert.match(styles, /\.quiz-option\.is-correct/); }); +test("records a minimal paid-pack interest without charging", async () => { + const response = await fetch(`${baseUrl}/api/paid-pack-interest`, { + method: "POST", + headers: { "Content-Type": "application/json", "User-Agent": "smoke-secret-agent" }, + body: JSON.stringify({ + topic: "MCP", + goal: "两天内独立做出一个只读 MCP 工具", + contact: "learner@example.com\u001b[31m", + consent: true, + context: { source: { excerpts: ["must not be stored"] } } + }) + }); + assert.equal(response.status, 201); + const result = await response.json(); + assert.equal(result.received, true); + assert.equal(result.charged, false); + assert.match(result.reference, /^QL-[A-F0-9]{8}$/); + + const records = (await readFile(leadFile, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + assert.equal(records.length, 1); + assert.deepEqual(Object.keys(records[0]).sort(), ["amountCny", "contact", "createdAt", "goal", "offer", "reference", "topic"]); + assert.equal(records[0].amountCny, 29); + assert.equal(records[0].contact, "learner@example.com [31m"); + assert.doesNotMatch(JSON.stringify(records[0]), /must not be stored|smoke-secret-agent|127\.0\.0\.1/); + assert.equal((await stat(leadFile)).mode & 0o777, 0o600); + + for (const path of ["/.data/paid-pack-interests.jsonl", "/server.mjs", "/.git/config"]) { + assert.equal((await fetch(`${baseUrl}${path}`)).status, 404, path); + } +}); + +test("records aggregate commercial events without identity data", async () => { + for (const event of ["offer_view", "offer_open"]) { + const response = await fetch(`${baseUrl}/api/commercial-event`, { + method: "POST", + headers: { "Content-Type": "application/json", "User-Agent": "must-not-be-stored" }, + body: JSON.stringify({ event, topic: "must-not-be-stored" }) + }); + assert.equal(response.status, 204); + } + const events = (await readFile(commercialEventFile, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(events.map(({ event }) => event), ["offer_view", "offer_open"]); + assert.deepEqual(Object.keys(events[0]).sort(), ["createdAt", "event"]); + assert.doesNotMatch(JSON.stringify(events), /must-not-be-stored/); + assert.equal((await stat(commercialEventFile)).mode & 0o777, 0o600); +}); + +test("rejects paid-pack interest without explicit consent", async () => { + const response = await fetch(`${baseUrl}/api/paid-pack-interest`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ topic: "React", goal: "完成一个实际项目", contact: "test@example.com", consent: false }) + }); + assert.equal(response.status, 400); + assert.match((await response.json()).error, /同意/); +}); + test("asks for clarification when a term is ambiguous", async () => { const result = await post("/api/analyze", { input: "苹果" }); assert.equal(result.needsClarification, true);