株式会社 Navic クリエイト事业部
租赁企业官网与 CMS 的全面重建
为株式会社 Navic クリエイト事业部重建官网:Nuxt 4 的 CMS 与 Astro 7 的前台共用一个 Cloudflare D1。47,938 行代码、378 例测试,3.3 个月单人交付。

概要
客户是株式会社 Navic 旗下的クリエイト事业部,官网 navic-inc.jp,经营太阳能、蓄电池与保安机材的租赁及销售。原官网建在 Wix 上,商品详情页受模板限制,无法按业务需要自由编排。
新版不沿用任何现成 CMS,直接用 Nuxt 4 与 Astro 7 从零搭建后台与前台。目标是把商品详情页的编排能力交给运营,同时保住访客侧的静态站性能。从数据库设计到部署配置由我一人完成。
架构设计
两个 Cloudflare Worker 共用同一个 D1 数据库 navic-rental:Nuxt 4 的 CMS 负责写入,Astro 7 的前台只读。媒体存 R2,浏览器拿 CMS 用 aws4fetch 签发的预签名 URL 直传,不经 Worker 中转。
官方 AWS SDK 在 workerd 上模块加载阶段即抛 loadConfig is not a function,因为依赖链在顶层调用了 Node 的 fs。改用 aws4fetch 解决,排查结论写进 r2-presigner.ts 的注释。
前台 19 个页面构建期预渲染,仅 3 个 API 端点走运行时。section 组件用 Vue 做零 JS 服务端渲染;筛选 island 用 Solid,编译边界由 astro.config.mjs 的 include 目录约定隔离。
技术难点
商品详情页的 Section 构建器
商品详情页要复刻一套固定视觉规范:15 类 section、41 个变体。难点不在数量,而在编辑器表单、编辑器预览、线上渲染这三处会各自漂移,任何一个变体加字段,三处都要同步改。
解法是把 section 抽成独立 workspace 包 @repo/sections,340 行 zod schema 是唯一契约,后台校验与前台渲染读同一批类型。27 个 Vue 组件在 CMS 里做 tiptap NodeView 预览,在 Astro 里零 JS 渲染。
数据形状不同的变体用 discriminatedUnion 表达,而不是把字段全设成可选。这样漏填会在保存时的校验阶段报错,而不是等到访客打开页面才暴露成运行时错误。

预渲染与未发布变更
全量预渲染带来一个必然副作用:编辑者在后台改完内容,网站上什么都不会变,直到下一次构建。单人运营的场景下,没有人会记得去控制台手动触发。
解法是把未发布做成系统里的一等状态。写路径成功后调 markBuildPending 置位单行表 build_state,SQL 用 COALESCE(pending_since, ?) 保证记录首次修改时刻,banner 才能显示积压时长。
发布按钮打 POST /api/build/trigger,服务端 fetch WEB_DEPLOY_HOOK_URL,含密钥的 URL 绝不下发浏览器。没有选每次保存自动构建,因为连改十个字段会触发十次构建,改成人工确认的批量发布。

删图前的全库引用扫描
media 表被四处引用,只有 featured_media_id、cover_media_id 是 FK。images_json、parts_json、gallery_json 存裸 id,content_json 存 R2 object key,都不受约束保护。漏检就是图片 404 且不可逆。
- 外键列直接比对 id
- 数字数组用 json_each 展开后逐项匹配
- 对象数组用 json_extract 取出其中的 media id
- 形状不定的配置列先判断 JSON 合法性再展开
- section 内容列存的是对象 key,改用带转义的子串匹配
- 命中即返回 409 并列出占用位置,告诉运营是哪几条内容在用这张图
子串匹配不精确,是刻意的取舍:误报只是一次可撤销的拦截,漏报则是已删的 R2 对象无法找回。旧实现去找 data-media-id 属性,而代码库里从没有任何地方写过它,扫描因此静默失效过一次。
没有把这些引用规范化成关联表,虽然那是教科书答案。规范化会给每次内容保存都加上一组 delete 加 insert 的同步逻辑,而删除媒体是低频操作,用一次全表扫描换掉长期同步负担更划算。
上线站点