ENZH
和 AI 讨论这篇文章
ChatGPTClaude

29 天把 5 个散装 app 合成一个平台

📊 幻灯片

第 1 篇讲了为什么零领域知识也敢做算命平台,这篇讲具体是怎么做的,不挑好看的讲:真实的架构决策,最难啃的 bug,还有让每天 39 个 commit 成为可能的那些基础设施。

猫咪建筑师将五个分散的小楼合并成一座统一平台猫咪建筑师将五个分散的小楼合并成一座统一平台

先说起点。起步的时候手上是 5 个完全独立的 Next.js 应用,5 个独立的 repo:八字、星座、塔罗、解梦、占卜。每个应用有自己的认证、自己的 Supabase client、自己的 Tailwind 配置。用户想全用一遍,得登录五次。这个状态只能说不叫平台,就是五个散装 app 摆在一起。

头四天,先把 5 个 repo 合成一个

第 1 天搭 Turborepo,21 个 commit。这件事概念上很简单,应用搬进 apps/,共享代码提进 packages/,但实际动手之后每条 import 路径都炸了。那天 80% 的时间就在干两件事:修 import,验证构建。

第 2 天撞上 CSS。把 Tailwind 配置统一到 packages/config 之后,所有依赖自定义 design token 的应用全崩了。这种崩不是红色报错那种,是「这个按钮怎么小了 2px」那种,没有报错可看,只能一个个肉眼对。38 个 commit,基本每个都在修视觉回归。

不过第 2 天也有真产出:提出来四个共享 package——PDF 生成、聊天记录、共享聊天 UI、通用 React hooks。看着 5 个应用里重复的代码收敛成一份,这个感觉真的很爽。

第 3 天加了 MBTI 性格测试,第 6 个应用。顺手做了微信浏览器检测,因为中国互联网很大一部分流量走微信内置浏览器,这个不处理不行。UA 嗅探还得加固,微信的 UA 在 Android、iOS 和桌面端长得都不一样。

第 4 天是大头,56 个 commit,把统一 API package 做出来了。packages/api 把所有应用跟 AI 模型通信的方式标准化成一套模式:认证检查、积分扣除、模型选择、SSE 流式输出、错误处理。52 条 API 路由全部迁过去。这个迁移是 claude code 干的,给它看了一个示例,剩下的它自己迁完,实际耗时大概一小时。手动做至少一整天。

第 4 天还顺手搞定了 GDPR 合规、中英双语的法律页面,和第一版基于 DFA 的敏感词过滤器。过滤器是刚需,玄学内容天然就带一堆会触发简单关键词过滤的词——算命、破财、桃花劫这种,所以得有一套合理的白名单方案。

测试和品牌

测试全 monorepo 用 Vitest。优先级我想得比较清楚,就三层:

  1. 先测共享 package——API 流式输出的 helper、Supabase client、积分管理。这些东西所有应用都压在上面,它出问题是全家一起出问题。
  2. 再测领域计算——八字计算、塔罗抽牌、六壬占卜。我零领域知识,没法验证算得对不对,那就退一步验证确定性:同样的输入永远给同样的输出。
  3. 最后测 API 路由,mock 认证 token 做集成测试。

CI 走 GitHub Actions,每个 PR 跑 lint、类型检查、构建、测试。到正式开始冲里程碑的时候,测试套件已经有 34 个文件、1,922 个测试了。

品牌也是这个阶段定下来的。PanPanMao(盼盼猫),会算命的猫。「猫」这个主题一确定,后面的事都好办了:每个应用配上猫主题装饰,积分货币变成小鱼干。积分这层抽象挺关键的,人们愿意「花 5 条小鱼干」,不太愿意看到具体的美元金额。人民币套餐也预置好了,没激活,等中国市场。

几块难啃的

手面相识别是技术难度最高的功能,PR #1 就是它:56 个文件变更,6,881 行新增。

思路是 MediaPipe 全跑在浏览器端,检测这一步不需要服务端往返。架构上拆成几个 hook:useMediaPipeLoader 懒加载 WASM(大概 3MB),模块级缓存;useFaceDetector 跑实时 BlazeFace,60fps 摄像头预览;useFaceLandmarker 出 478 点面部网格,拍照的时候跑一次;useHandLandmarker 出 21 点手部关键点,也是拍照跑一次。

后面还补了两个中国特供的修复。PR #50 把 WASM 和模型文件本地化到 public/mediapipe/,因为中国用户访问 cdn.jsdelivr.netstorage.googleapis.com 不稳。PR #35 给掌纹检测加了 GPU 到 CPU 的降级,处理那些 WebGL 起不来的设备。

多模态这块 prompt 也不能偷懒,你不能跟模型说一句「看看手」就完了。prompt 是四层结构:角色定义 → 知识注入(五官、三停、十二宫)→ 交叉验证 → 结构化输出格式。

积分经济这边最头疼的是匿名用户转正。PR #2(M1 Foundation)动了 100 个文件。新用户进来就是 Supabase 匿名账户,等他用兑换码或者登录的时候,积分、算命记录、聊天历史都要无缝合并到正式账户上。兑换流程做成了状态机:idle → checking → redeeming/logging_in → success/error。

跨标签页的积分同步用 Supabase 实时订阅。这里有个很隐蔽的 bug:用户在一个标签页买了积分,切到另一个标签页,旧余额还挂在那。用户以为购买失败,又买一次,双重扣费。修法就是实时订阅一秒内把所有标签页同步掉。

然后是 18 条流式路由的大统一。到这个阶段已经有 18 条 SSE 流式路由了,每条自己实现一遍 ReadableStream。PR #16 做了个大重构,15 条迁到共享的 createAIStreamResponse() helper,消掉大概 1,200 行重复样板,顺手加了生命周期钩子:initEventsonCompleteonErrorrefundCreditsOnError。剩下三条确实需要自定义处理:MBTI 聊天(accumulated-buffer-delta 模式做信号剥离)、梦境小说(多步骤生成协议)、每日运势(非流式,带服务端缓存)。

AI 语气这件事是全场最难做对的。PR #38,由真用户反馈触发:解读太讨好型了,什么都往好了说,事业说必成,健康说没问题,感情也总有希望。真的算命先生不这么说话。修法是把全部 6 个领域的 prompt 系统性改一遍,整体语气从「偏积极正面」改成「好说好,不好明确指出」;健康解读从「混合分析」改成「中立偏负面,直接给健康警示」;负面指标直接用凶、不利这种词,后面跟化解方案。这是少数 AI 没法独立完成的改动,语气校准需要人对文化期望的判断,这个判断模型自己给不出来。

里程碑冲刺

2 月 12 日一天合了 15 个 PR,M1(积分经济)、M2(每日枢纽)、M3(PostHog 数据分析)同天上线。

每日枢纽(PR #4)是给留存做的。算命天然是事件驱动的,用户带着问题来,拿到答案就走了,枢纽给他一个每天回来的理由。这里想清楚了一件事:内容要在北京时间午夜用 cron 预生成,不要按需生成。用户早上打开是瞬间加载的,不用对着转圈等 3 秒。这 3 秒真让用户等下去,用户就不信任这个产品了。

接下来加了流年运程(PR #85),一个全新领域:生肖计算器、太岁分析、流年飞星、AI 运势解读。48 个文件,162 个领域测试。

Portal 主题那个 bug 挺有意思的,值得展开讲。每个应用有自己的 CSS 主题(.theme-hub.theme-tarot 这种),但 React portal 是通过 createPortal(content, document.body) 渲染的,DOM 节点直接挂在 body 上,跑到应用主题 wrapper 外面去了,CSS 变量在 portal 内容里就不可见。最开始的修法是检测 closest('.theme-hub') 然后硬编码 overlay 的类,但 9 个主题乘 13 个 portal 组件,这条路走不下去。PR #67 的方案:加一个 ThemeProvider context 声明当前主题类,再做一个 ThemedPortal 组件,用 MutationObserver 把正确的主题类和暗色模式类包到 portal 内容外面。这个方案就对了,后面再加多少主题都不用动。

React portal 渲染到 body、跑出应用主题 wrapper,CSS 变量失效;用 ThemedPortal 把主题类包回 portal 内容来修React portal 渲染到 body、跑出应用主题 wrapper,CSS 变量失效;用 ThemedPortal 把主题类包回 portal 内容来修

弹性 AI 流式传输是最后一个大基础设施 PR(#105)。做的事情是服务端流式缓冲:19 条 AI 流式路由通过 StreamBufferManager 把数据块顺手缓冲进数据库,客户端中途断线的话,GET /api/stream/{id} 能把完整响应恢复回来。同一个 PR 还统一了消费端,一个 useAIStream hook 替换掉 14 个组件里的 3 种 SSE 消费模式。最后算下来,加了一整套弹性基础设施,代码反而净减 186 行。

内容过滤器前后进化了三版。V1 就是第 4 天那个 DFA 关键词过滤,敏感消息整条拦截。V2(PR #78)做了流感知过滤,但遇到敏感词是把整个流截断——用户积分扣了,拿到的解读是残缺的,这个体验不行。V3(PR #104)改成行内遮蔽,敏感词替换成 **检测到违禁词**,流不中断。架构上是双过滤:输入侧用严格过滤器,广告、政治、色情、犯罪全拦;输出侧用宽松过滤器,把「广告」类别排除掉,不然算命术语会被误杀。还要做边界安全遮蔽,处理跨 chunk 边界的词。

内容过滤器三版演进:V1 整条拦截、V2 遇敏感词截断整条流、V3 行内遮蔽不中断流内容过滤器三版演进:V1 整条拦截、V2 遇敏感词截断整条流、V3 行内遮蔽不中断流

最后盘个数

29 天,1,134 个 commit,日均 39,峰值一天 98,没有一天是零 commit。109 个 PR,66 个带详细描述。9 个产品线,都有真业务逻辑。85 个 API 端点,19 条 SSE 流式路由。大概 284,000 行代码,16 个 package。2,021 个测试通过(最终计数)。

这个速度能撑住,靠的就是前面那些共享 package。新增一个产品线,认证、积分、模型选择、内容过滤、流式传输、错误处理、UI 组件全是现成的,新产品的边际成本几乎全落在 prompt engineering 上。

共享 package 做成底座后,新增一个产品线只需写 prompt,认证/积分/流式全是现成的,边际成本几乎归零共享 package 做成底座后,新增一个产品线只需写 prompt,认证/积分/流式全是现成的,边际成本几乎归零

第 3 篇讲真实的教训——哪些事出乎意料,哪些事做错了,以及这一趟下来我对以后怎么做产品的想法变了哪些。

和 AI 讨论这篇文章
ChatGPTClaude

订阅更新

新文章发布时发到你的邮箱,不发别的。


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0