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

自托管与后端源码

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

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

路由方法作用收到什么它能不能看到你的内容防护
/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(6390 字节)

/* Cloudflare Pages Function:跨设备进度同步(端到端加密)。
   安全加固(对应评测报告 D20/D21):
     1) Origin 校验:带 Origin 的跨站请求一律 403;同源(含 *.pages.dev 预览域名)放行。
     2) CORS 收紧:只在被允许的 Origin 上回 Access-Control-Allow-Origin,不再无条件 *。
     3) 限流:进程内计数为主(零 KV 写)+ KV 兜底(仅在用量过半时写一次),任何 KV 异常 fail-open。
     4) 指纹格式校验:只接受 64 位十六进制(客户端 SHA-256 指纹),其余一律 400。
     5) 单条上限 200KB;写空值 = 删除该条。
     6) 存储故障不再抛 500:读/写失败回 503 + JSON 说明,限流统计失败不影响请求。
   为什么改成「零 KV 写限流」(2026-09-18 真实故障复盘):
     旧实现的 incr() 每个请求都要 KV 读 + KV 写,一次请求共 2 次写;客户端每 60 秒轮询,
     单个用户一天 2880 次写,而免费版 KV 每天只有 1000 次写 → 额度打满后 kv.put 抛异常,
     Worker 未捕获直接 500 —— 连「读回自己云端副本」都做不了。现在常规请求 0 次 KV 写。
   服务器只收到密文(base64url)与不可逆指纹,没有密码、没有派生密钥,无法解密。 */

const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
const KEY_RE = /^[0-9a-f]{64}$/;
const MAX_BYTES = 200000;
const WINDOW_S = 600;                    // 限流窗口:10 分钟
const LIMIT_IP = 150;                    // 每 IP 每窗口(读+写合计)
const LIMIT_READ = 150;                  // 每指纹每窗口读取
const LIMIT_WRITE = 60;                  // 每指纹每窗口写入
const BACKSTOP_AT = 0.5;                 // 用量过半才把计数写回 KV(正常用户永不触发)

/* 进程内限流表:窗口切换即清空。isolate 回收只会让计数变宽(fail-open),不会误伤正常用户。 */
const RATE = new Map();
let RATE_BUCKET = 0;
function bump(id) {
  const b = Math.floor(Date.now() / (WINDOW_S * 1000));
  if (b !== RATE_BUCKET) { RATE.clear(); RATE_BUCKET = b; }
  const n = (RATE.get(id) || 0) + 1;
  RATE.set(id, n);
  return n;
}

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 kv = context.env.SYNC_KV;
  if (!kv) return storageDown(rl);
  let val;
  try { val = await kv.get(key); }
  catch (e) { return storageDown(rl, e); }
  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.length > MAX_BYTES) return json({ error: "too large" }, 413, rl.headers);
  const kv = context.env.SYNC_KV;
  if (!kv) return storageDown(rl);
  try {
    if (!data) await kv.delete(key);
    else await kv.put(key, data);
  } catch (e) { return storageDown(rl, e); }
  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 origin = req.headers.get("Origin");
  const headers = corsHeaders(origin, false);
  const ip = String(req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
  const ipUsed = bump("ip:" + ip);
  const keyUsed = bump(kind + ":" + key.slice(0, 16));
  headers["x-ratelimit-limit"] = String(limit);
  headers["x-ratelimit-used"] = String(keyUsed);
  headers["x-ratelimit-ip-used"] = String(ipUsed);
  const blocked = ipUsed > LIMIT_IP || keyUsed > limit;
  /* KV 兜底:只有异常流量(用量过半)才会写,且每满 10 次才写一次;失败不影响判断 */
  if (context.env.SYNC_KV) {
    const needIp = ipUsed > LIMIT_IP * BACKSTOP_AT && ipUsed % 10 === 0;
    const needKey = keyUsed > limit * BACKSTOP_AT && keyUsed % 10 === 0;
    if (needIp || needKey) {
      const b = Math.floor(Date.now() / (WINDOW_S * 1000));
      try {
        if (needIp) await context.env.SYNC_KV.put(["sync:rl", "ip", ip, b].join(":"), String(ipUsed), { expirationTtl: WINDOW_S * 3 });
        if (needKey) await context.env.SYNC_KV.put(["sync:rl", kind, key.slice(0, 16), b].join(":"), String(keyUsed), { expirationTtl: WINDOW_S * 3 });
      } catch (e) { /* fail-open:计数只是兜底,写不进去不影响限流本身 */ }
    }
  }
  return { blocked, headers, retry: secsToNextWindow() };
}
function storageDown(rl, err) {
  return json({
    error: "storage_unavailable",
    message: "云端存储暂时不可用(免费额度用尽或临时故障)。你的本机进度不受影响,稍后重试即可;也可以用「导出加密备份」在本机留底。",
    detail: err ? String((err && err.message) || err).slice(0, 120) : "no kv binding",
  }, 503, rl ? rl.headers : {});
}
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 });
}
function secsToNextWindow() {
  return WINDOW_S - Math.floor((Date.now() / 1000) % WINDOW_S);
}

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

/* Cloudflare Pages Function:DeepSeek AI 代理(带访问门槛,防白嫖 + 防脚本刷费用)。
   防滥用四件套:
     1) Origin 校验:只接受本站同源请求,跨站脚本一律 403。
     2) 匿名设备标识:前端生成 deviceId,调 /api/ai-issue 领取服务端 HMAC 签发的 badge;
        本接口校验 badge 与 deviceId 匹配,无法本机伪造。
     3) 每日配额:每个 deviceId 每天 200 次(env AI_DEVICE_DAILY 可改)+ 每个 IP 每天 300 次上限(兜底防批量养号)→ 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) || 200;   /* t500:与前端 DAILY_CAP 对齐(原 50 会导致"前端显示还有额度、后端已 429") */
  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) {
    /* 额度计数:KV 读是权威来源(KV 写额度用尽时读仍可用);写失败就退回进程内计数,
       避免「存储故障 → 整个 AI 功能 500」。 */
    const used = await countDay(kv, ["ai:d", deviceId, day], "d:" + deviceId + ":" + day);
    const ipUsed = await countDay(kv, ["ai:ip", ip, day], "ip:" + ip + ":" + day);
    if (used > cap || ipUsed > ipCap) {
      const limit = used > cap ? cap : ipCap;
      const _why = (used > cap) ? "device" : "ip";
      return json({ error: { message: "今日内置 AI 次数已用完(上限 " + limit + " 次/天,明天 0 点自动恢复)", limit: limit, kind: _why } }, 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;
  // ===== t580(文档 5.3):真 tool-loop =====
  const TOOL_SPECS = [
    { type: "function", function: { name: "get_progress_stats", description: "查询用户学情统计:已复习数、正确率、错题数、薄弱章节、预估分", parameters: { type: "object", properties: {}, required: [] } } },
    { type: "function", function: { name: "get_wrong_book", description: "查询用户错题本(按出错次数倒序)", parameters: { type: "object", properties: { limit: { type: "number" } }, required: [] } } },
    { type: "function", function: { name: "get_today_plan", description: "查询今日到期清单与新学建议", parameters: { type: "object", properties: {}, required: [] } } },
    { type: "function", function: { name: "get_weak_chapters", description: "查询薄弱章节(按失败率倒序)", parameters: { type: "object", properties: { limit: { type: "number" } }, required: [] } } },
    { type: "function", function: { name: "get_progress_gap", description: "对比目标分与当前预估分,输出带数字的章节提分点清单", parameters: { type: "object", properties: { target: { type: "number" } }, required: [] } } },
    { type: "function", function: { name: "search_bank", description: "按关键词检索题库,返回题干与标准答案", parameters: { type: "object", properties: { query: { type: "string" }, limit: { type: "number" } }, required: ["query"] } } },
    { type: "function", function: { name: "calc_dose", description: "确定性计算剂量与体积,AI 只选公式不要心算", parameters: { type: "object", properties: { weight_kg: { type: "number" }, dose_mg_per_kg: { type: "number" }, conc_mg_per_ml: { type: "number" } }, required: ["weight_kg", "dose_mg_per_kg"] } } },
    { type: "function", function: { name: "web_search", description: "联网搜索(用于时效性事实:报名时间、国家线、招生简章、复试线、调剂等题库没有的公开信息)", parameters: { type: "object", properties: { query: { type: "string", description: "搜索词" } }, required: ["query"] } } },
    { type: "function", function: { name: "get_kp_related", description: "按题目ID返回知识点与关联考点", parameters: { type: "object", properties: { qid: { type: "string" } }, required: ["qid"] } } }
  ];
  /* __m1326ToolCache(§6.2.2):**工具结果同日复用**
     文档要求「报告统计与画像同日复用」——避免同一请求内/同日重复计算。
     两层:进程内 Map(快)+ KV(跨 isolate,TTL 到当日结束)。
     只缓存**只读**工具(报告/画像/薄弱章节/计划),web_search 与 calc_dose 不缓存。 */
  const TOOL_CACHEABLE = { get_progress_stats: 1, get_weak_chapters: 1, get_today_plan: 1, get_progress_gap: 1, get_wrong_book: 1 };
  const TOOL_MEM = new Map();
  const toolDay = new Date().toISOString().slice(0, 10);
  function toolCacheKey(dev, name, args) {
    let afp = "";
    try { afp = JSON.stringify(args || {}).slice(0, 120); } catch (e) { afp = ""; }
    return "tc:" + toolDay + ":" + String(dev || "anon").slice(0, 24) + ":" + name + ":" + afp;
  }
  async function toolCacheGet(env, key) {
    if (TOOL_MEM.has(key)) return TOOL_MEM.get(key);
    try {
      const kv = env && env.SYNC_KV;
      if (kv) { const v = await kv.get(key, "json"); if (v) { TOOL_MEM.set(key, v); return v; } }
    } catch (e) {}
    return null;
  }
  async function toolCachePut(env, key, val) {
    TOOL_MEM.set(key, val);
    try {
      const kv = env && env.SYNC_KV;
      if (kv) {
        const secs = Math.max(60, 86400 - Math.floor((Date.now() % 86400000) / 1000));
        await kv.put(key, JSON.stringify(val), { expirationTtl: secs });
      }
    } catch (e) {}
  }

  async function execTool(name, args, ctx) {
    const c = ctx || {};
    const num = (v, d) => (Number.isFinite(Number(v)) ? Number(v) : d);
    try {
      if (name === "get_progress_stats") return c.stats || { note: "no stats snapshot" };
      if (name === "get_wrong_book") return { total: (c.wrong || []).length, items: (c.wrong || []).slice(0, num(args && args.limit, 8)) };
      if (name === "get_today_plan") return c.plan || { note: "no plan" };
      if (name === "get_weak_chapters") return { items: (c.weakChapters || []).slice(0, num(args && args.limit, 8)) };
      if (name === "get_progress_gap") {
        const target = num(args && args.target, 320);
        const cur = num(c.stats && c.stats.estScore, 0);
        const plan = (c.weakChapters || []).slice(0, 5).map((w) => ({ chapter: w.chapter, failRate: w.failRate, fail: w.fail,
          suggest: "该章正确率约 " + (100 - num(w.failRate, 0)) + "%,建议刷 " + Math.max(10, num(w.n, 0) * 2) + " 题 + 重做 " + num(w.fail, 0) + " 道错题" }));
        return { target: target, current: cur, gap: target - cur, plan: plan };
      }
      if (name === "search_bank") {
        const q = String((args && args.query) || "");
        const lim = num(args && args.limit, 4);
        const hits = (c.bank || []).filter((x) => {
          const t = String(x.q || "") + " " + String(x.a || "");
          const zh = q.match(/[\u4e00-\u9fff]{2,}/g) || [];
          for (let i = 0; i < zh.length; i++) { const seg = zh[i]; for (let j = 0; j + 2 <= seg.length; j++) if (t.indexOf(seg.substr(j, 2)) >= 0) return true; }
          return false;
        }).slice(0, lim).map((x) => ({ id: x.id, q: x.q, a: String(x.a || "").slice(0, 150) }));
        return { query: q, count: hits.length, items: hits };
      }
      if (name === "calc_dose") {
        const w = num(args && args.weight_kg, 0), d = num(args && args.dose_mg_per_kg, 0), cc = num(args && args.conc_mg_per_ml, 0);
        if (!(w > 0) || !(d > 0)) return { ok: false, err: "weight and dose must be positive" };
        const total = w * d;
        const out = { ok: true, formula: "总量(mg) = 体重(kg) × 剂量(mg/kg)", weight: w, dose: d, totalMg: total };
        if (cc > 0) { out.concMgPerMl = cc; out.volumeMl = total / cc; out.formulaVolume = "体积(mL) = 总量(mg) ÷ 浓度(mg/mL)"; }
        return out;
      }
      if (name === "web_search") {
        /* §12.3:服务端出网(不受 CORS 限制);多源回退:Serper → Wikipedia → DuckDuckGo */
        const q = String((args && args.query) || "").slice(0, 80);
        if (!q) return { error: "empty query" };
        const n = 5;
        const cl = (x, m) => String(x == null ? "" : x).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, m || 240);
        let hit = null, tried = [];
        /* __m1803:**优先消费站内官方域检索** —— 这才是「会用工具解题」的关键。
           原实现只走 Serper(无 key)/Wikipedia/DuckDuckGo ⇒ 对「2025年考研多少人」
           「网上确认时间」这类中文事实拿不到官方来源(实测 _m1801:轨迹为空、凭记忆答)。
           而站内 /api/search 已实现 site: 限定 + chsi/moe 白名单 + 研招网直抓 + 主题词过滤;
           前端命中时效词时**还会先预取**并塞进 tool_context.web —— 原实现完全不读它(死数据)。
           故改成三层:① 预取 ② 服务端直连站内检索 ③ 原三源兜底。 */
        const _pickItems = (o) => {
          const src = []
            .concat((o && o.official) || [])
            .concat((o && o.results) || [])
            .concat((o && o.candidate) || []);
          const seen = {}, out = [];
          for (const it of src) {
            const u = String((it && it.url) || "");
            const k = u || String((it && it.title) || "");
            if (!k || seen[k]) continue;
            seen[k] = 1;
            out.push({ title: cl(it.title, 120), snippet: cl(it.snippet, 240), url: u });
            if (out.length >= n) break;
          }
          return out;
        };
        const _OFF_NOTE = "站内官方域检索(研招网/教育部/考试院优先)。必须逐条标注 url 来源;不得编造结果里没有的数字。";
        try {
          const _items = _pickItems(c && c.web);
          if (_items.length) return { query: q, source: "official-prefetch", count: _items.length, results: _items, note: _OFF_NOTE };
        } catch (e) { tried.push("prefetch:err"); }
        try {
          const _su = new URL("/api/search", context.request.url);
          _su.searchParams.set("q", q);
          _su.searchParams.set("n", String(n));
          const _r0 = await fetch(_su.toString(), { headers: { "Accept": "application/json" } });
          if (_r0.ok) {
            const _j0 = await _r0.json().catch(() => null);
            const _items = _pickItems(_j0);
            if (_items.length) return { query: q, source: "official-" + String((_j0 && _j0.source) || "search"), count: _items.length, results: _items, note: _OFF_NOTE };
            tried.push("search:0");
          } else tried.push("search:http-" + _r0.status);
        } catch (e) { tried.push("search:err"); }
        /* 源 1:Serper.dev(需 SERPER_API_KEY) */
        try {
          const key = context.env && context.env.SERPER_API_KEY;
          if (key) {
            const r1 = await fetch("https://google.serper.dev/search", { method: "POST", headers: { "Content-Type": "application/json", "X-API-KEY": key }, body: JSON.stringify({ q: q, num: n }) });
            if (r1.ok) {
              const j1 = await r1.json();
              const items = (j1.organic || []).slice(0, n).map((x) => ({ title: cl(x.title, 120), snippet: cl(x.snippet, 240), url: x.link || "" }));
              if (items.length) hit = { source: "serper", results: items, answerBox: (j1.answerBox && (j1.answerBox.answer || j1.answerBox.snippet)) ? cl(j1.answerBox.answer || j1.answerBox.snippet, 300) : null };
            }
            tried.push("serper:" + (hit ? "ok" : "fail"));
          } else tried.push("serper:no-key");
        } catch (e) { tried.push("serper:err"); }
        /* 源 1.5:__m1821 维基百科**精确条目直取**(按标题取条目正文,优先于关键词搜索)
           —— 实测根因:`list=search` 关键词搜索对「病原体」会返回维基「動物試驗」这种同形无关条目,
              也是「AI 太笨、搜不到东西」的直接来源;而**按标题精确取 + redirects 跟随**
              能直接命中条目正文,相关性高出一个量级。
           ⚠ 只在缺结果时才走这一路;**不动** ① 前端预取 ② 站内 /api/search,也不碰本地 RAG。 */
        if (!hit) {
          try {
            const _cands = [q, String(q).split(/\s+/)[0]].filter(Boolean);
            for (const _t of _cands) {
              const _r = await fetch("https://zh.wikipedia.org/w/api.php?action=query&format=json&utf8=1&redirects=1&prop=extracts&explaintext=1&exintro=1&titles=" + encodeURIComponent(_t), { headers: { "User-Agent": "vetquiz/1.0" } });
              if (!_r.ok) continue;
              const _j = await _r.json();
              const _pages = (_j.query && _j.query.pages) || {};
              const _pg = Object.keys(_pages).map((k) => _pages[k]).find((p) => p && p.pageid && !p.missing && String(p.extract || "").trim());
              if (!_pg) continue;
              const _txt = cl(_pg.extract, 300);
              if (!_txt) continue;
              hit = { source: "wikipedia-entry", results: [{ title: cl(_pg.title, 120), snippet: _txt, url: "https://zh.wikipedia.org/wiki/" + encodeURIComponent(_pg.title) }] };
              tried.push("wiki-entry:ok");
              break;
            }
            if (!hit) tried.push("wiki-entry:miss");
          } catch (e) { tried.push("wiki-entry:err"); }
        }
        /* 源 1.6:__m1821 百度百科**条目直取**(中文专业词覆盖优于中文维基)
           实测:中文维基对「传染源」这类兽医学名词没有独立条目(wiki-entry:miss),
           百度百科有。这里只取词条的 meta description(词条简介)——最轻、最稳,
           失败就静默跳过,绝不影响其他源。 */
        if (!hit) {
          try {
            const _c2 = [q, String(q).split(/\s+/)[0]].filter(Boolean);
            for (const _t of _c2) {
              const _r = await fetch("https://baike.baidu.com/item/" + encodeURIComponent(_t), { headers: { "User-Agent": "Mozilla/5.0 (compatible; vetquiz/1.0)" } });
              if (!_r.ok) continue;
              const _h = await _r.text();
              const _m = _h.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']{20,})["']/i) || _h.match(/<meta[^>]+content=["']([^"']{20,})["'][^>]*name=["']description["']/i);
              const _d = _m ? cl(_m[1], 300) : "";
              if (!_d) continue;
              hit = { source: "baike", results: [{ title: cl(_t, 120), snippet: _d, url: "https://baike.baidu.com/item/" + encodeURIComponent(_t) }] };
              tried.push("baike:ok");
              break;
            }
            if (!hit) tried.push("baike:miss");
          } catch (e) { tried.push("baike:err"); }
        }
        /* 源 2:Wikipedia */
        if (!hit) {
          try {
            const r2 = await fetch("https://zh.wikipedia.org/w/api.php?action=query&format=json&list=search&utf8=1&srlimit=" + n + "&srsearch=" + encodeURIComponent(q), { headers: { "User-Agent": "vetquiz/1.0" } });
            if (r2.ok) {
              const j2 = await r2.json();
              const hits = (j2.query && j2.query.search) || [];
              if (hits.length) hit = { source: "wikipedia-zh", results: hits.slice(0, n).map((x) => ({ title: cl(x.title, 120), snippet: cl(x.snippet, 240), url: "https://zh.wikipedia.org/wiki/" + encodeURIComponent(x.title) })) };
            }
            tried.push("wiki:" + (hit ? "ok" : "fail"));
          } catch (e) { tried.push("wiki:err"); }
        }
        /* 源 3:DuckDuckGo */
        if (!hit) {
          try {
            const r3 = await fetch("https://api.duckduckgo.com/?q=" + encodeURIComponent(q) + "&format=json&no_html=1&skip_disambig=1", { headers: { "User-Agent": "vetquiz/1.0" } });
            if (r3.ok) {
              const j3 = await r3.json();
              const out = [];
              if (j3.AbstractText) out.push({ title: cl(j3.Heading, 120) || q, snippet: cl(j3.AbstractText, 300), url: j3.AbstractURL || "" });
              (j3.RelatedTopics || []).slice(0, n).forEach((t) => { if (t && t.Text) out.push({ title: cl(t.Text, 80), snippet: cl(t.Text, 240), url: t.FirstURL || "" }); });
              if (out.length) hit = { source: "duckduckgo", results: out.slice(0, n) };
            }
            tried.push("ddg:" + (hit ? "ok" : "fail"));
          } catch (e) { tried.push("ddg:err"); }
        }
        if (!hit) return { query: q, count: 0, results: [], note: "联网未返回结果", tried: tried };
        return { query: q, source: hit.source, answerBox: hit.answerBox || null, count: hit.results.length, results: hit.results, tried: tried,
                 note: "联网结果仅供参照;必须标注来源,不得编造未出现在结果中的信息。" };
      }
      if (name === "get_kp_related") return (c.kp && c.kp[String(args && args.qid)]) || { note: "no kp label" };
      return { error: "unknown tool: " + name };
    } catch (e) { return { error: String((e && e.message) || e) }; }
  }
  const toolCtx = (body.tool_context && typeof body.tool_context === "object") ? body.tool_context : null;
  /* __m1313LastUser:**只在当轮用户问题上匹配联网触发词**
     —— 原实现用 messages 全量拼接,历史里问过"预报名"就会污染之后所有提问。 */
  const PLAN_FILL_RE = /当前计划:[^。;\n]*|学习日\s*\d+\s*天?|核心集\s*\d+\s*\/\s*\d+|计划末日[^。;\n]*|L[123]\s*档|今日三步|今天的三个步|三件套|该背的背完|温故今天到期|错题重做一遍/g;
  function lastUserAsk() {
    try {
      for (let i = messages.length - 1; i >= 0; i--) {
        const m = messages[i];
        if (m && m.role === "user") return String(m.content || "");
      }
    } catch (e) {}
    return "";
  }
  function cleanAsk(t) {
    return String(t || "").replace(PLAN_FILL_RE, " ").replace(/\s+/g, " ").trim();
  }
  const __askRaw = lastUserAsk();
  const __askClean = cleanAsk(__askRaw);

  /* __m1365UnifyTimely:**时效词表单一来源**
     优先采用前端传来的 AI_TIMELY_PAT 源串(保证前后端一致),否则用内置默认。 */
  /* __m1802:内置默认词表补齐**报名人数类** —— 实测(_m1801 线上取证)「2025年考研多少人」
     在前端未传 timely_pat 时(API 直连 / 第三方调用 / 旧版前端)不命中任何时效词
     ⇒ wantWebHint=false ⇒ 不注入联网提示 ⇒ 模型凭记忆直接答数字。前端词表已同步补齐,此处保持一致。 */
  const TIME_FACT_SRC = "预报名|正式报名|报名时间|报名流程|网上报名|网上确认|现场确认|确认时间|信息填报|填写考生信息|报考点|准考证|打印准考证|成绩|出分|成绩公布|成绩查询|查分|复试名单|国家线|分数线|复试线|复试时间|复试通知|调剂|推免|报考条件|加试|免试|考试时间|初试时间|考试大纲|招生简章|招生目录|政审|体检|档案|录取通知书|初试|初试日期|几号考|什么时候考|初试什么时候|考试日期|报名人数|报考人数|报名总人数|报考人数统计|报考规模|考研多少人|多少人报考";
  function reqTimelyPat() {
    /* __m1796(AI 工具能力 DSH 级对齐 · 第一层真因):
       原实现「前端传了 timely_pat 就**只用它**」⇒ 前端词表落后(缺报名人数/多少人)时,
       服务端 __m1802 补好的 TIME_FACT_SRC **永远用不上** ⇒ wantWebHint=false
       ⇒ 不注入强制联网提示、第 0 轮不钉 tool_choice ⇒ 模型凭记忆答「388万」。
       线上取证(_m1793):同一问题带 timely_pat 时轨迹含 web_search,不带时轨迹为 []。
       修法:**前端词表与内置词表取并集**(任一命中即视为时效事实题)。 */
    let _client = "";
    try {
      _client = String(body.timely_pat || "").trim();
      if (_client.length < 4) _client = "";
    } catch (e) { _client = ""; }
    if (!_client) return new RegExp(TIME_FACT_SRC);
    try {
      return new RegExp("(?:" + _client + ")|(?:" + TIME_FACT_SRC + ")");
    } catch (e) {
      /* 前端词表语法异常 ⇒ 退回内置词表,绝不因前端脏数据而失效 */
      return new RegExp(TIME_FACT_SRC);
    }
  }
  const wantLoop = !!toolCtx && body.tool_loop !== false && !body.json_object;
  /* §12.5 触发规则:命中时效事实词 ⇒ 提示模型优先联网(仍由模型自主决定是否调用) */
  /* __m1365UnifyTimely:与前端 AI_TIMELY_PAT 保持同一词表(前端在 body 里同时带 AI_TIMELY 时以它为准) */
  const TIME_FACT_RE = reqTimelyPat();
  const wantWebHint = wantLoop && TIME_FACT_RE.test(__askClean);  /* __m1313:只看当轮用户问题 */

  const payload = { model, messages, stream, temperature: typeof body.temperature === "number" ? body.temperature : 0.4 };
  /* __m1301Gate(修理三·工具白名单)
     web_search 只在联网通道挂载(intent=info_web/general_info 或提问命中时效词)。
     ⚠️ 顺序铁律:本块只引用**上方已定义**的 TOOL_SPECS / body / messages / wantLoop / payload。 */
  const reqIntent = String(body.intent || "");
  const askText = __askClean;  /* __m1313:只看当轮用户问题(已剥离计划回填) */
    const TIME_FACT = TIME_FACT_RE;
  /* __m1313:规划/错题/策略通道**物理上不许联网**(即使问题里碰巧含时效词) */
  const INTENT_NO_WEB = (reqIntent === "personal_plan" || reqIntent === "error_analysis" || reqIntent === "strategy");
  /* __m1820:knowledge(学科知识题)也允许挂 web_search —— 材料里答不出时按需联网兜底。
     起因(用户反馈):「问传染源,回答不出来,也不会去网上搜索」;knowledge 此前不在白名单,
     工具层直接不挂 web_search,模型即便想查也查不了。 */
  const webAllowed = !INTENT_NO_WEB && ((reqIntent === "info_web" || reqIntent === "general_info" || reqIntent === "web_general" || reqIntent === "knowledge") || TIME_FACT.test(askText));
  /* __m1810WebGeneral:通用知识联网通道(区别于时效事实题)—— 挂 web_search + 第 0 轮强制联网 */
  const isGeneralWeb = webAllowed && reqIntent === "web_general";

  /* __m1319DenyWeb:**断网通道的 system 硬声明**
     实测:即使 tools 里没有 web_search、tool_context.web=null,
     模型仍会凭 base 提示词脑补「我这边联网检索也没返回可用结果,联网未查到」。
     ⇒ 必须在 system 里**明确否认**联网能力,否则它会编造检索过程。 */
  const NO_WEB_TEXT = "【本通道无联网能力(事实声明)】本次请求**没有联网工具**,你**无法搜索、无法联网、无法检索外部信息**。"
    + "因此:① **严禁**在回答里出现「联网」「搜索」「检索」「web_search」「抓取」等字样,也严禁标注任何**检索/联网来源**(如「来源:web_search」「来源:联网」);(如实说明数据出处是可以的,例如「数据来源:本次学情接口」)"
    + "② **严禁**编造任何检索过程或检索结果(例如「我这边联网检索也没返回可用结果」「联网未查到」);"
    + "③ 你的任务是基于上面 tool_context 里的学情数据,**直接回答用户问的那件事**;"
    + "④ 若用户问的确实是报名时间/国家线/招生简章等资讯,就回一句「这类资讯请单独提问」,然后继续回答学情问题。"
    + "(注意:本通道连那两个敏感字都不必出现。)";

  /* __m1332RoleServer(§11.1.4):**三角色人格在服务端注入**
     文档要求三角色共用同一意图与工具层;服务端按请求判定角色,
     保证 API 直连与第三方调用也拿到一致人格(不依赖客户端)。 */
  const ROLE_PROMPTS = {
    teacher: "【你现在的角色:老师】先给明确动作再解释原因;学生焦虑时先接住情绪(一句即可),"
      + "然后立刻落到「今天做什么、做多少、怎么判断会了」。不要空谈道理,每条建议都要可执行。",
    /* __m1577(手册 §0.2 第一类 入口5):**与人前端 AI_ROLE_PROMPTS.grader 口径一致** ——
       踩分点制逐点判分,不再自述"三维百分比"(原提示词写 40/35/25,前端代码实现 45/35/20,已一并消除)。 */
    grader: "【你现在的角色:阅卷老师】严格、按**踩分点**给分,不做鼓励式点评。"
      + "批改必须**逐点判对错**:本题的踩分点清单与每点分值随材料给出,你必须直接引用那些点,"
      + "说明哪点答到(给分)、哪点答偏(半分)、哪点未答到(0 分);"
      + "不得自己另立一套评分维度,也不得把分数说成「三维百分比」。"
      + "最后给出:总分、最容易补回来的 1-2 点、一句话怎么补。严禁改动学生原答案,只批注。",
    diagnoser: "【你现在的角色:学情诊断师】只讲数据结论。输出必须含:① 当前预估分与目标分差距 "
      + "② 按失败率倒序的薄弱章节(带题量)③ 每章「补多少题预计补多少分」④ 排序后的下一步动作。"
      + "**所有数字必须来自注入数据,缺数据就写「暂无数据」,严禁编造。**"
  };
  function pickRole() {
    const t = String(__askClean || "") + " " + String(__askRaw || "");
    if (/批改|打分|我答得|评一下|看看我写|对不对|阅卷/.test(t)) return "grader";
    if (reqIntent === "strategy" || reqIntent === "personal_plan" || /薄弱|提分|还差|诊断|学情|错题/.test(t)) return "diagnoser";
    return "teacher";
  }
  const ROLE_TEXT = ROLE_PROMPTS[pickRole()] || ROLE_PROMPTS.teacher;


  const ACTIVE_TOOLS = webAllowed ? TOOL_SPECS : TOOL_SPECS.filter(function (t) { return !(t.function && t.function.name === "web_search"); });
  if (wantLoop) { payload.tools = ACTIVE_TOOLS; payload.tool_choice = "auto"; }
  /* __m1332RoleServer:把角色人格前置到 system(两条路径都生效) */
  try {
    const i0 = messages.findIndex(function (m) { return m && m.role === "system"; });
    if (i0 >= 0) {
      if (String(messages[i0].content || "").indexOf("【你现在的角色") < 0) {
        messages[i0] = { role: "system", content: ROLE_TEXT + "\n\n" + String(messages[i0].content || "") };
      }
    } else {
      messages.unshift({ role: "system", content: ROLE_TEXT });
    }
  } catch (e) {}

  // ===== t580:tool-loop 执行(计划→执行→反思),上限 3 轮 =====
  if (wantLoop) {
    const conv = messages.slice();
    const trace = [];
    let finalMsg = null;
    for (let round = 0; round < 3; round++) {
      const WEB_HINT_TEXT = "【强制联网 / __t586HardWebHint】本轮用户问题属于**时效性事实**(报名时间/国家线/分数线/招生简章/复试线/调剂/推免/报考条件等)。"
        + "**先用一两句直接回答用户问的那件事**(依据 tool_context 里的学情数据),如涉及时效信息再补充联网结果;**禁止把「未查到」作为唯一回答**。"
        + "然后调用 web_search 获取时效信息作为补充。"
        + "严禁凭记忆直接给出日期或数字;如果 web_search 没有返回有用结果,就如实说「联网未查到,请以官网为准」,不要用记忆中的大概时间替代。"
        + "回答末尾必须标注来源(工具返回的 url 或来源名)。";
      /* __m1810WebGeneral:通用知识联网提示(区别于时效事实提示)—— 允许范围外联网作答 */
      const GENERAL_WEB_HINT = "【通用知识联网要求】本轮用户问的是本应用材料范围外的通用知识/学习/职业类问题(如编程、AIGC、语言、行业、转行等)。"
        + "你必须先调用 web_search 联网查询,只依据返回结果作答,并在回答末尾标注来源(来源名或 url)。"
        + "严禁说「不在本应用材料范围内」「材料里没有」这类拒绝;严禁凭记忆编造;严禁反问用户。"
        + "若联网未返回有效结果,如实说「联网未查到有效信息,建议自行检索」,不要编。";
      /* __m1820:学科知识题(knowledge)的**按需联网**提示 —— 不强制第 0 轮就搜:
         材料够就直接答,材料确实没有才联网查证。与"时效事实强制联网"是两种口径。 */
      const KNOW_WEB_HINT = "【按需联网 / __m1820】本轮是学科知识提问。规则:① 先看上面的题目卡片与材料,材料里能答**就直接答**、不要联网;"
        + "② 材料里确实没有、或你不确定,就调用 web_search 联网查一次,依据返回结果作答,并在末尾标注来源(来源名或 url)。"
        + "**查询词必须带学科与意图限定**(例如「病原体 兽医学 定义」「猪瘟 传播途径 兽医」),禁止只丢一两个字的裸词 —— 实测裸词检索会返回同名无关条目(查「病原体」返回维基「動物試驗」),搜回无关内容比不搜更糟;"
        + "③ 严禁编造检索过程或检索结果;④ 严禁只用「材料里没有」「不在本应用材料范围内」这类话把用户打发掉 —— 要么依据材料答,要么联网查证后答。";
      /* __m1319DenyWeb:断网通道注入"无联网能力"声明;联网通道才注入联网提示 */
      if (round === 0 && webAllowed && (wantWebHint || isGeneralWeb)) conv.push({ role: "system", content: isGeneralWeb ? GENERAL_WEB_HINT : WEB_HINT_TEXT });
      if (round === 0 && webAllowed && reqIntent === "knowledge" && !wantWebHint && !isGeneralWeb) conv.push({ role: "system", content: KNOW_WEB_HINT });
        /* __m1496(§19.4 阶段3):**行为约束三层防线** —— 与能力层解耦,只加 system 规则,不动工具/路由。
           对应 §18.3 八条坏行为:第1 过度调用工具 / 第2 工具失败当结论 / 第3 空话自我表扬 /
           第5 忽略显式约束 / 第6 答非所问 / 第7 无证据下结论 / 第8 越权行动。 */
        if (round === 0) {
          conv.push({ role: "system", content:
            "【结构防线】回答前先确认用户问的是哪件事;若你的回答没有针对**这件事本身**(例如问流程你却答考点、问操作你却汇报进度),必须重写。"
            + "用户在提问里给的显式格式要求(字数上限、禁用符号、不要表格)**必须遵守**,与内容同等重要。"
            + "不要为了显得做了事而调用工具:简单问题直接回答,把工具留给真正需要取数的问题。",
          });
          conv.push({ role: "system", content:
            "【证据防线】回答里出现的每个数字,必须能在工具结果或对话数据里逐字找到;找不到来源就不要给这个数字。"
            + "没有某人的数据时,规定话术是「我没有你的 X 数据,无法判断」,不得用「很棒/加油/坚持下去/继续努力」这类没有数字支撑的鼓励句充数。"
            + "禁止宣称已执行某个动作(已写入/已修改/已部署)—— 你没有写权限,这类话一律不许说。",
          });
        }
      if (round === 0 && !webAllowed) conv.push({ role: "system", content: NO_WEB_TEXT });
      /* __m1802ForceWeb:**该用工具时一定用** —— 时效事实题 + 允许联网 ⇒ 第 0 轮把 tool_choice
         钉死到 web_search。实测(_m1801 线上取证):「2025年考研多少人」在只给提示词、
         tool_choice 留 auto 时,模型**凭记忆直接答 388万**(X-Tool-Trace 为 []),
         即工具已挂载却不被调用 ⇒ 不符合「像 DSH 一样用工具解题」。
         故第 0 轮不由模型自由裁量;拿到工具结果后(round≥1)恢复 auto,交还模型自主权。
         ⚠️ 仅当 webAllowed(工具确实挂载)时才强制,否则会强制调用不存在的工具导致上游报错。 */
      const _forceWeb = (round === 0 && (wantWebHint || isGeneralWeb) && webAllowed);
      const lp = { model, messages: conv, temperature: payload.temperature, tools: ACTIVE_TOOLS,
        tool_choice: _forceWeb ? { type: "function", function: { name: "web_search" } } : "auto" };
      let lr;
      try {
        lr = 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(lp) });
      } catch (e) { return json({ error: "upstream unreachable (loop)" }, 502); }
      if (!lr.ok) { const t = await lr.text().catch(() => ""); return new Response(t || lr.statusText, { status: lr.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); }
      const lj = await lr.json().catch(() => null);
      const msg = lj && lj.choices && lj.choices[0] && lj.choices[0].message;
      if (!msg) return json({ error: "bad loop response" }, 502);
      const calls = msg.tool_calls || [];
      if (!calls.length) { finalMsg = msg; break; }
      conv.push({ role: "assistant", content: msg.content || "", tool_calls: calls });
      for (let ci = 0; ci < calls.length; ci++) {
        const tc = calls[ci];
        let a = {};
        try { a = JSON.parse((tc.function && tc.function.arguments) || "{}"); } catch (e) { a = {}; }
        /* __m1326ToolCache:只读工具同日复用 */
        const _tname = tc.function && tc.function.name;
        let res;
        if (TOOL_CACHEABLE[_tname]) {
          const _ck = toolCacheKey(deviceId, _tname, a);
          const _hit = await toolCacheGet(context.env, _ck);
          if (_hit) { res = _hit; try { trace[trace.length - 1].cached = true; } catch (e) {} }
          else { res = await execTool(_tname, a, toolCtx); await toolCachePut(context.env, _ck, res); }
        } else {
          res = await execTool(_tname, a, toolCtx);
        }
        trace.push({ round: round + 1, tool: tc.function && tc.function.name, args: a });
        conv.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(res) });
      }
    }
    if (!finalMsg) {
      const fp = { model: model, messages: conv.concat([{ role: "user", content: "请立即基于以上工具结果给出最终回答,不要再调用工具。" }]), temperature: payload.temperature };
      const fr = 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(fp) });
      const fj = await fr.json().catch(() => null);
      finalMsg = (fj && fj.choices && fj.choices[0] && fj.choices[0].message) || { content: "" };
    }
    /* __m1497(§19.4 阶段3 ③ 判定防线):**工具失败不许当结论**(§18.3 第2条)。
       触发条件(全部满足才重写,最多一次):
         · 本轮调用过工具,但所有工具结果都是空的(条数=0 / 空数组 / 空对象)
         · 且回答很短(< 200 字)又含「未查到 / 没有查到 / 暂无数据」这类话
       处置:让模型**回答用户问题本身**(用已有数据+常识给可执行指引),而不是把「未查到」当答案。 */
    let _rewritten = false;
    try {
      if (trace.length) {
        let _anyData = false;
        for (const _c of conv) {
          if (!_c || _c.role !== "tool") continue;
          const _s = String(_c.content || "");
          if (_s.length > 40 && !/"count":\s*0/.test(_s) && !/^\s*(\[\]|\{\})\s*$/.test(_s)) { _anyData = true; break; }
        }
        const _cur = String(finalMsg.content || "");
        const _shortUnfound = _cur.length < 200 && /未查到|没有查到|暂无数据|查无/.test(_cur);
        if (!_anyData && _shortUnfound) {
          const rp = { model: model, temperature: payload.temperature, messages: conv.concat([
            { role: "user", content: "【判定防线·重写】你刚才的回答把「未查到」当成了结论,但用户问的是他这件事本身。"
              + "请直接回答用户的问题本身:能确定的部分照实说(依据已有数据),"
              + "不确定的部分给出可执行指引(去哪查、查什么、今天做什么),"
              + "不要用「未查到」作为唯一内容,也不要编造数字。" }
          ]) };
          const rr = 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(rp) });
          const rj = await rr.json().catch(() => null);
          const _nc = rj && rj.choices && rj.choices[0] && rj.choices[0].message && rj.choices[0].message.content;
          if (_nc && String(_nc).trim().length > _cur.trim().length) { finalMsg = { content: _nc }; _rewritten = true; }
        }
      }
    } catch (e) { try { trace.push({ rewrite: "error" }); } catch (e2) {} }
    if (_rewritten) { try { trace.push({ rewrite: "judge-guard" }); } catch (e) {} }
    const txt = finalMsg.content || "";
    const sse = "data: " + JSON.stringify({ choices: [{ delta: { content: txt } }] }) + "\n\ndata: [DONE]\n\n";
    return new Response(sse, { status: 200, headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", "X-Tool-Trace": encodeURIComponent(JSON.stringify(trace).slice(0, 800)) } });
  }
  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("");
}
/* 进程内兜底计数(KV 写失败时用;isolate 回收只会放宽,不会误伤正常用户) */
const MEM = new Map();
function memBump(k, v) {
  MEM.set(k, v);
  if (MEM.size > 5000) { for (const key of MEM.keys()) { MEM.delete(key); if (MEM.size <= 4000) break; } }
  return v;
}
async function countDay(kv, parts, memKey) {
  const key = ["ai:rl", ...parts].join(":");
  let base = 0;
  try { base = Number(await kv.get(key)) || 0; } catch (e) { base = 0; }   // 读失败:当作 0,靠内存兜底
  const n = Math.max(base, MEM.get(memKey) || 0) + 1;
  memBump(memKey, n);
  try { await kv.put(key, String(n), { expirationTtl: 26 * 3600 }); }      // 26h 过期,覆盖跨日清零
  catch (e) { /* 写额度用尽:计数留在内存里,配额仍然生效(按 isolate 计) */ }
  return n;
}
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(2182 字节)

/* 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(":");
    try {
      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 });
    } catch (e) { /* KV 写额度用尽:仍然签发徽章(fail-open),否则整个 AI 入口会卡死 */ }
  }
  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(5816 字节)

/* Cloudflare Pages Function:接收题库纠错/反馈,落 KV。
   - 加固(2026-09-18):所有 KV 读写包 try/catch,存储不可用时回 503 + 人话提示(附邮箱),
     而不是抛未捕获异常变成 HTML 500;计数写失败也不再影响提交本身。
   - 前端"⚠️ 这题有问题"弹层点「提交反馈」→ 本函数把 {题目, 题目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(":");
      try {
        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 });
      } catch (e) { /* 计数失败不影响提交 */ }
    }
    // IP 级兜底:同一 IP 每小时 ≤100 条,防脚本批量灌库
    const rateKey = ["fb:rl", "ip", ip, hour].join(":");
    try {
      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 });
    } catch (e) { /* 计数失败不影响提交 */ }

    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) {
      try { await kv.put("feedback:" + id, val, { expirationTtl: 180 * 24 * 3600 }); }
      catch (e) {
        return json({ ok: false, error: { message: "云端存储暂时不可用,反馈没提交成功。可以直接发邮件 2515591683@qq.com,谢谢!" } }, 503);
      }
    }
  }
  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(2681 字节)

/* Cloudflare Pages Function:前端错误上报(白屏/JS 异常/资源加载失败)。
   - 前端 window.onerror + unhandledrejection 收拢后 POST 到本接口。
   - 反滥用:进程内计数(同 IP 每分钟 ≤30、每小时 ≤120),不写 KV 计数(免费版每天只有 1000 次写)。
   - 落 KV 键 error:<ISO>:<rand>;KV 写失败不再 500,只如实回 {ok:true, stored:false}。
   - 字段截断,不落任何学习数据。 */
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 ip = String(req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
  const now = Date.now();
  if (!allow(ip + ":m", Math.floor(now / 60000), 30) || !allow(ip + ":h", Math.floor(now / 3600000), 120)) {
    return json({ ok: true, error: "rate limited" }, 429);
  }

  const kv = context.env.SYNC_KV;
  if (!kv) return json({ ok: true, stored: false }, 200);
  const id = 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(now).toISOString(), msg, stack, url, line, col,
    d: deviceId, p: locationHint(req, url),
    ua: String(req.headers.get("User-Agent") || "").slice(0, 120)
  });
  if (val.length > 4096) return json({ ok: true, stored: false }, 200);
  try { await kv.put("error:" + id, val, { expirationTtl: 7 * 24 * 3600 }); }
  catch (e) { return json({ ok: true, stored: false, error: "storage_unavailable" }, 200); }
  return json({ ok: true, stored: true }, 200);
}

/* 进程内限流:窗口键变化即视为新窗口 */
const HITS = new Map();
function allow(id, windowId, limit) {
  const k = id + ":" + windowId;
  const n = (HITS.get(k) || 0) + 1;
  HITS.set(k, n);
  if (HITS.size > 5000) { for (const key of HITS.keys()) { HITS.delete(key); if (HITS.size <= 4000) break; } }
  return n <= limit;
}
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"];

functions/api/push-subscribe.js(9374 字节)

/* Cloudflare Pages Function:Web Push 订阅上报(存 / 删)。
   路径:/api/push-subscribe  (同一 origin,**客户端不跨域**)

   为什么必须是同源 Pages Function(W47 实测结论,重要):
     * 本机实测 `*.workers.dev` 在本网络下 TCP 连接直接超时(curl 000,连
       `https://example.workers.dev/` 也不通),而 `vetquiz.pages.dev` 返回 200。
       ⇒ 客户端**不能**把订阅 POST 到 Cron Worker 的 workers.dev 域名,否则中国大陆用户必失败。
     * 所以上报走同源 Pages Function;Cron Worker(vetquiz-push)只负责定时扫描与发送,
       它和本 Function 读写**同一个 KV 命名空间**(Pages 侧绑定名 SYNC_KV,
       Worker 侧绑定名 PUSH_KV,id 都是 95db106bf3c54c3d9e2c6e957a407eb1)。
     * 复用 SYNC_KV 而**不新增绑定** ⇒ 不动 vetquiz-web/wrangler.toml、不改 Pages 部署配置。
       代价:订阅记录与「端到端加密的进度密文」同处一个命名空间;两者键空间不重叠
       —— 进度密文用裸 64 位十六进制键,订阅记录一律带 `sub:` 前缀,且本 Function
       只读写 `sub:*`,永不触碰进度键。

   安全口径(沿用 functions/api/sync.js):
     · Origin 校验:带 Origin 的跨站请求一律 403;无 Origin(服务端/命令行)放行,真正防线是限流。
     · 限流:进程内计数为主(零 KV 写)+ KV 兜底(用量过半才写);KV 异常 fail-open。
     · 体积上限 MAX_BYTES = 4096(订阅记录约 300–600 字节;同步端点那种 200KB 上限在这里没有意义)。
     · **只存推送所需的最小字段**:endpoint / keys / tz / remindHHMM / pendingCount / lastSyncAt。
       入参里其余任何键(尤其是学习数据)一律丢弃 —— 本 Function 不认识、也不落库任何学习数据。
     · 入参校验:endpoint 必须 https、keys.p256dh 必须是 65 字节未压缩点、keys.auth 必须 16 字节、
       tz 必须是合法 IANA 时区、remindHHMM 必须 HH:MM、pendingCount 必须 0..999 的整数。
     · 客户端**不会**拿到 VAPID 私钥;私钥只在发送端 Worker 的 secret 里。 */

const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
const KV_PREFIX = "sub:";
const MAX_BYTES = 4096;
const WINDOW_S = 600;
const LIMIT_IP = 150;
const LIMIT_WRITE = 60;
const BACKSTOP_AT = 0.5;
const TTL_DAYS = 400;
const RECORD_V = 1;
const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
const TZ_RE = /^[A-Za-z0-9_+\-/]{1,64}$/;

const RATE = new Map();
let RATE_BUCKET = 0;
function bump(id) {
  const b = Math.floor(Date.now() / (WINDOW_S * 1000));
  if (b !== RATE_BUCKET) { RATE.clear(); RATE_BUCKET = b; }
  const n = (RATE.get(id) || 0) + 1;
  RATE.set(id, n);
  return n;
}

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 onRequestPost(context) { return upsert(context); }
export async function onRequestPut(context) { return upsert(context); }

export async function onRequestDelete(context) {
  const req = context.request;
  if (!originAllowed(req)) return json({ error: "forbidden origin" }, 403);
  const ip = ipOf(req);
  const ipUsed = bump("ip:" + ip);
  if (ipUsed > LIMIT_IP) return tooMany(secsToNextWindow(), LIMIT_IP);
  let endpoint = new URL(req.url).searchParams.get("endpoint") || "";
  if (!endpoint) {
    try { const j = await req.json(); endpoint = String((j && j.endpoint) || ""); } catch (e) { endpoint = ""; }
  }
  const u = endpointOf(endpoint);
  if (!u) return json({ error: "bad-endpoint" }, 400);
  const kv = context.env.SYNC_KV;
  if (!kv) return storageDown();
  try { await kv.delete(KV_PREFIX + await sha256hex(endpoint)); }
  catch (e) { return storageDown(e); }
  return json({ ok: true, deleted: true }, 200);
}

async function upsert(context) {
  const req = context.request;
  if (!originAllowed(req)) return json({ error: "forbidden origin" }, 403);
  const ip = ipOf(req);
  const ipUsed = bump("ip:" + ip);
  if (ipUsed > LIMIT_IP) return tooMany(secsToNextWindow(), LIMIT_IP);

  const raw = await req.text();
  if (raw.length > MAX_BYTES) return json({ error: "too large", max: MAX_BYTES }, 413);
  let j;
  try { j = JSON.parse(raw); } catch (e) { return json({ error: "bad body" }, 400); }

  const v = validateSubscribe(j, new Date().toISOString());
  if (!v.ok) return json({ error: v.error }, 400);

  const id = KV_PREFIX + await sha256hex(v.record.endpoint);
  const used = bump("write:" + id.slice(4, 20));
  if (used > LIMIT_WRITE) return tooMany(secsToNextWindow(), LIMIT_WRITE);

  const kv = context.env.SYNC_KV;
  if (!kv) return storageDown();
  let prev = null;
  try { const old = await kv.get(id); if (old) prev = JSON.parse(old); } catch (e) { /* 读失败按新记录处理 */ }
  const rec = Object.assign({}, prev || {}, v.record, {
    createdAt: (prev && prev.createdAt) || new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  });
  /* 当天已发的标记不能被「刷新 pendingCount」刷掉,否则同一天会重复打扰 */
  if (prev && prev.lastSentDate) rec.lastSentDate = prev.lastSentDate;
  try { await kv.put(id, JSON.stringify(rec), { expirationTtl: TTL_DAYS * 86400 }); }
  catch (e) { return storageDown(e); }

  return json({
    ok: true, id: id,
    pendingCount: rec.pendingCount, remindHHMM: rec.remindHHMM, tz: rec.tz,
    storedFields: ["endpoint", "keys", "tz", "remindHHMM", "pendingCount", "lastSyncAt"],
  }, 200, rateHeaders(used, LIMIT_WRITE, ipUsed));
}

/* ---------------- 入参校验(与 workers/push/src/index.js 的 validateSubscribe 同口径) ---------------- */

function endpointOf(s) {
  const t = String(s || "").trim();
  if (t.length < 12 || t.length > 1024) return null;
  let u;
  try { u = new URL(t); } catch (e) { return null; }
  if (u.protocol !== "https:") return null;
  if (!u.hostname) return null;
  if (u.username || u.password) return null;
  return u;
}

function b64uDecode(str) {
  const t = String(str || "").trim().replace(/-/g, "+").replace(/_/g, "/");
  if (!t || !/^[A-Za-z0-9+/]*={0,2}$/.test(t)) return null;
  const pad = t.length % 4 === 0 ? "" : "=".repeat(4 - (t.length % 4));
  let raw;
  try { raw = atob(t + pad); } catch (e) { return null; }
  const out = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
  return out;
}

async function sha256hex(str) {
  const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
  return Array.from(new Uint8Array(d)).map((x) => x.toString(16).padStart(2, "0")).join("");
}

export function validateSubscribe(j, nowIso) {
  if (!j || typeof j !== "object") return { ok: false, error: "bad-body" };
  const endpoint = String(j.endpoint || "").trim();
  if (!endpointOf(endpoint)) return { ok: false, error: "bad-endpoint" };

  const keys = j.keys || {};
  const p256dh = String(keys.p256dh || "").trim();
  const auth = String(keys.auth || "").trim();
  const pk = b64uDecode(p256dh), ak = b64uDecode(auth);
  if (!pk || pk.length !== 65 || pk[0] !== 4) return { ok: false, error: "bad-p256dh" };
  if (!ak || ak.length !== 16) return { ok: false, error: "bad-auth" };

  const tz = String(j.tz || "Asia/Shanghai").trim();
  if (!TZ_RE.test(tz)) return { ok: false, error: "bad-tz" };
  try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); } catch (e) { return { ok: false, error: "bad-tz" }; }

  const remindHHMM = String(j.remindHHMM || "20:00").trim();
  if (!HHMM_RE.test(remindHHMM)) return { ok: false, error: "bad-remind" };

  const n = Number(j.pendingCount);
  if (!Number.isFinite(n) || n < 0 || n > 999) return { ok: false, error: "bad-pending" };

  const lastSyncAt = String(j.lastSyncAt || nowIso || new Date().toISOString());
  if (!Number.isFinite(Date.parse(lastSyncAt))) return { ok: false, error: "bad-lastSyncAt" };

  return {
    ok: true,
    record: {
      v: RECORD_V,
      endpoint: endpoint,
      /* 白名单:只有这些字段会被写进 KV,入参里的其它键(含任何学习数据)一律丢弃 */
      keys: { p256dh: p256dh, auth: auth },
      tz: tz,
      remindHHMM: remindHHMM,
      pendingCount: Math.floor(n),
      lastSyncAt: lastSyncAt,
      ua: String(j.ua || "").slice(0, 40),
    },
  };
}

/* ---------------- 工具 ---------------- */

function ipOf(req) {
  return String(req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
}
function originAllowed(req) {
  const origin = req.headers.get("Origin");
  if (!origin) return true;
  try {
    const o = new URL(origin);
    const self = new URL(req.url);
    if (o.host === self.host) return true;
    return ALLOW_ORIGINS.some((a) => origin === a || origin === a + "/");
  } catch (e) { return false; }
}
function corsHeaders(origin, jsonBody) {
  const h = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
  if (jsonBody) h["Content-Type"] = "application/json";
  if (origin && originAllowed({ url: "https://vetquiz.pages.dev/", headers: new Headers({ Origin: origin }) })) {
    h["Access-Control-Allow-Origin"] = origin;
    h["Vary"] = "Origin";
    h["Access-Control-Allow-Methods"] = "POST,PUT,DELETE,OPTIONS";
    h["Access-Control-Allow-Headers"] = "Content-Type";
  }
  return h;
}
function rateHeaders(used, limit, ipUsed) {
  return { "x-ratelimit-limit": String(limit), "x-ratelimit-used": String(used), "x-ratelimit-ip-used": String(ipUsed) };
}
function json(obj, status, extra) {
  const h = Object.assign({ "Content-Type": "application/json", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }, extra || {});
  return new Response(JSON.stringify(obj), { status, headers: h });
}
function tooMany(retry, limit) {
  return json({ error: "too many requests", retry_after: retry }, 429, { "Retry-After": String(retry), "x-ratelimit-limit": String(limit) });
}
function storageDown(err) {
  return json({
    error: "storage_unavailable",
    message: "云端存储暂时不可用(免费额度用尽或临时故障)。提醒开关在本机仍然保留,稍后会自动重试上报。",
    detail: err ? String((err && err.message) || err).slice(0, 120) : "no kv binding",
  }, 503);
}
function secsToNextWindow() { return WINDOW_S - Math.floor((Date.now() / 1000) % WINDOW_S); }

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

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

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

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

六、数据随时可以搬走

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