这个应用是纯静态页面 + 6 个单文件后端接口:题库、背书、模考、备份全部在浏览器里跑,学习数据只存在你自己的设备上。只有三件事需要服务器——可选的跨设备加密同步、AI 转发、以及纠错与报错收集。这 6 个接口的源码下面全文公开,你也可以自己搭一份。
| 路由 | 方法 | 作用 | 收到什么 | 它能不能看到你的内容 | 防护 |
|---|---|---|---|---|---|
/api/sync | GET · PUT | 跨设备同步那一条进度记录(可选功能) | 64 位指纹(记录名)+ base64 密文;PUT 空值=删除这条记录 | 看不到内容:不解析 data,也没有你的密码与派生密钥 | 来源校验 + 指纹格式校验 + 每 IP / 每指纹限流 + 单条 200KB 上限 |
/api/ai | POST | AI 讲解 / 模拟卷 / 助教问答的转发通道 | 你提问的题目与上下文(明文)、匿名设备标识、配额计数 | 能看到你发给 AI 的内容(它必须转发给模型服务商才能生成回答) | 同源校验 + 设备徽章(HMAC)+ 每设备每天 50 次 / 每 IP 每天 300 次 + 模型白名单 |
/api/ai-issue | GET | 给匿名设备签发一枚 HMAC 徽章(AI 配额的第二道门槛) | 匿名设备标识(前端随机生成,可随时清空) | 不涉及任何学习内容 | 同源校验 + 每 IP 每小时最多 20 枚 |
/api/feedback | POST · GET | 接收题库纠错/反馈;GET 是给站长看的只读看板(需口令) | 题目原文、题号、你写的说明、设备标识前 24 位、User-Agent(明文存 KV,180 天自动过期) | 能看到你提交的反馈文字(纠错本来就要人读) | 同源校验 + 每设备每小时 10 条 / 每 IP 每小时 100 条 + 单条 4KB |
/api/error | POST | 接收前端报错(白屏 / 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); }
/api/ai,再由它转发给模型服务商 DeepSeek(默认模型 deepseek-chat,可在白名单内选择 deepseek-reasoner)。密钥只存在后端环境变量里,前端拿不到,也不会把你的 Key 发给任何第三方。online/。SYNC_KV(6 个接口共用它做存取与限流计数)。DEEPSEEK_API_KEY(要 AI 功能才需要)、AI_BADGE_SECRET、AI_DEVICE_DAILY、AI_IP_DAILY、FB_VIEW_TOKEN;把各文件里的 ALLOW_ORIGINS 改成你自己的域名。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 删掉。[vars] 里(部署管线限制,见仓库注释),这一点也一并说明,不藏着。PBKDF2-SHA256(15 万次迭代、固定盐)→ ① AES-256-GCM 加密钥(只在内存里);② SHA-256(盐 :: 密码) 作为记录名(指纹,不可逆)。crypto.getRandomValues 生成,只显示给你、只以密文形式参与包裹。kvKeys / envSeal / kvEncrypt;③ Network 里看 PUT /api/sync 的请求体只有指纹与 base64 密文;④ 直接读上面的后端源码。{ __app, data:{...各存储键...} };加密导出则是 { __app, __enc:1|2, ... }。