从假后端到 Cloudflare D1:一次医疗旅游网站的重构实践
这篇文章记录了我重构 ChinaHospital.info 的过程——一个面向国际患者、连接中国三甲医院的医疗旅游信息平台。旧项目存在三个结构性缺陷,这次用 Next.js + Cloudflare D1 + Drizzle ORM 彻底重建
背景
接手这堆破烂玩意是一个标准的"前端 Cloudflare Pages + 后端 Cloudflare Worker"双项目结构,Next.js 15 前端 + TypeScript Worker 后端。表面上线上运行正常,但代码审查后发现三个根本性问题。
旧项目的三个致命问题
1. 后端是"假的"
Worker 后端是一个 1469 行的单体 index.ts,路由通过字符串匹配分发。真是日了狗了,最严重的是:POST /api/meetings/create 和 /api/registrations/create 这类接口只校验输入就返回成功,不持久化任何数据。每次冷启动数据就丢了。
更隐蔽的是,Stripe 预约页面收集了患者信息(姓名、病情、护照),但只往后端发送了 {productId, price}——用户填的表单数据直接丢弃。
2. 数据三套并存且互相矛盾
同一份医院数据存在于三个地方:
- 后端 TypeScript 模块(
src-ts/data/hospitals.ts) - 前端 JSON 文件(
data/hospitals/hospitals.json) - 页面里的硬编码数组
而且这三份已经漂移了:后端的 240 小时签证 JSON 是损坏的(1 行/12KB 的乱码),前端那份才是正常的。lib/articles.ts 有一整条 markdown 渲染管线(react-markdown + rehype-highlight),但从未被任何页面调用——/guides/[slug] 是个空目录,直接 404。
3. Cloudflare Service Binding 配错了
wrangler.toml 声明了 Service Binding:
[[services]]
binding = "BACKEND_API"
service = "chinahospital-backend"
但消费代码读的是 process.env.BACKEND_API——Cloudflare Pages 运行时从不往 process.env 注入 binding(它只在 Pages Functions 的 onRequest handler 的 env 参数里)。所以 useServiceBinding 永远是 false,所有前端到后端的流量都走了公网 fetch 到 *.workers.dev URL。
重构决策:单应用 + D1
核心思路:一个 git 仓库、一个 Next.js 应用、一个 Cloudflare Worker、一个 D1 数据库。
为什么不继续用双项目?
Service Binding 的配置是复杂度的核心来源(而且它还是坏的)。单应用里,Route Handler 直接读 env.DB(D1 binding),零网络跳、零 Service Binding。
为什么不用 Vercel?
Vercel 免费的 Hobby 档禁止商业用途。这个站有付费服务,严格说必须用 Pro($20/月)。当然了,咱们也不在乎这个。 不过赛博菩萨 Cloudflare Workers 免费档没有这个限制。
技术栈
| 层 | 选择 | 原因 |
|---|---|---|
| 框架 | Next.js 15 App Router | 已有代码基础,团队熟悉 |
| 数据库 | Cloudflare D1 | 免费 5GB / 每天 500 万次读 |
| ORM | Drizzle ORM | 类型安全、生成 D1 migration、zero-overhead |
| 部署 | OpenNext (@opennextjs/cloudflare) |
替代 legacy next-on-pages,支持 Workers runtime |
| CI/CD | GitHub Actions | push 到 main 自动部署 |
为什么选 Drizzle 而不是 raw SQL 或 Prisma?
Prisma 对 Cloudflare D1 / Workers 的支持当时还不成熟。Drizzle 是轻量的 query builder,生成的 SQL 可以直接用 wrangler d1 migrations apply 执行,类型推导也足够好。
数据模型设计
所有内容(医院、文章、FAQ、法律文档、签证)和交易数据(联系表单、服务咨询)都进 D1。这从根上消灭了"三套数据并存"的问题。
// schema.ts 片段
export const hospitals = sqliteTable("hospitals", {
id: text("id").primaryKey(), // slug: "pumch"
name: text("name").notNull(),
nameCn: text("name_cn").notNull(),
level: text("level").notNull().default("3A"),
city: text("city").notNull(),
specialties: text("specialties").notNull().default("[]"), // JSON string[]
rating: real("rating"),
// ...
});
几个设计约定:
id是文本 slug(pumch、fuwai-beijing),稳定且对 SEO 友好- 数组/对象字段存 JSON 文本(SQLite 没有原生数组),在查询层
JSON.parse还原 - 金额存整数美分(
104900= $1,049.00),不用浮点 - 时间戳存 ISO-8601 文本(SQLite 的
CURRENT_TIMESTAMP默认就是这个)
种子数据:从旧 JSON 导入
写了一个 seed.ts 脚本,从旧前端 data/ 目录读 JSON + Markdown,清洗后写入 D1。这里踩了第一个大坑。
坑 1:价格解析的逗号 bug
旧数据里的价格是字符串:"$1,049 - $1,469"。我的解析函数:
// ❌ 错误:把逗号替换成空格
const nums = usd.replace(/[$,]/g, " ").split(/\s+/).map(Number);
// "$1,049" → "1 049" → [1, 049] → min=100 ($1.00), max=4900 ($49.00)
$1,049 被拆成了 1 和 049(=49),所以线上显示 PET-CT 价格为 $1.00 – $49.00,而旁边的人民币写的是 ¥7,500 – ¥10,500。数据明显矛盾,用户立刻就会怀疑数据质量。
修复:删除逗号而不是替换成空格:
// ✅ 正确:删除逗号
const nums = usd.replace(/[$,]/g, "").split(/[-–—\s]+/).map(Number);
// "$1,049" → "1049" → [1049, 1469] → min=104900, max=146900
影响 14+ 条含逗号的价格数据,部分还反转了(min > max)。
FAQ 的 SEO 问题:答案不在 HTML 里
坑 2:客户端条件渲染导致 Google 抓不到答案
FAQ 页面用手风琴组件展示问答:
// ❌ 答案只在点击后才渲染
{isOpen && <p>{answer}</p>}
isOpen 是 useState 初始为 false,所以服务端渲染的 HTML 里只有问题没有答案。Google 抓取到的是 30 个问题、0 个答案——大量的长尾 SEO 机会浪费了。
虽然 JSON-LD 的 FAQPage 结构化数据里包含了答案,但可见 HTML 里缺失仍然是问题。
修复:改用原生 <details>/<summary>,答案始终在 DOM 里,浏览器只负责切换可见性:
// ✅ 答案始终在 HTML 中
<details>
<summary>{question}</summary>
<p>{answer}</p>
</details>
这样 Google 既能抓到完整的问答文本,页面也不需要额外加载 JS 来显示答案。
CI/CD:GitHub Actions 自动部署 + 智能 Seed
部署 workflow
push 到 main 时自动执行:typecheck → next build → D1 migration → OpenNext build & deploy → 同步 Postmark secret。
# deploy.yml 核心步骤
- name: Build Next.js
run: pnpm build
- name: Apply D1 migrations (remote)
run: pnpm exec wrangler d1 migrations apply chos-db --remote
- name: Build & deploy Worker (OpenNext)
run: pnpm exec opennextjs-cloudflare build && pnpm exec opennextjs-cloudflare deploy
Seed 的自动触发
Seed 脚本是破坏性操作(先 DELETE 再 INSERT),不应该每次 push 都跑。用 paths 过滤器解决:
on:
push:
branches: [main]
paths:
- "seed-data/**"
- "scripts/seed*.ts"
workflow_dispatch:
只有改了 seed-data/ 里的内容数据时才自动触发 seed,改代码不触发。两个 workflow 共享 concurrency: deploy-prod,避免同时跑互相打架。
坑 3:Seed 脚本的 cwd 路径
Seed workflow 从 apps/web 目录运行 ../../scripts/seed.ts,但脚本内部 spawn wrangler 时用了 process.cwd():
// ❌ CI 里 process.cwd() = "apps/web"
// join(process.cwd(), "apps/web") = "apps/web/apps/web" → 不存在
execSync(`wrangler d1 execute ...`, {
cwd: join(process.cwd(), "apps/web"),
});
这意味着所有"重灌数据"的修复(价格、法律文案、FAQ)都静默失败了——wrangler 在不存在的目录里找不到配置文件,但退出码居然是 0。
修复:基于脚本自身位置解析路径,不依赖 process.cwd():
const REPO_ROOT = resolve(__dirname, "..");
const wranglerCwd = join(REPO_ROOT, "apps/web");
SEO 结构化数据
这是重构后投入最多的部分。为每类页面设计了对应的 JSON-LD:
| 页面 | schema.org 类型 | 效果 |
|---|---|---|
| 首页 | MedicalOrganization + WebSite |
品牌知识图谱、sitelinks |
| 医院详情 | Hospital + BreadcrumbList |
富结果卡(地址、坐标、专科) |
| 指南文章 | Article + BreadcrumbList |
文章富结果(发布日期、作者) |
| FAQ | FAQPage |
Google 搜索结果直接展开问答 |
所有结构化数据集中在 lib/jsonld.ts 里,单一来源,各页面通过 <JsonLd> 组件渲染。
踩坑总结
| 坑 | 根因 | 教训 |
|---|---|---|
| PET-CT 显示 $1.00–$49.00 | 逗号替换成空格而非删除 | 处理数字字符串时,用 .replace(/[,]/g, "") 而非 .replace(/[,]/g, " ") |
| FAQ 答案 Google 抓不到 | {isOpen && ...} 条件渲染 |
SEO 关键内容必须用 SSR 或原生 HTML 元素(<details>),不能藏在 JS 状态后面 |
| Seed 静默失败 | process.cwd() 在不同执行位置不同 |
脚本里的路径永远基于 __dirname 解析,不依赖 process.cwd() |
| 无来源星级评分 | 旧 JSON 的 rating 值没有出处 | 医疗/财务类站点,不展示没有可验证来源的数据 |
总结
这次重构的核心快把我搞吐血了,把三个结构性问题各对应一个架构决策:
| 旧问题 | 新方案 |
|---|---|
| 后端是假的(不持久化) | 所有写入进 D1,联系表单已验证可落库 |
| 数据三套且漂移 | 全部进 D1,单一 lib/queries.ts 数据访问层 |
| Service Binding 配坏 | 单应用,无 binding,Route Handler 直读 env.DB |
如果你也在做 Cloudflare D1 + Next.js 的项目,欢迎砖头。项目线上地址是 chinahospital.info,欢迎交流。

浙公网安备 33010602011771号