从假后端到 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(pumchfuwai-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 被拆成了 1049(=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>}

isOpenuseState 初始为 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,欢迎交流。

posted @ 2026-07-03 22:38  db2zos  阅读(4)  评论(0)    收藏  举报