‹ 打开小程序自托管与后端源码
功能总览 › 自托管

自托管与后端源码

这个应用是纯静态页面 + 5 个单文件后端接口:题库、背书、模考、备份全部在浏览器里跑,学习数据只存在你自己的设备上。只有三件事需要服务器——可选的跨设备加密同步、AI 转发、以及纠错与报错收集。这 5 个接口的源码下面全文公开,你也可以自己搭一份。

一、后端一共 5 个路由,各自能看到什么

路由方法作用收到什么它能不能看到你的内容防护
/api/syncGET · PUT跨设备同步那一条进度记录(可选功能)64 位指纹(记录名)+ base64 密文;PUT 空值=删除这条记录看不到内容:不解析 data,也没有你的密码与派生密钥来源校验 + 指纹格式校验 + 每 IP / 每指纹限流 + 单条 200KB 上限
/api/aiPOSTAI 讲解 / 模拟卷 / 助教问答的转发通道你提问的题目与上下文(明文)、匿名设备标识、配额计数能看到你发给 AI 的内容(它必须转发给模型服务商才能生成回答)同源校验 + 设备徽章(HMAC)+ 每设备每天 50 次 / 每 IP 每天 300 次 + 模型白名单
/api/ai-issueGET给匿名设备签发一枚 HMAC 徽章(AI 配额的第二道门槛)匿名设备标识(前端随机生成,可随时清空)不涉及任何学习内容同源校验 + 每 IP 每小时最多 20 枚
/api/feedbackPOST · GET接收题库纠错/反馈;GET 是给站长看的只读看板(需口令)题目原文、题号、你写的说明、设备标识前 24 位、User-Agent(明文存 KV,180 天自动过期)能看到你提交的反馈文字(纠错本来就要人读)同源校验 + 每设备每小时 10 条 / 每 IP 每小时 100 条 + 单条 4KB
/api/errorPOST接收前端报错(白屏 / JS 异常 / 资源加载失败),用于修 bug错误信息、堆栈、页面路径、行号列号、User-Agent、设备标识前 24 位(明文存 KV,7 天过期)不涉及学习内容(除非报错文本里恰好带上,所以请不要把隐私写进批注/自出题)同源校验 + 每 IP 每分钟 30 条 / 每小时 120 条
一句话总结/api/sync 只拿到密文(学不到任何内容);/api/ai/api/feedback/api/error 会看到你主动发出去的那部分文字——所以「不要把唯一副本交给云端、也不要把隐私写进提问/反馈」这两条建议是认真的。

二、后端源码全文(与线上运行的完全一致)

构建时直接从仓库里的 functions/api/*.js 读入,不是手抄的。你也可以在浏览器里直接访问 /api/sync?key=0000000000000000000000000000000000000000000000000000000000000000 看真实响应(会返回 {"empty":true})。

functions/api/sync.js(5185 字节)

/* Cloudflare Pages Function:跨设备进度同步(端到端加密)。
   安全加固(对应评测报告 D20/D21):
     1) Origin 校验:带 Origin 的跨站请求一律 403;同源(含 *.pages.dev 预览域名)放行。
     2) CORS 收紧:只在被允许的 Origin 上回 `Access-Control-Allow-Origin`,不再无条件 *。
     3) 限流:每 IP / 每指纹各一套窗口计数(KV,默认 10 分钟窗口),超限 429 + Retry-After。
     4) 指纹格式校验:只接受 64 位十六进制(客户端 SHA-256 指纹),其余一律 400。
     5) 单条上限 200KB;写空值 = 删除该条。
   - 服务器只收到密文(base64url)与不可逆指纹,没有密码、没有派生密钥,无法解密。
   - 本文件不接触任何明文;加密与密钥派生全部发生在浏览器端(页面源码公开可查)。 */

const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
const KEY_RE = /^[0-9a-f]{64}$/;        // 客户端指纹 = SHA-256 十六进制(32 字节)
const MAX_BYTES = 200000;
const WINDOW_S = 600;                    // 限流窗口:10 分钟
const LIMIT_IP = 150;                    // 每 IP 每窗口(读+写合计)
const LIMIT_READ = 150;                  // 每指纹每窗口读取
const LIMIT_WRITE = 60;                  // 每指纹每窗口写入

export async function onRequestOptions(context) {
  const origin = context.request.headers.get("Origin");
  if (!originAllowed(context.request)) return json({ error: "forbidden origin" }, 403);
  return new Response(null, { status: 204, headers: corsHeaders(origin, false) });
}

export async function onRequestGet(context) {
  const req = context.request;
  if (!originAllowed(req)) return json({ error: "forbidden origin" }, 403);
  const key = (new URL(req.url).searchParams.get("key") || "").trim();
  if (!KEY_RE.test(key)) return json({ error: "bad key" }, 400);
  const rl = await rateLimit(context, req, key, "read", LIMIT_READ);
  if (rl.blocked) return tooMany(rl, LIMIT_READ);
  const val = await context.env.SYNC_KV.get(key);
  if (val == null) return json({ empty: true }, 200, rl.headers);
  return json({ data: val }, 200, rl.headers);
}

export async function onRequestPut(context) {
  const req = context.request;
  if (!originAllowed(req)) return json({ error: "forbidden origin" }, 403);
  let j;
  try { j = await req.json(); } catch (e) { return json({ error: "bad body" }, 400); }
  const key = String(j.key || "").trim();
  const data = String(j.data || "");
  if (!KEY_RE.test(key)) return json({ error: "bad key" }, 400);
  const rl = await rateLimit(context, req, key, "write", LIMIT_WRITE);
  if (rl.blocked) return tooMany(rl, LIMIT_WRITE);
  if (!data) {
    await context.env.SYNC_KV.delete(key);
    return json({ ok: true }, 200, rl.headers);
  }
  if (data.length > MAX_BYTES) return json({ error: "too large" }, 413, rl.headers);
  await context.env.SYNC_KV.put(key, data);
  return json({ ok: true }, 200, rl.headers);
}

/* ---------------- 工具 ---------------- */
function originAllowed(req) {
  const origin = req.headers.get("Origin");
  if (!origin) return true;                       // 服务端/命令行调用没有 Origin,不拦(真正的防线是限流)
  try {
    const o = new URL(origin);
    const self = new URL(req.url);
    if (o.host === self.host) return true;        // 同源(含 <hash>.vetquiz.pages.dev 预览域名)
    return ALLOW_ORIGINS.some((a) => origin === a || origin === a + "/");
  } catch (e) { return false; }
}
function corsHeaders(origin, json) {
  const h = { "Cache-Control": "no-store" };
  if (json) h["Content-Type"] = "application/json";
  h["X-Content-Type-Options"] = "nosniff";
  if (origin && originAllowed({ url: "https://vetquiz.pages.dev/", headers: new Headers({ Origin: origin }) })) {
    h["Access-Control-Allow-Origin"] = origin;    // 只回允许的 Origin,不再 *
    h["Vary"] = "Origin";
    h["Access-Control-Allow-Methods"] = "GET,PUT,OPTIONS";
    h["Access-Control-Allow-Headers"] = "Content-Type";
  }
  return h;
}
async function rateLimit(context, req, key, kind, limit) {
  const kv = context.env.SYNC_KV;
  const origin = req.headers.get("Origin");
  const headers = corsHeaders(origin, false);
  const bucket = Math.floor(Date.now() / (WINDOW_S * 1000));
  if (!kv) return { blocked: false, headers };
  const ip = (req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
  const ipUsed = await incr(kv, ["ip", ip, bucket]);
  const keyUsed = await incr(kv, [kind, key.slice(0, 16), bucket]);
  headers["x-ratelimit-limit"] = String(limit);
  headers["x-ratelimit-used"] = String(kind === "read" ? keyUsed : keyUsed);
  headers["x-ratelimit-ip-used"] = String(ipUsed);
  const blocked = ipUsed > LIMIT_IP || (kind === "read" ? keyUsed > LIMIT_READ : keyUsed > LIMIT_WRITE);
  return { blocked, headers, retry: secsToNextWindow() };
}
function tooMany(rl, limit) {
  const h = Object.assign({}, rl.headers, { "Retry-After": String(rl.retry || WINDOW_S), "x-ratelimit-limit": String(limit) });
  return json({ error: "too many requests", retry_after: rl.retry || WINDOW_S }, 429, h);
}
function json(obj, status, extra) {
  const h = Object.assign({ "Content-Type": "application/json", "Cache-Control": "no-store" }, extra || {});
  if (!h["X-Content-Type-Options"]) h["X-Content-Type-Options"] = "nosniff";
  return new Response(JSON.stringify(obj), { status, headers: h });
}
async function incr(kv, parts) {
  const k = ["sync:rl", ...parts].join(":");
  const cur = Number(await kv.get(k)) || 0;
  const nxt = cur + 1;
  await kv.put(k, String(nxt), { expirationTtl: WINDOW_S * 3 });
  return nxt;
}
function secsToNextWindow() {
  return WINDOW_S - Math.floor((Date.now() / 1000) % WINDOW_S);
}

functions/api/ai.js(5920 字节)

/* Cloudflare Pages Function:DeepSeek AI 代理(带访问门槛,防白嫖 + 防脚本刷费用)。
   防滥用四件套:
     1) Origin 校验:只接受本站同源请求,跨站脚本一律 403。
     2) 匿名设备标识:前端生成 deviceId,调 /api/ai-issue 领取服务端 HMAC 签发的 badge;
        本接口校验 badge 与 deviceId 匹配,无法本机伪造。
     3) 每日配额:每个 deviceId 每天 50 次 + 每个 IP 每天上限(兜底防批量养号)→ KV 计数,超限 429。
     4) 模型白名单:不信任客户端传的任意 model,只放行允许列表,其余强制回落默认,杜绝任意模型串。
   - API Key 只存在环境变量 DEEPSEEK_API_KEY,前端永不接触。
   - 流式:把 DeepSeek 的 SSE 响应原样转发。
   新增环境变量:AI_BADGE_SECRET(HMAC 密钥,签发/校验设备 badge)。未配置时退化为「只查 deviceId + IP 上限」的开发宽松模式。
*/
export async function onRequestPost(context) {
  const req = context.request;
  let rate = null;   // 每个请求各自一份,不能放在模块级(同一 isolate 并发会互相串用)

  // 1) Origin 校验
  const origin = req.headers.get("Origin");
  if (!(context.env.AI_DEV === "1")) {
    // 同源请求(浏览器 GET 不携带 Origin,同源 POST 带本站 Origin)应放行;只有带不匹配 Origin 的跨站请求才拦截
    const okOrigin = !origin || ALLOW_ORIGINS.some((o) => origin === o || origin === o + "/");
    if (!okOrigin) return json({ error: "forbidden origin" }, 403);
  }

  // 2) 设备标识 + 签发校验
  const deviceId = String(req.headers.get("X-Device-Id") || "").trim();
  if (!DEVICE_RE.test(deviceId)) return json({ error: "missing or invalid X-Device-Id" }, 401);
  if (context.env.AI_BADGE_SECRET) {
    const badge = String(req.headers.get("X-Device-Badge") || "").trim();
    if (!DEVICE_RE.test(badge)) return json({ error: "missing or invalid X-Device-Badge" }, 401);
    const expect = await hmacHex(context.env.AI_BADGE_SECRET, deviceId);
    if (!timingSafeEqualStr(expect, badge)) return json({ error: "badge mismatch" }, 401);
  }

  // 3) 每日配额(KV 计数)
  const day = new Date().toISOString().slice(0, 10); // UTC 日
  const cap = Number(context.env.AI_DEVICE_DAILY) || 50;
  const ip = (req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
  const ipCap = Number(context.env.AI_IP_DAILY) || 300;
  const kv = context.env.SYNC_KV;
  if (kv) {
    const used = await incr(kv, ["ai:d", deviceId, day]) ;      // 每设备
    const ipUsed = await incr(kv, ["ai:ip", ip, day]);            // 每 IP
    if (used > cap || ipUsed > ipCap) {
      const limit = used > cap ? cap : ipCap;
      return json({ error: { message: "今日内置 AI 次数已用完,明天 0 点自动恢复" } }, 429, {
        "Retry-After": String(secsToMidnightUTC()), "x-ratelimit-limit": String(limit)
      });
    }
    rate = { limit: cap, used: used, ipUsed: ipUsed };
  }

  let body;
  try { body = await req.json(); } catch (e) { return json({ error: "body must be JSON" }, 400); }
  if (!context.env.DEEPSEEK_API_KEY) return json({ error: "server not configured (DEEPSEEK_API_KEY)" }, 503);
  const messages = Array.isArray(body.messages) ? body.messages : null;
  if (!messages) return json({ error: "missing messages" }, 400);

  // 4) 模型白名单:非法 model 统一回落默认,防止被当成任意 model 通道
  const requested = typeof body.model === "string" ? body.model : "deepseek-chat";
  const model = ALLOWED_MODELS.includes(requested) ? requested : DEFAULT_MODEL;

  const stream = !!body.stream;
  const payload = { model, messages, stream, temperature: typeof body.temperature === "number" ? body.temperature : 0.4 };
  if (!stream) payload.max_tokens = Number.isFinite(body.max_tokens) ? body.max_tokens : 1500;
  // 需要严格 JSON(AI 模拟整卷)时,用 DeepSeek 的 json_object 模式,显著降低结构化输出跑偏
  if (body.json_object === true && !stream) payload.response_format = { type: "json_object" };

  let upstream;
  try {
    upstream = await fetch("https://api.deepseek.com/chat/completions", {
      method: "POST",
      headers: { "Content-Type": "application/json", "Authorization": "Bearer " + context.env.DEEPSEEK_API_KEY },
      body: JSON.stringify(payload)
    });
  } catch (e) { return json({ error: "upstream unreachable" }, 502); }
  if (!upstream.ok) {
    const t = await upstream.text().catch(() => "");
    return new Response(t || upstream.statusText, { status: upstream.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
  }
  const h = new Headers();
  h.set("Content-Type", upstream.headers.get("Content-Type") || "application/json");
  h.set("Cache-Control", "no-store");
  if (rate) { h.set("x-ratelimit-limit", String(rate.limit)); h.set("x-ratelimit-usage", String(rate.used)); }
  if (stream && upstream.body) {
    return new Response(upstream.body, { status: 200, headers: h });
  }
  const text = await upstream.text();
  return new Response(text, { status: 200, headers: h });
}

/* ---------------- 常量 & 工具 ---------------- */
const DEFAULT_MODEL = "deepseek-chat";
const ALLOWED_MODELS = ["deepseek-chat", "deepseek-reasoner"];   // DeepSeek 官方模型名,杜绝任意串
const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
const DEVICE_RE = /^[A-Za-z0-9_-]{8,64}$/;

function json(obj, status, extra) {
  const h = { "Content-Type": "application/json", "Cache-Control": "no-store" };
  if (extra) Object.assign(h, extra);
  return new Response(JSON.stringify(obj), { status, headers: h });
}
function timingSafeEqualStr(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}
async function hmacHex(secret, data) {
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
  return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function incr(kv, parts) {
  const key = ["ai:rl", ...parts].join(":");
  const cur = Number(await kv.get(key)) || 0;
  const nxt = cur + 1;
  await kv.put(key, String(nxt), { expirationTtl: 26 * 3600 }); // 26h 自动过期,覆盖跨日清零
  return nxt;
}
function secsToMidnightUTC() {
  const now = Date.now();
  const next = new Date(now);
  next.setUTCDate(next.getUTCDate() + 1);
  next.setUTCHours(0, 0, 0, 0);
  return Math.ceil((next.getTime() - now) / 1000) + 60;
}

functions/api/ai-issue.js(2099 字节)

/* Cloudflare Pages Function:签发匿名设备 badge(并发量的准入门槛)。
   前端首次使用时带上它生成的 deviceId 来这里领一枚 HMAC 签名徽章并缓存;
   之后的 /api/ai 请求凭「deviceId + badge」被信任,配额按 deviceId 独立计。
   - 本接口也不开大闸:同一个 IP 每小时最多签 20 枚,防止脚本批量养号刷配额。
   - AI_BADGE_SECRET 未配置时直接返回空 badge(开发宽松模式,前端不缓存)。
*/
export async function onRequestGet(context) {
  const req = context.request;
  if (!(context.env.AI_DEV === "1")) {
    const origin = req.headers.get("Origin");
    // 同源 GET(浏览器不携带 Origin)应放行;只有带不匹配 Origin 的跨站请求才拦截
    const okOrigin = !origin || ALLOW_ORIGINS.some((o) => origin === o || origin === o + "/");
    if (!okOrigin) return json({ error: "forbidden origin" }, 403);
  }
  const d = new URL(req.url).searchParams.get("d") || "";
  if (!DEVICE_RE.test(d)) return json({ error: "bad device id" }, 400);

  if (!context.env.AI_BADGE_SECRET) return json({ badge: "" }, 200);

  // 每 IP 每小时 ≤20 枚
  const ip = (req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
  const kv = context.env.SYNC_KV;
  if (kv) {
    const hour = new Date().toISOString().slice(0, 13);
    const key = ["ai:issue", ip, hour].join(":");
    const cur = Number(await kv.get(key)) || 0;
    if (cur >= 20) return json({ error: { message: "领取太频繁,请稍后再试" } }, 429, { "Retry-After": "600" });
    await kv.put(key, String(cur + 1), { expirationTtl: 2 * 3600 });
  }
  const badge = await hmacHex(context.env.AI_BADGE_SECRET, d);
  return json({ badge }, 200);
}

const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
const DEVICE_RE = /^[A-Za-z0-9_-]{8,64}$/;

function json(obj, status, extra) {
  const h = { "Content-Type": "application/json", "Cache-Control": "no-store" };
  if (extra) Object.assign(h, extra);
  return new Response(JSON.stringify(obj), { status, headers: h });
}
async function hmacHex(secret, data) {
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
  return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

functions/api/feedback.js(5441 字节)

/* Cloudflare Pages Function:接收题库纠错/反馈,落 KV。
   - 前端"⚠️ 这题有问题"弹层点「提交反馈」→ 本函数把 {题目, 题目ID, 说明, 时间} 写进 KV。
   - 键:feedback:<UTC秒>:<短随机> → 值:JSON。后台用 SYNC_KV.list({ prefix:'feedback:' }) 批量导出即一份免费纠错工单。
   - 反滥用:同一 IP 每小时 ≤100 条;单条 ≤4KB;禁止超大/超长。
   - 产物是非敏感文本(用户自述 + 题目引用),不落任何个人信息,无需端到端加密。 */
export async function onRequestPost(context) {
  const req = context.request;
  // 同源限制:仅本应用域名可写(浏览器同源 POST 通常自然满足,这里做双保险)
  const origin = req.headers.get("Origin") || "";
  if (!(context.env.AI_DEV === "1") && origin && !ALLOW_ORIGINS.some((o) => origin === o || origin === o + "/")) {
    return json({ ok: false, error: "forbidden origin" }, 403);
  }

  let j;
  try { j = await req.json(); } catch (e) { return json({ ok: false, error: "bad body" }, 400); }

  const question = String(j.question || "").slice(0, 1200);
  const questionId = String(j.questionId || "").slice(0, 60);
  const note = String(j.note || "").slice(0, 1600);
  if (!question && !note) return json({ ok: false, error: "empty" }, 400);

  const kv = context.env.SYNC_KV;
  if (kv) {
    const ip = (req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
    const hour = new Date().toISOString().slice(0, 13);
    // 设备级:同一匿名设备每小时 ≤10 条(X-Device-Id 由前端持久生成,与 AI 配额同源)
    const deviceId = String(req.headers.get("X-Device-Id") || "").trim();
    if (DEVICE_RE.test(deviceId)) {
      const dKey = ["fb:rl:d", deviceId, hour].join(":");
      const dCur = Number(await kv.get(dKey)) || 0;
      if (dCur >= 10) return json({ ok: false, error: { message: "反馈提交太频繁,请稍后再试" } }, 429);
      await kv.put(dKey, String(dCur + 1), { expirationTtl: 90 * 60 });
    }
    // IP 级兜底:同一 IP 每小时 ≤100 条,防脚本批量灌库
    const rateKey = ["fb:rl", "ip", ip, hour].join(":");
    const cur = Number(await kv.get(rateKey)) || 0;
    if (cur >= 100) return json({ ok: false, error: { message: "提交太频繁,请稍后再试" } }, 429);
    await kv.put(rateKey, String(cur + 1), { expirationTtl: 60 * 90 });

    const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
    const val = JSON.stringify({
      t: new Date().toISOString(),
      question, questionId, note,
      d: deviceId ? deviceId.slice(0, 24) : "",
      ua: String(req.headers.get("User-Agent") || "").slice(0, 160)
    });
    if (val.length <= 4096) await kv.put("feedback:" + id, val, { expirationTtl: 180 * 24 * 3600 });
  }
  return json({ ok: true }, 200);
}

/* 只读看板:GET /api/feedback?days=30&limit=50  (需带口令)
   - 认证:?token=... 或 X-Feedback-Token 头或 Authorization: Bearer <token>,比对该站的 FB_VIEW_TOKEN。
   - 只读:列出 + 按题/按日汇总 feedback:* 的 KV 记录,绝不写入。 */
export async function onRequestGet(context) {
  const req = context.request;
  const url = new URL(req.url);
  const given =
    String(url.searchParams.get("token") || "").trim() ||
    String(req.headers.get("X-Feedback-Token") || "").trim() ||
    String(req.headers.get("Authorization") || "").replace(/^Bearer\s+/i, "").trim();
  const expected = String(context.env.FB_VIEW_TOKEN || "").trim();
  if (!expected || given !== expected) return json({ ok: false, error: "forbidden" }, 403);

  const kv = context.env.SYNC_KV;
  if (!kv) return json({ ok: false, error: "no kv" }, 500);

  const days = Math.min(720, Math.max(1, Number(url.searchParams.get("days")) || 30));
  const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit")) || 50));

  const keys = [];
  let cursor;
  do {
    const page = await kv.list({ prefix: "feedback:", cursor });
    keys.push(...(page.keys || []));
    cursor = page.cursor;
  } while (cursor && keys.length < 5000);

  const entries = [];
  for (const k of keys) {
    let v; try { v = await kv.get(k.name); } catch (e) { continue; }
    let o; try { o = JSON.parse(v); } catch (e) { o = {}; }
    entries.push({ name: k.name, time: Date.parse(o.t || "") || 0, ...o });
  }
  entries.sort((a, b) => b.time - a.time);

  const cutoff = Date.now() - days * 86400000;
  const byDay = {}, byQ = {};
  for (const e of entries) {
    const d = (e.t || "").slice(0, 10); if (d) byDay[d] = (byDay[d] || 0) + 1;
    const qid = e.questionId || "(无题号)";
    const g = (byQ[qid] = byQ[qid] || { count: 0, last: 0, notes: [] });
    g.count++; if (e.time) g.last = Math.max(g.last, e.time);
    if (e.note) g.notes.push(String(e.note));
  }
  const topByQuestion = Object.keys(byQ)
    .map(function (qid) {
      const g = byQ[qid];
      return { questionId: qid, count: g.count, last: g.last, samples: g.notes.slice(-3).map(function (s) { return s.slice(0, 120); }) };
    })
    .sort((a, b) => b.count - a.count).slice(0, 30);

  return json({
    ok: true,
    viewToken: expected.slice(0, 3) + "…",
    total: entries.length,
    inWindow: entries.filter(function (e) { return e.time >= cutoff; }).length,
    days, to: new Date().toISOString(), from: new Date(cutoff).toISOString(),
    byDay: Object.keys(byDay).sort().map(function (d) { return { day: d, count: byDay[d] }; }),
    topByQuestion,
    recent: entries.slice(0, limit).map(function (e) {
      return { t: e.t, questionId: e.questionId, question: String(e.question || "").slice(0, 80), note: String(e.note || "").slice(0, 160), device: e.d };
    })
  }, 200);
}

function json(obj, status) {
  return new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
}

const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
const DEVICE_RE = /^[A-Za-z0-9_-]{8,64}$/;

functions/api/error.js(2426 字节)

/* Cloudflare Pages Function:前端错误上报(白屏/JS 异常/资源加载失败)。
   - 前端 window.onerror + unhandledrejection 收拢后 POST 到本接口。
   - 反滥用:同 IP 每分钟 ≤30 条、每小时 ≤120 条;字段截断。
   - 落 KV 键 error:<ISO>:<rand>,后台 list({prefix:'error:'}) 即可读。 */
export async function onRequestPost(context) {
  const req = context.request;
  const origin = req.headers.get("Origin") || "";
  if (!(context.env.AI_DEV === "1") && origin && !ALLOW_ORIGINS.some((o) => origin === o || origin === o + "/")) {
    return json({ ok: false, error: "forbidden origin" }, 403);
  }
  let j;
  try { j = await req.json(); } catch (e) { return json({ ok: false, error: "bad body" }, 400); }
  const msg = String(j.msg || "").slice(0, 600);
  const stack = String(j.stack || "").slice(0, 1200);
  const url = String(j.url || "").slice(0, 300);
  const line = Number(j.line) || 0;
  const col = Number(j.col) || 0;
  if (!msg && !stack) return json({ ok: true }, 200); // 空错误按成功吞掉(避免脏数据)

  const kv = context.env.SYNC_KV;
  if (kv) {
    const ip = (req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
    const now = Date.now();
    const minK = ["er:rl:m", ip, Math.floor(now / 60000)].join(":");
    const hrK = ["er:rl:h", ip, new Date(now).toISOString().slice(0, 13)].join(":");
    const m = Number(await kv.get(minK)) || 0;
    const h = Number(await kv.get(hrK)) || 0;
    if (m >= 30 || h >= 120) return json({ ok: true, error: "rate limited" }, 429);
    await kv.put(minK, String(m + 1), { expirationTtl: 90 });
    await kv.put(hrK, String(h + 1), { expirationTtl: 3600 });

    const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
    const deviceId = String(req.headers.get("X-Device-Id") || "").slice(0, 24);
    const val = JSON.stringify({
      t: new Date().toISOString(), msg, stack, url, line, col,
      d: deviceId, p: locationHint(context.request, url),
      ua: String(req.headers.get("User-Agent") || "").slice(0, 120)
    });
    if (val.length <= 4096) await kv.put("error:" + id, val, { expirationTtl: 7 * 24 * 3600 });
  }
  return json({ ok: true }, 200);
}
function locationHint(req, url) {
  try { return new URL(url || req.url).pathname.slice(0, 80); } catch (e) { return ""; }
}
function json(obj, status) {
  return new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
}
const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];

三、用 AI 时,什么会离开本机

四、自己搭一份(约 15 分钟)

  1. 拿到静态文件:把线上站点的静态文件另存到本地目录,或直接从仓库里取 online/
  2. 建 KV 并绑定:建一个 KV 命名空间,绑定变量名必须是 SYNC_KV(5 个接口共用它做存取与限流计数)。
  3. 配环境变量DEEPSEEK_API_KEY(要 AI 功能才需要)、AI_BADGE_SECRETAI_DEVICE_DAILYAI_IP_DAILYFB_VIEW_TOKEN;把各文件里的 ALLOW_ORIGINS 改成你自己的域名。
  4. 发布npx wrangler pages deploy .(本项目的 wrangler.toml 里已声明 pages_build_output_dir 与 KV 绑定)。
自建时的两个安全提醒:① DEEPSEEK_API_KEY 请用 Cloudflare 的 Secretwrangler pages secret put)而不是明文变量,避免 Key 出现在配置与面板里;② 只保留你真正要用的接口,不用 AI 就把 ai.js / ai-issue.js 删掉。
本站当前把 Key 放在 [vars] 里(部署管线限制,见仓库注释),这一点也一并说明,不藏着。

五、端到端加密是怎么做的(可直接核对)

六、数据随时可以搬走

诚实写明边界:后端源码公开、前端可查、5 个路由各自能拿到什么已如实列出,但本项目仍未经过第三方安全审计,也没有 SLA——它是个人维护的免费工具,请始终留一份本地备份。
▶ 打开小程序安全与边界隐私政策