Quotify 引语:无数据库部署 Cloudflare 的仿一言 API 方案|软件开发
想做一个仿一言 API:随机返回一条或多条引语,能按分类、标签筛选,也能按关键词搜索。功能不复杂,麻烦的是部署——为这点读流量单独维护一个数据库,不值。
Quotify 就是冲着这个场景去的。它把数据源放在一个或多个 data.json 里,部署在 Cloudflare Workers 上,运行时用 Cloudflare KV 做懒加载缓存:筛选、搜索、随机都在 Worker 内存里算完,整条链路不用 D1,也不依赖任何 Node.js 专有 API。
取名取自 quote,中文名 引语。功能定位参考一言(Hitokoto),但原生接口是一套自己的约定,并不是 Hitokoto 的兼容实现;只有确实需要零改动迁移既有一言客户端时,才会用到 /v1/hitokoto 这层兼容壳。多个数据来源可以同时配置,随机抽取时合并成一个总池。
技术底盘:Workers + KV + 一份 JSON
后面的设计取舍都建立在这套底盘上,先把清单列清楚:
- 运行时:Cloudflare Workers(V8 Isolates)
- 语言:TypeScript(
strict: true,禁用any) - 框架:Hono
- 数据源:默认由
DATA_MANIFEST_URL指向的来源清单提供(改清单文件即可动态增减来源,无需重新部署),可另配DATA_SOURCES作静态兜底;每个来源独立缓存、独立容错 - 缓存:Cloudflare KV(每个来源各缓存一份,TTL 300 秒)
- 限流:Cloudflare Rate Limiting Binding(公开 60 次/分钟/IP,管理 10 次/分钟/IP,
/与/api/health300 次/分钟/IP) - 测试:Vitest +
@cloudflare/vitest-pool-workers(测试真实运行在 Workers 运行时中) - 工程化:ESLint + Prettier + TypeScript 严格模式(含
noUncheckedIndexedAccess),CI 全量校验 - bundle:约 93 KiB(gzip 约 23 KiB),远低于 1 MB 上限
这些能力有一个共同点:KV、Rate Limiting 都以绑定(binding)的形式写在 wrangler.toml 里,全部落在 Workers 平台自身,没有额外要维护的外部服务。引语接口本身轻,不值得为它单独跑一台机器。这也是我愿意把它托管在 Cloudflare 上的主要原因。
一次请求走过的路
数据从来源仓库到响应,中间只经过一层 Worker 和一层 KV:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
来源清单 sources.json(默认,DATA_MANIFEST_URL)
│ ["url", "url", ...](与 DATA_SOURCES 同格式)
▼
数据源 0..n(GitHub raw / 任意 HTTPS)
│ data.json × n(每个来源格式相同)
▼
Cloudflare Worker (quotify-api)
│ 1. 进程内热缓存(60s,避免每请求重复解析)—— 命中即返回
│ 2. 解析来源列表 = DATA_SOURCES + 清单(去重、截断到 20 个)
│ 3. 并发查各来源的 KV 缓存 (quotify:data:v1:<URL 指纹>)
│ 4. 未命中 / 过期 → 并发回源
│ (5s 超时 + 边缘缓存 60s;携带 If-None-Match,304 则复用旧数据并续期)
│ 5. 校验 JSON → 各来源回写自己的 KV (TTL 300s) + meta(含来源 URL 与上游 ETag)
│ 6. 合并所有可用来源为一个总池(单个来源失败只跳过它,不拖垮整体)
▼
Cloudflare KV (quotify-cache)
▼
API 响应
这里有一个容易忽略的细节:热缓存以 isolate 为单位,只用来省掉「读 KV + 全量解析」这一步,并不承担一致性保证。管理端 /refresh 会用重新加载后的结果覆盖当前 isolate 的副本;多 isolate 并存时,数据最多陈旧一个热缓存 TTL(60s)。把「省事」和「保证一致」这两件事分开,后面很多行为才说得通。
代码结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
quotify/
├── src/
│ ├── index.ts # Worker 入口:安全头、CORS、根域重定向、健康检查、路由挂载、统一错误处理
│ ├── config.ts # 常量、KV 键(URL 指纹)、来源列表解析、TTL 夹紧、重定向与来源白名单校验
│ ├── routes/
│ │ ├── quotes.ts # 公开查询路由
│ │ ├── admin.ts # 管理路由
│ │ └── compat.ts # Hitokoto 兼容层(/v1/hitokoto:字段映射 + 分类码 + JSONP)
│ ├── data/
│ │ ├── loader.ts # 多来源懒加载 / 合并 + 各来源独立 KV 与条件请求 + 数据校验
│ │ │ # (含 single-flight 回源去重、失败重试、按 id 去重)
│ │ ├── store.ts # 内存查询(filter / search / random / paginate)+ 派生索引记忆化
│ │ └── types.ts # Quote / QuoteDataset 类型
│ ├── middleware/
│ │ ├── security.ts # 通用安全响应头
│ │ ├── cors.ts # CORS + OPTIONS 预检
│ │ ├── auth.ts # 管理接口 Bearer 鉴权(恒定时间比较)
│ │ └── rateLimit.ts # 速率限制(公开 / 管理 / 宽松三档)
│ ├── utils/
│ │ ├── response.ts # 统一响应格式 + 缓存头 / 304 / ETag 宽松匹配
│ │ └── error.ts # 错误类型 + 参数校验
│ └── types/
│ └── env.ts # 应用面向的 Env 绑定视图(唯一契约)
├── tests/
│ ├── api.test.ts # 端到端集成测试
│ ├── store.test.ts # store 纯函数单测
│ ├── config.test.ts # 配置解析与边界单测
│ ├── hardening.test.ts # 安全 / 缓存 / 回源容错加固项
│ ├── compat.test.ts # Hitokoto 兼容层
│ ├── data.test.ts # 示例数据集校验
│ └── test-token.ts # 测试用 ADMIN_TOKEN(与 vitest.config.ts 共用)
├── data/
│ ├── data.json # 示例数据集(推送到 quotify-data 仓库)
│ └── schema.json # 数据集 JSON Schema(供外部工具复用)
├── .github/workflows/ci.yml # CI:类型生成 + 依赖审计 + 风格 + 静态检查 + typecheck + test
├── .github/dependabot.yml # 依赖自动升级
├── eslint.config.js / .prettierrc.json / .prettierignore
├── .dev.vars.example # 本地开发密钥模板
├── wrangler.toml
├── vitest.config.ts
├── tsconfig.json
└── package.json
worker-configuration.d.ts 由 npm run cf-typegen 生成、已在 .gitignore 中:业务代码只依赖 src/types/env.ts 这份契约,避免生成物与配置漂移。
接口契约
命名约定:契约用 quote,品牌用 quotify
这个项目只用两个词,各司其职,避免同一个概念出现多种叫法:
| 层 | 用词 | 示例 |
|---|---|---|
| 公开契约 + 数据模型 | quote | GET /api/quotes、数据集字段 quotes、类型 Quote / QuoteDataset |
| 项目 / 品牌标识 | quotify | 包名 quotify-api、KV 键前缀 quotify:、service: "quotify" |
公开接口、数据集字段与代码类型统一使用通用的 quote,理由有两条。
一是语义准确。这个 API 收录的是「有出处、有作者的引语」——诗词、骈文、戏剧台词、箴言、讲义摘句,正是 quote / quotation 的范畴,Quotify 这个项目名也由此而来。
二是契约友好。路径与数据集字段是长期对外契约,也是最难变更的部分,用通用词对使用者更友好,也是这类 API 的通行叫法。Quotify / quotify 只作为项目品牌保留在包名、KV 键前缀与服务标识里,不再出现在对外契约中。
中文名「引语」是
quote的直译,仅用于对外称呼与文档表述,不参与任何标识符命名。
统一外壳与响应头
所有接口(含错误)统一返回同一个外壳:
1
{ "status": 200, "message": "ok.", "data": {}, "ts": 1581759895072 }
状态码约定是:200 成功 / 400 参数错误 / 401 未授权 / 404 未找到 / 429 限流 / 500 服务器错误。
在响应头上有几条硬性规则:
- 所有响应都带
Access-Control-Allow-Origin: *(含错误响应),以及安全头X-Content-Type-Options: nosniff、Referrer-Policy: no-referrer、X-Frame-Options: DENY、Cross-Origin-Resource-Policy: cross-origin。 - 除了分类 / 标签这类内容稳定的派生接口,其余响应一律带
Cache-Control: no-store。原因不难想:随机接口一旦被中间代理缓存,所有用户就会看到同一条引语。 - 路径末尾的斜杠会被忽略:
/api/quotes与/api/quotes/等价(Hono 以strict: false启动)。 - 参数取值有长度与格式上限:
limit/offset只接受十进制整数(0x10、1e3会被拒绝),offset上限 10000,q/category/tag单个取值上限 100 个字符。
公开接口
GET / — 根域自动重定向
直接访问根域(例如 https://quotify-api.<your-subdomain>.workers.dev/)会 302 跳转到 /api/quotes/,避免用户看到裸 404。跳转只改写路径、保留原始查询串,因此 /?limit=5&format=text 等价于 /api/quotes/?limit=5&format=text。
- 目标可通过环境变量
ROOT_REDIRECT配置(默认/api/quotes/)。 - 出于安全考虑只接受站内绝对路径(以单个
/开头),配置成外链等非法值会回退到默认值,避免开放重定向。反斜杠(/\evil.com)与控制字符同样被拒绝——浏览器会把\规范化成/,从而绕过//检查。 HEAD /与GET /行为一致(Hono 会按 GET 处理并剥离响应体)。
GET /api/quotes — 随机一言
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
category | string | 无 | 分类,多个用逗号分隔(OR),最多 50 个 |
tag | string | 无 | 标签,多个用逗号分隔(OR),最多 50 个 |
limit | number | 1 | 返回数量,1–20(超出上限收敛到 20) |
format | string | json | json 或 text |
data 始终为数组。format=text 时直接返回纯文本 content(多条以换行分隔),Content-Type: text/plain; charset=utf-8。category / tag 的取值个数超过 50 个时返回 400。
GET /api/quotes/search — 关键词搜索
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
q | string | 必填 | 关键词,最少 1 个字符 |
category | string | 无 | 分类过滤,最多 50 个 |
tag | string | 无 | 标签过滤,最多 50 个 |
limit | number | 10 | 1–50 |
offset | number | 0 | 偏移量,≥ 0 |
匹配方式是在 content / author / source 中做大小写不敏感的子串匹配。category 与 tag 之间是 AND,同一参数内多个值是 OR。
GET /api/quotes/categories / GET /api/quotes/tags
返回去重、按拼音排序的字符串数组。结果在同一数据集内稳定,因此响应带 Cache-Control: public, max-age=<min(3600, DATA_TTL)>(默认 300)与 ETag;客户端带 If-None-Match 再次请求时返回 304(无正文),并同样带 CORS 与安全头。缓存时长与数据集 TTL 取小,是为了避免客户端缓存得比服务端刷新周期还久。
这里的 ETag 由全量内容哈希而来,而不是只看 version / updated_at / 条数。因此维护者改了分类或标签、却没更新 updated_at,ETag 也会变化,不会一直返回过期目录。If-None-Match 采用宽松匹配:容忍 W/ 前缀差异、多值列表与代理追加的 -gzip 之类后缀。
运维接口
GET /api/health — 健康检查
该接口只读缓存状态(优先命中热缓存,不触发回源)。data.sources 表示「已缓存来源数 / 配置来源总数」。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"status": 200,
"message": "ok.",
"data": {
"service": "quotify",
"environment": "production",
"cached": true,
"state": "warm",
"manifest_cached": true,
"cache_loaded_at": 1759000000000,
"total": 1204,
"sources": { "loaded": 2, "total": 2 }
},
"ts": 1759000000123
}
state 用来区分「还没加载过」与「真的出问题了」,避免监控误报:
state | 含义 | 是否需要告警 |
|---|---|---|
warm | 所有来源都有可用缓存 | 否 |
degraded | 部分来源加载失败(见 /api/admin/stats 的 failures) | 视情况 |
cold | 这个 isolate 还没加载过(冷启动时的正常状态) | 否 |
真正需要告警的组合是 state === "cold" 且 manifest_cached === true:说明来源已经枚举出来了,却一个都没加载成功。
POST /api/admin/refresh — 强制刷新缓存(需鉴权)
刷新对每个来源分别执行「重新拉取 → 校验通过后覆盖自己的 KV」(put 会重置值与 TTL)。它不会先删键:这样上游数据非法时,该来源的缓存仍保留上一份合法数据,不会出现空缓存窗口。若配置了来源清单,清单也会一并刷新。
单个来源失败不影响其它来源,其结果记录在 failures 里,服务照常使用其余来源;只有全部来源都失败才返回 500。
若带上一次记录的上游 ETag 请求,上游返回 304 时直接复用缓存数据并仅续期 KV,不重新下载与解析正文;此时 total 仍与当前数据集一致。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"status": 200,
"message": "ok.",
"data": {
"refreshed": true,
"total": 1204,
"loaded_at": 1759000000000,
"sources": [
{ "url": "https://.../a.json", "total": 600, "loaded_at": 1759000000000, "cached": false }
],
"failures": [],
"manifest": { "url": "https://.../sources.json", "count": 2, "error": null }
},
"ts": 1759000000123
}
GET /api/admin/stats — 数据统计(需鉴权)
返回 { total, categories, tags, version, updated_at, cached, cache_loaded_at, sources, failures, manifest }。其中 version / updated_at 是各来源的聚合值(取较大者),按来源的明细在 sources 与 failures 中;manifest 是清单状态 { url, count, error },未配置清单时为 null。来源出问题时先看这里。
鉴权方式是请求头 Authorization: Bearer <ADMIN_TOKEN>,Token 采用恒定时间比较。
兼容层:给一言客户端留一扇门
GET /v1/hitokoto 以 Hitokoto 的契约返回裸对象(不套统一外壳),便于既有的一言客户端零改动迁移。字段映射关系如下:
| Hitokoto 字段 | 来源 |
|---|---|
id | 由 quote.id 指纹稳定派生的数字 |
uuid | quote.id |
hitokoto | quote.content |
type | quote.category 映射到的分类码(未收录 → g) |
from | quote.source(缺省空串) |
from_who | quote.author(缺省空串) |
creator / creator_uid / reviewer | 固定为 quotify / 0 / 0 |
commit_from | 固定 api |
created_at | 数据集的 updated_at |
length | content 的长度 |
分类码沿用 a–l:动画 a、漫画 b、游戏 c、文学 d、原创 e、网络 f、其他 g、影视 h、诗词 i、网易云 j、哲学 k、抖机灵 l。
| 参数 | 默认 | 说明 |
|---|---|---|
c | 无 | 分类码(a–l),多个用逗号分隔(OR) |
encode | json | json / text / js(JSONP) |
callback | 无 | encode=js 时必填,必须是合法 JS 标识符(防脚本注入) |
min_length / max_length | 无 | 按 content 长度过滤 |
1
2
3
curl "$BASE/v1/hitokoto?c=d&encode=json"
curl "$BASE/v1/hitokoto?encode=text"
curl "$BASE/v1/hitokoto?encode=js&callback=hitokoto_cb"
兼容层只做字段与参数映射,不改原生接口;/ 的行为也不变(仍是 302 到 /api/quotes/,与 v1.hitokoto.cn 直接返回 JSON 不同)。
数据与配置
数据集格式与校验
数据集放在独立仓库 quotify-data:按 category 拆分到 data/ 下的多个文件,根目录的 sources.json(来源清单)列出这些文件的地址。
本项目默认就是这么配的——DATA_SOURCES 留空、DATA_MANIFEST_URL 指向该清单,因此维护数据不需要重新部署 Worker:新增一个分类,只需在 data/ 加文件并登记到 sources.json。
1
2
3
4
5
6
7
8
9
quotify-data/
├── sources.json # 来源清单(列出下面全部数据文件)
└── data/
├── internet.json # 网络
├── literature.json # 文学
├── technology.json # 科技
├── philosophy.json # 哲学
├── film.json # 影视
└── wisdom.json # 哲理
每个数据文件都使用下面这同一种格式;运行时所有文件会被合并成一个总池,随机抽取覆盖全部文件,搜索、分类、标签也跨文件聚合:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"version": 1,
"updated_at": "2026-09-26T12:00:00Z",
"quotes": [
{
"id": "e1f3a2",
"content": "人生如逆旅,我亦是行人。",
"source": "临江仙·送钱穆父",
"author": "苏轼",
"category": "文学",
"tags": ["古诗", "人生"]
}
]
}
校验规则写在 src/data/loader.ts:
version必须是数字。quotes必须是数组。- 每条记录必须含非空字符串
id与content。 source/author/category可选,若存在必须是字符串;tags可选,若存在必须是字符串数组。- 非法数据直接返回
500并记录日志,不会静默丢弃,也不会写入 KV。 - 多来源合并时按
id去重(保留靠前的来源),重复条目数记在/api/admin/stats的duplicates里。
仓库里 data/data.json 是一份可直接推送的示例数据集(10 条),并且已被纳入 CI 校验(npm run validate:data,见 tests/data.test.ts);data/schema.json 是等价的 JSON Schema,供编辑器与外部工具复用。
KV 键设计
KV 键的稳定性直接决定了增删来源是否安全,所以单独说一下:
| 键 | 值 | 说明 |
|---|---|---|
quotify:data:v1:<URL 指纹> | JSON 字符串 | 该来源的数据集(指纹 = 来源 URL 的 FNV-1a 哈希) |
quotify:meta:v1:<URL 指纹> | JSON 字符串 | { loaded_at, source_url, etag? };source_url 用于校验缓存归属,etag 用于条件请求 |
quotify:manifest:v1:<URL 指纹> | JSON 字符串 | 来源清单缓存:{ raw, loadedAt, etag }(未配置清单时不产生) |
键后缀由来源 URL 派生,而不是数组下标,因此增删或重排来源都不会让某个来源读到别人的缓存;读取时还会比对 meta 里的 source_url,即使哈希碰撞也只会退化成一次「未命中 + 回源」。
环境变量与密钥
| 名称 | 类型 | 默认 | 说明 |
|---|---|---|---|
DATA_SOURCES | var | [](不配置静态来源) | 数据集来源列表(JSON 字符串数组)。默认留空——来源全部由清单提供;需要静态兜底(清单不可用时仍有内容)时在此列出 |
DATA_MANIFEST_URL | var | 作者的 quotify-data/sources.json | 来源清单地址(JSON 字符串数组),与 DATA_SOURCES 合并去重(上限 20)。改清单即可动态增减来源、无需重新部署;显式留空则关闭清单 |
DATA_TTL | var | 300 | KV 缓存 TTL(秒)。会被夹紧到 [60, 31536000]:低于 60 会让 KV 写入直接抛错 |
DATA_SOURCE_HOSTS | var | ""(不限制) | 来源主机白名单(逗号分隔)。配置后,来源清单里解析出的地址必须命中白名单才允许加载(防 SSRF / 清单被篡改) |
ROOT_REDIRECT | var | /api/quotes/ | 根路径 302 重定向目标(仅接受站内绝对路径) |
ENVIRONMENT | var | production | 环境标识 |
ADMIN_TOKEN | secret | 无默认值 | 管理接口 Token。未配置时管理接口直接 500 并打告警 |
CACHE | KV binding | — | 缓存命名空间 |
PUBLIC_RATE_LIMITER | ratelimit binding | 60 / 60s | 公开接口限流 |
ADMIN_RATE_LIMITER | ratelimit binding | 10 / 60s | 管理接口限流 |
HEALTH_RATE_LIMITER | ratelimit binding | 300 / 60s | / 与 /api/health 的宽松限流 |
生产环境必须执行 npx wrangler secret put ADMIN_TOKEN;本地开发请把 ADMIN_TOKEN=... 写进 .dev.vars(参考 .dev.vars.example,该文件已在 .gitignore 中)。
三处刻意设计
有三处设计是刻意的,值得单独说明。
ADMIN_TOKEN 不再提供默认值。遗漏 wrangler secret put 时,宁可让管理接口 500,也不能带着一个公开已知的口令上线。
限流只以 CF-Connecting-IP 分桶,不回退到 x-forwarded-for。后者是客户端完全可控的请求头,轮换它就能绕过限流。
关于 compatibility_date:规范要求 2026-09-01,但本地测试链路(@cloudflare/vitest-pool-workers 内置的 miniflare / workerd)当前最高支持 2026-08-22,因此这里取 2026-08-01,保证 dev / test / deploy 三端行为一致。
本地开发
1
2
3
4
5
6
7
8
9
10
11
npm install
# 准备本地密钥(管理接口用;未配置时管理接口会 500)
cp .dev.vars.example .dev.vars
# 启动本地开发服务器(默认 http://127.0.0.1:8787)
npm run dev
# 需要真实数据时,把 DATA_SOURCES 指向一个可访问的地址,例如本地静态文件服务
node -e "const http=require('http'),fs=require('fs');http.createServer((q,s)=>{s.setHeader('content-type','application/json');s.end(fs.readFileSync('data/data.json'))}).listen(8788)"
npx wrangler dev --var 'DATA_SOURCES:["http://127.0.0.1:8788/data.json"]'
其它脚本:
1
2
3
4
5
6
npm test # vitest run,在 Workers 运行时中执行全部测试
npm run typecheck # tsc --noEmit
npm run lint # ESLint 静态检查(npm run lint:fix 自动修复)
npm run format # Prettier 格式化(npm run format:check 只检查)
npm run validate:data # 校验 data/data.json
npm run cf-typegen # 修改 wrangler.toml 后重新生成 worker-configuration.d.ts
worker-configuration.d.ts 由 wrangler types 生成(包含 KVNamespace、RateLimit 等运行时类型以及全局 Env),已在 .gitignore 中,修改 wrangler.toml 后需要重新生成。业务代码只依赖 src/types/env.ts 这份契约,不直接使用生成的字面量类型。
Fork 与部署:换成你自己的数据
下面这段面向「Fork 一份、换成自己的数据集、部署到自己的 Cloudflare 账号」的场景。每一步都对应 wrangler.toml 里的注释,照着改即可。
前置条件
- 一个 Cloudflare 账号(Workers Free 计划即可)
- 已把仓库 Fork 到你的 GitHub 账号并 clone 到本地
- 本地执行过
npm install(Wrangler 是项目依赖,统一用npx wrangler调用) - 已执行
npx wrangler login完成授权
准备你自己的数据集
新建一个 GitHub 仓库(例如 my-quotes-data),在里面放数据集文件。推荐按 category 拆分到一个子目录(如 data/),一个分类一个文件,格式见上文「数据与配置」——根字段必须是 quotes,每条记录至少要有非空的 id 与 content。
可以直接复制仓库里的 data/data.json 当单文件模板,或参考作者仓库 quotify-data 的按分类拆分结构。
wrangler.toml中DATA_SOURCES的注释写着「任意公网可访问、且返回符合本项目数据格式的 JSON 的链接都可以」——也就是说数据集不一定要放在 GitHub,你自己的静态服务器或对象存储同样可行,只要 URL 公网可读。
把来源指向你自己的数据集
推荐的方式是清单驱动,改文件即可,无需重新部署。在数据仓库根目录放一份 sources.json,列出各个数据文件的地址,再把 DATA_MANIFEST_URL 指向它:
1
2
3
4
[
"https://raw.githubusercontent.com/<你的用户名>/my-quotes-data/main/data/poetry.json",
"https://raw.githubusercontent.com/<你的用户名>/my-quotes-data/main/data/sayings.json"
]
1
2
3
[vars]
DATA_SOURCES = '[]' # 留空:不配置静态来源,来源全部来自清单
DATA_MANIFEST_URL = "https://raw.githubusercontent.com/<你的用户名>/my-quotes-data/main/sources.json"
也可以退回静态来源,写法更直接,但改一次就要重新部署——把地址写进 DATA_SOURCES,并显式关闭清单:
1
2
3
[vars]
DATA_SOURCES = '["https://raw.githubusercontent.com/<你的用户名>/my-quotes-data/main/data/poetry.json"]'
DATA_MANIFEST_URL = ""
两种方式可以混用:DATA_SOURCES 与清单里的来源会合并去重(上限 20 个)。
对应的注释是:「Fork 后请换成你自己的仓库地址,否则你改不动数据」。保持默认值时,你的实例会一直读取作者的数据集,自己改数据不会有任何效果。
DATA_SOURCES必须是合法的 JSON 字符串数组。写错时不会让接口直接500:静态来源被降级为空列表(错误打到日志),来源改由清单提供;只有当清单也解析不出任何来源时,才会报500并提示「未解析到任何数据来源」。这样一处配置笔误不会让整个 API 不可用。DATA_MANIFEST_URL显式留空则关闭清单。
创建你自己的 KV 命名空间
1
npx wrangler kv namespace create quotify-cache
命令会输出一个 id,用它覆盖 wrangler.toml 里 [[kv_namespaces]].id:
1
2
3
[[kv_namespaces]]
binding = "CACHE"
id = "<上一步输出的 id>"
对应注释:「下面的 id 属于本项目作者,Fork 后你无权访问,必须换成自己的」。KV 命名空间 id 本身是公开信息、不是密钥,但它绑定在作者账号下,你无法读写。
设置管理接口 Token
wrangler.toml 里没有 ADMIN_TOKEN 默认值(避免把公开已知的口令带上线),需要自己注入:
1
2
3
4
5
# 本地开发:写进 .dev.vars(已在 .gitignore 中)
cp .dev.vars.example .dev.vars # 然后修改里面的 ADMIN_TOKEN
# 生产环境:用 secret
npx wrangler secret put ADMIN_TOKEN
未配置时管理接口(/api/admin/*)会直接返回 500 并在日志里告警——这是刻意设计:401 会暴露「接口存在、只是口令不对」,500 + 告警才是安全的失败方式。
按需调整其它变量
| 变量 | 是否必须改 | 说明 |
|---|---|---|
ROOT_REDIRECT | 否 | 访问根域时的 302 目标,默认 /api/quotes/;只接受站内绝对路径 |
DATA_TTL | 否 | KV 缓存 TTL(秒),默认 300;越界会被夹紧到 [60, 31536000] |
DATA_SOURCE_HOSTS | 建议 | 留空表示不限制主机;建议至少锁到你的数据托管域名 |
ENVIRONMENT | 否 | 环境标识,会出现在 /api/health 响应中 |
[[ratelimits]].namespace_id | 建议 | 见下方说明 |
[[kv_namespaces]].preview_id | 建议 | 见下方说明(否则 wrangler dev --remote 会读写生产 KV) |
对应注释:「访问根域(
/)时 302 跳转的目标(站内绝对路径),默认指向公开随机一言接口」。限流绑定的
namespace_id是「账号内唯一」的标识:同一账号下若已有 Worker 用了相同的值,两者会共享同一份限流计数。默认值已取较不常用的9101/9102/9103,若遇到意料之外的 429,把它们改成不冲突的数字。
preview_id缺省时wrangler dev --remote会直接读写生产 KV。请执行npx wrangler kv namespace create quotify-cache-preview并把输出 id 填进去。
部署
1
2
3
4
5
# 生产环境
npx wrangler deploy
# 预发布环境(用独立 KV,不会污染生产缓存)
npx wrangler deploy --env staging
部署完成后终端会输出形如 https://quotify-api.<你的子域>.workers.dev 的访问地址。
首次预热与验证
1
2
3
4
5
6
7
8
BASE=https://quotify-api.<你的子域>.workers.dev
curl "$BASE/api/health" # 首次:cached=false、total=0(KV 还是空的)
curl "$BASE/api/quotes" # 触发首次回源,返回你自己的数据
curl "$BASE/api/health" # 再次:cached=true、total 与你的数据集条数一致
# 主动刷新缓存(需要上一步设置的 Token)
curl -X POST -H "Authorization: Bearer <你的 ADMIN_TOKEN>" "$BASE/api/admin/refresh"
完整验收项见文末的验收清单。
日常维护
- 改引语内容:编辑数据文件并提交,等
DATA_TTL(默认 300 秒)自动生效,或调一次POST /api/admin/refresh立即生效。 - 新增分类 / 数据文件:在数据仓库新建
<slug>.json,把地址追加到sources.json,提交即可——不需要重新部署 Worker。 - 新增来源仓库:同上,把新仓库的数据文件地址登记进清单。
- 排查来源问题:
GET /api/admin/stats的sources/failures/manifest会显示每个来源的状态与清单解析结果。
清单里的来源会与 DATA_SOURCES 合并(去重后最多 20 个,超出部分丢弃并告警)。清单不可用或内容格式非法时不会影响服务:自动降级为「只用 DATA_SOURCES」或「沿用上一份合法清单」,具体原因可在 /api/admin/stats 的 manifest.error 中看到。
上线前的验收清单
npm test—— 全部用例通过(当前 138 个)。npm run typecheck—— 无类型错误。npm run lint && npm run format:check—— 无静态检查 / 风格问题。curl -i "$BASE/"—— 返回302且Location指向/api/quotes/。curl "$BASE/api/health"——status=200、data.service="quotify",data.state为warm/cold,data.sources显示「已缓存 / 配置总数」。- 首次
curl "$BASE/api/quotes"—— 返回数据且 KV 被写入;再次请求data.cached为true。 curl -i "$BASE/api/quotes"—— 含Cache-Control: no-store(随机结果不能被缓存)。curl -i -X OPTIONS "$BASE/api/quotes"—— 返回Access-Control-Allow-Origin: *。curl -i "$BASE/api/health"—— 含X-Content-Type-Options: nosniff与Referrer-Policy: no-referrer。curl -i "$BASE/api/quotes/categories"—— 含Cache-Control: public, max-age=300(默认 TTL)与ETag;带上该ETag的If-None-Match再次请求 —— 返回304。curl -X POST "$BASE/api/admin/refresh"(无 Token)—— 返回401。curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$BASE/api/admin/refresh"—— 返回200且total与数据仓库条数一致。- 把
data.json中某条记录的content置空并提交 —— 管理刷新接口应返回500,且 KV 中仍是上一份合法数据。 - 配好两个
DATA_SOURCES后curl "$BASE/api/admin/stats"——sources有两项,total等于两个数据集条数之和(若有重复id会被去重,看duplicates)。 - (可选)配置
DATA_MANIFEST_URL后curl "$BASE/api/admin/stats"——manifest显示清单地址、解析出的来源数,且清单里新增的来源已被合并进total。 - (可选)
curl "$BASE/v1/hitokoto?c=d"—— 返回含hitokoto/type/from的裸对象。
常见问题
- 访问根域 404? 根域会自动 302 到
/api/quotes/;若你改过ROOT_REDIRECT,确认它是站内绝对路径(以单个/开头)。 /api/quotes一直返回 500? 说明所有来源都加载失败了(单个来源失败只会被跳过)。常见原因是数据集格式不合规:根字段不是quotes,或某条记录缺id/content。校验不通过时不会写入缓存,日志里会指出是哪个来源、第几条、哪个字段。- 某个来源挂了会有影响吗? 不会:该来源被跳过,其余来源照常提供服务;
/api/admin/stats的failures里能看到具体原因和 URL。 - 管理接口返回 401? 检查请求头为
Authorization: Bearer <token>,且 token 与ADMIN_TOKEN一致。 - 管理接口返回 500 并提示未配置 ADMIN_TOKEN? 说明
ADMIN_TOKEN没注入成功:本地请把ADMIN_TOKEN=...写进.dev.vars,生产请执行npx wrangler secret put ADMIN_TOKEN。 /api/health一直是cold? 冷启动(或 isolate 刚被回收)时的正常状态,只要manifest_cached为false就只是「还没加载过」;若它已经是true却仍为cold,再去/api/admin/stats看failures。- 改了
wrangler.toml后类型报错? 执行npm run cf-typegen重新生成worker-configuration.d.ts。
curl 速查
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
BASE=https://quotify-api.<your-subdomain>.workers.dev
# 根域自动重定向(302 → /api/quotes/)
curl -i "$BASE/"
# 健康检查
curl "$BASE/api/health"
# 随机一条
curl "$BASE/api/quotes"
# 随机 5 条
curl "$BASE/api/quotes?limit=5"
# 纯文本输出(可直接接入终端脚本)
curl "$BASE/api/quotes?format=text"
# 按分类筛选
curl "$BASE/api/quotes?category=文学"
# 按标签筛选(OR)
curl --get "$BASE/api/quotes" --data-urlencode "tag=古诗,人生"
# 分类 + 标签组合(AND)
curl --get "$BASE/api/quotes" --data-urlencode "category=文学" --data-urlencode "tag=人生" --data-urlencode "limit=3"
# 关键词搜索
curl --get "$BASE/api/quotes/search" --data-urlencode "q=人生"
# 搜索 + 分页
curl --get "$BASE/api/quotes/search" --data-urlencode "q=人" --data-urlencode "limit=5" --data-urlencode "offset=5"
# 分类 / 标签枚举
curl "$BASE/api/quotes/categories"
curl "$BASE/api/quotes/tags"
# Hitokoto 兼容层(裸对象 / 纯文本 / JSONP)
curl "$BASE/v1/hitokoto?c=d"
curl "$BASE/v1/hitokoto?encode=text"
curl "$BASE/v1/hitokoto?encode=js&callback=hitokoto_cb"
# 参数错误示例(400)
curl "$BASE/api/quotes/search"
# CORS 预检
curl -i -X OPTIONS "$BASE/api/quotes"
# 管理接口:强制刷新缓存
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$BASE/api/admin/refresh"
# 管理接口:数据统计
curl -H "Authorization: Bearer $ADMIN_TOKEN" "$BASE/api/admin/stats"
写在最后:边界与代价
Quotify 想解决的问题很具体:想要一个能随机吐引语、能按分类和标签筛选、能搜索的接口,又不想为此维护数据库。数据直接以 JSON 落在能公网访问的地方。运行时靠 KV 缓存扛住读流量,筛选与随机都在内存里算完。
代价也都摆在明面上。数据规模受限于「一次能解析多少 JSON」,因此它适合几千到几万条量级的引语库,而不是海量语料;缓存的更新时间最坏是 DATA_TTL(默认 300 秒)加一个热缓存 TTL(60 秒),改完数据不会立刻全网可见——真要立即生效,就调一次 /api/admin/refresh。
如果这两条边界对你的场景不构成问题,那这套「无数据库、改清单即扩容」的方案,维护成本会低得让人省心。
