流萤简历是我做的 AI 简历与求职资料工作台。这是专栏第 2 篇,从产品里并排出现的编辑、练习、连接器和管理功能,聊聊它们背后的模块化单体。
产品特写:用户看到的是一条连续的工作流
在流萤里,你可以编辑一份简历,把相关经历带进 AI 练习,整理成面试资料,再通过 AI 连接器继续加工。编辑、资料、授权和次数消耗发生在同一个账号下,用户不用为了每一项能力重新搬运一份数据。

截图里,资料目录和正文属于练习业务,分享按钮背后要读取页面权限,生成解答要使用模型配置和次数权益。界面上的一个按钮,经常需要几个业务共同完成。如果代码也跟着按钮所在页面分散,规则很快会出现多份。
模块化单体的意思是:应用仍然作为一个整体部署,内部按业务能力组织代码与依赖。对这个项目,我更关心"一个规则归谁负责、其他入口怎样复用它",而不是先拆出多少服务。
先沿着一条用户操作看责任
拿"一键生成简历"做例子。用户提供真实经历、目标职业与可选 JD,选择版式后点击生成,等待结构化内容,再进入编辑器。这个操作至少经过 HTTP 输入、账号验证、功能配置、次数预占、模型请求、结果解析与次数结算。
如果把这些都放在路由文件里,第一次实现会很快。等到个人 OpenAPI 也需要生成简历,开发者可能复制路由逻辑,再删掉几段浏览器检查。后续修复一次失败返还,就得记得改两份。遗漏不一定立即报错,却会让两个入口逐渐提供不同的产品行为。
当前仓库把 URL 映射留在 src/routes/api/,HTTP 解析放在所属业务的 controller,业务流程放在 service。Controller 可以理解为把请求转成业务调用的适配器;service 负责真正的业务动作和事务编排。

这张图里,简历生成与保存简历是两个动作。生成服务交付候选内容,前端再根据模板创建简历;不能写成"模型直接更新数据库里的当前简历"。这个区分同时影响用户是否能预览、失败是否会覆盖旧内容、计次的交付标准是什么。
调用链也说明了跨业务编排应该放在哪里。生成这件事归 resume,所以由它串联配置、模型和计费;billing 不需要反过来理解简历有哪些字段,AI 协议接入也不需要知道用户该扣几次。
为什么按业务分目录,而不是把所有 service 放在一起
项目现在的主要业务目录包括 resume、practice、interview、delivery、sharing、billing、auth、connections 和 site-settings。同一业务的页面、状态、请求、跨端契约与服务端实现尽量放在一起,按复杂度创建子目录。
这解决的是修改时的导航问题。排查"保存旧简历覆盖新内容",从 resume 找状态、同步器和服务端,比在全局 hooks、utils、services 之间猜名字更直接。排查"分享页权限撤销后还能访问",主责应当落在 sharing,即使问题最初出现在练习页面。

图按主要关系做了简化,不是全量依赖清单。它想强调的是方向:业务可以使用基础能力,shared 不反向知道业务。几个页面都用到了某条退款规则,也不意味着它就该迁进 shared;它仍然归计费业务。
按业务内聚也不等于强制目录对称。一个只有简单读取的业务,不必为了看起来完整创建空的 repository。数据访问确实被多个流程复用,或者 SQL 投影有独立价值,再拆出 repository。代码层次要解释实际责任,不能只解释目录长得整齐。
后台同理。产品面板能配置套餐、退款和模型,不代表这些规则都属于 admin。后台负责装配入口和管理员访问控制,商品与权益继续由 billing 维护。这样将来换一套后台界面,也不需要复制结算规则。
网页和 OpenAPI,复用到哪一层才合适
浏览器请求通常有 Cookie 会话和同源检查,个人 OpenAPI 使用令牌。两者的认证入口不同,但"这份简历是不是你的""这个页面当前能不能编辑"仍然是同一条业务规则。
一个危险的复用方式是:OpenAPI 认证后,再伪造浏览器请求头,调用网页 controller。这样看似少写代码,实际把两套身份语义绑在一起了。之后浏览器新增账号变化头或 Origin 限制,机器接口可能无端失效;维护者也很难判断最终执行权限时使用的是哪个身份。
当前 OpenAPI 入口先认证一次,把账号对象显式传给按业务划分的 HTTP 适配器,由适配器直接调用对应 service。各自保留协议层该有的请求体、错误格式和 PDF 响应头,业务权限则继续落在原来的实现里。

把认证后的账号显式传递还有一个可读性收益:函数签名表达调用需要什么,测试能直接比较网页与 OpenAPI 传入的参数,不用理解隐藏的临时上下文。协议兼容测试则防止两个入口在状态码或字段名上逐渐漂移。
service 之间使用进程内具名函数调用。应用本来就在一个部署单元里,没有必要为了"像服务化"而绕一圈 HTTP。网络调用会增加失败点,也会让原本可共享的事务变得难以传递。需要独立部署时再讨论远程协议,那时应该有清楚的容量、隔离或团队边界理由。
TypeScript 类型相同,不代表运行环境相同
全栈 TypeScript 很容易给人一种错觉:前后端都能 import,边界就只剩文件夹。实际还有浏览器、服务端和两端都可能执行的组件或 loader。
当前项目使用 TanStack Start。server/ 和 *.server.* 表达服务端实现约定,数据库、密钥和 Node 专用能力应从浏览器依赖链排除。跨端 contracts 则只放协议、类型与必要的 schema,不能反过来导入页面、store 或服务端配置。
根 index.ts 也要有边界。它只显式导出真正需要跨业务使用的客户端能力;不能把整个业务所有文件重新导出,再期待打包器自动替维护者分清轻重。PDF 导出之类重型功能保留独立入口和动态加载,就不会因为一个普通按钮依赖模块根入口而提前带进首屏。
代码里某个 import type 在编译后会消失,某个 createServerFn handler 也可能经框架转换从浏览器代码剥离。因此,静态扫描和最终产物检查在回答不同问题:前者约束开发依赖,后者观察真正发给浏览器的内容。
目录约定还不够,要看真正生成的 chunk
在分析基线中,项目针对 Start 1.160.2 配置了 Vite 插件 feature-client-boundaries。它在 generateBundle 阶段遍历浏览器 chunk,检查仍有实际输出的模块是否来自 server/ 或带 .server.* 后缀。
下面摘出核心判断,省略了路径辅助与错误汇总:
js
for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk") continue;
for (const [moduleId, module] of Object.entries(chunk.modules)) {
if (module.renderedLength > 0 &&
isServerModule(moduleId, sourceRoot)) {
violations.add(relative(sourceRoot, moduleId));
}
}
}
renderedLength > 0 是关键。已经被框架编译或 tree-shaking 移除的内容,不应被误报成浏览器泄露;仍然进入 chunk 的服务端实现,则应阻断构建。tree-shaking 指构建器去除没有被使用的代码,这里观察它处理之后的结果。
这个检查还会跳过服务端构建,只对浏览器产物生效。否则服务端 bundle 里出现数据库实现本来就是正常行为。一次好的架构门禁,需要拒绝真实错误,也要允许合法使用,不能靠"只要看到某个路径就失败"制造噪声。
当然,路径检查本身有前提:服务端实现按约定归位,第三方模块和运行时动态行为也不会凭这个插件自动获得完整安全证明。它把项目内一类明确风险变成构建错误,并不代替所有依赖审计。
同一个"公共入口",还要考虑加载成本
浏览器跨业务可通过模块根入口、登记过的子入口或 contracts 访问。为什么允许子入口?因为状态、缓存、配置装配与重型导出具有不同的加载边界。
退出登录只需要清理练习缓存,没有理由为了导入一个清理函数同时加载整个富文本编辑器。后台配置组件又可能被多个页面使用,若所有内容都经一个根入口汇总,容易形成消费者反向依赖配置装配的环。
项目用 feature-dependencies.mjs 登记允许的子入口,既保留清楚的公开边界,也给实际运行环境留下表达空间。业务内部直接导入实现,不反向经过自己的根入口,否则一个聚合文件就可能把互不相关的模块绕成循环。
这些约定的价值不是让 import 更短,而是让维护者能回答:这个能力承诺被谁复用,运行在哪一端,什么时候加载。三个问题说不清,重导出再漂亮也帮不上忙。
做结构调整时,最怕丢的是非代码契约
移动一个文件,编辑器里的引用都更新了,并不说明迁移完成。Docker 可能显式复制根目录维护脚本,启动服务可能动态 import RTC 模块,CI 可能执行一个旧命令,公开页面还可能依赖固定资源路径。
流萤的生产构建包括类型、架构、CLI 打包与 Vite 产物。根目录的 Node 启动、维护与 RTC 入口还承担部署契约。这些文件是否值得移动,要同时看 Docker、部署 bundle 和启动流程,不能只用"全仓没有静态 import"判断它无用。
这里有一个可以迁移到其他项目的检查顺序:先追踪真实用户动作的入口与规则,再确认公开 URL 和协议,再检查锁、事务和资源归属,最后核对构建与部署。目录改动完成后,应当仍能从同一个用户动作走到同一条规则。
架构有用与否,也可以用故障定位来判断。用户报告 AI 结果失败还扣次,维护者应迅速定位到 billing 的操作生命周期;用户报告复制资料丢失块标识,应定位 connections 的候选生成与文档表示。若每次都从页面 JSX 里重新寻找规则,说明边界还没有真正形成。
怎样验证边界真的守住了
架构检查需要有"应通过"和"应失败"的场景。合法的类型导入应通过,间接重导出的服务端实现进入浏览器应失败;业务调用另一业务的公开客户端入口应通过,深层导入内部实现应失败。只有失败用例,没有合法用例,规则很容易越收越紧,最后迫使开发者到处加例外。
生产构建还要看真正的客户端产物,因为静态扫描并不了解框架全部转换。OpenAPI 的验证要比较与网页接口的响应和 service 参数,避免仅证明"调用到了某个 mock"。涉及目录迁移时,还要从生产启动方式访问相关页面。
sh
pnpm check:architecture
pnpm check:openapi
pnpm build
可以打开流萤简历,从编辑简历切到练习资料,再看看 AI 连接器,这些界面共用的规则就是这篇讨论的对象。让功能继续增长的前提,是每条规则有清楚的主责与稳定入口。下一篇回到编辑器,看一次自动保存怎样处理保存期间又发生的修改。