从用户故事到接口契约
「AI 看得懂中文,但 AI 更看得懂结构化数据。」
这是写完这篇文档后,我最深的一句话感悟。
写在前面
上一篇我聊了「为什么一个人开发也要写需求文档」,那篇收尾时我说了一句话:
在 AI 编程时代,需求文档不是写给人看的,是写给 AI 看的。
但写完产品定位文档后,我发现一个尴尬的事实:这份文档 AI 其实「看不太懂」。
我对 Trae 说:「按这份需求文档帮我生成 note_text 表的 CRUD 接口」。它给我返回了一段代码,字段是 id、title、content、createTime——看起来没毛病,但完全不符合我的业务。我文档里明明写了要 category、tags、wordCount、aiOrganizedIds,它一个都没加。
后来我才明白:AI 看得懂中文,但 AI 更看得懂结构化数据。产品定位文档解决的是「做什么」,而 AI 写代码需要的是「怎么做」——它需要知道:
用户会做什么操作(用户故事)
这些操作对应哪些模块(模块清单)
每个模块对外暴露什么接口(接口契约)
接口背后是怎么存数据的(表结构)
这篇就专门讲我是怎么把这四件事写清楚的。最终输出了一份 pig-note后端模块规划设计.md,这份文档后来成了 Trae 生成后端代码的「圣经」。
一、用户故事:从「模糊想法」到「可执行描述」
1.1 为什么需要用户故事
一开始我直接写功能清单:
- 文本笔记:增删改查 - 语音笔记:增删改查 - 图片笔记:增删改查
但当我把这份清单丢给 AI 时,它问我:
文本笔记删除是物理删除还是逻辑删除?
语音笔记新增时需要传音频文件还是 URL?
图片笔记的图片是单张还是多张?
我答不上来。于是我开始写用户故事。
1.2 用户故事模板
用户故事的标准格式是:
作为一个 <角色>, 我想要 <做什么>, 以便于 <达到什么目的>。
比如:
作为一个注册用户, 我想要在首页看到今日记录数量和最近 5 条笔记预览, 以便于快速了解自己今天的记录情况。
1.3 星尘笔记的 12 个核心用户故事
我把 App 拆成了 12 个用户故事,这里挑几个完整的写出来:
用户故事 1 · 快速记录灵感
作为注册用户,我想要在首页点击浮动按钮后选择记录方式(文字/语音/图片/手绘/AI对话),以便于在 30 秒内完成一次灵感记录。
用户故事 2 · 语音转文字
作为注册用户,我想要录完音后自动转写成文字,以便于之后搜索和阅读,不用每次都听音频。
用户故事 3 · AI 整理笔记
作为注册用户,我想要对任意一条原始笔记一键调用 AI 整理,以便于得到结构化的标题、摘要、标签和待办事项。
用户故事 4 · 跨类型查找
作为注册用户,我想要通过自然语言问 AI「上周记过关于账单的笔记」,以便于不用翻列表就能找到老笔记。
用户故事 5 · 待办管理
作为注册用户,我想要把笔记里提到的事项标记为待办,以便于之后提醒自己执行。
用户故事 6 · 记账统计
作为注册用户,我想要按月查看我的支出/收入/结余统计,以便于了解自己的财务状况。
完整的 12 个用户故事我都写在了 docs/产品需求规划-灵感笔记.md 里,这里不一一列出。重点是:每个用户故事都对应一个或多个功能模块,可以直接映射成接口。
二、从用户故事拆出 10 大业务模块
2.1 拆解方法:动词 + 名词
把每个用户故事里的动词和名词拆出来:
2.2 10 大业务模块清单
最终拆出了 10 个后端业务模块,每个模块对应一张或多张表:
NoteText | note_text | |||
NoteVoice | note_voice | |||
NotePhoto | note_photo | |||
NoteDrawing | note_drawing | |||
NoteAiOrganized | note_ai_organized | |||
NoteAiChat | note_ai_chat_message | |||
NoteTodo | note_todo | |||
NoteAccount | note_account | |||
NoteGift | note_gift | |||
NoteUserSetting | note_user_setting |
2.3 加上 2 张基础设施表
除了 10 个业务表,我还规划了 2 张「基础设施」表:
note_base:聚合索引表,解决多表分页查询的性能问题(每张子表增删改时同步维护)
note_view_history:浏览历史表,用于「最近看过」功能
总共 12 张业务表。这个规模一个人扛,必须靠 AI——这也是为什么我要把每个模块的接口契约都写得这么细。
三、接口契约:用 Markdown 表格画路径 / 方法 / 入参 / 返回
3.1 接口设计的 5 条约定
在动手写接口表之前,我给自己定了 5 条硬性约定:
路径前缀统一:所有业务接口都以
/note开头,避免和 pig 框架自带的/admin冲突返回结构统一:所有接口返回
R<T>,其中R是 pig 封装的统一响应体(code/msg/data)HTTP 方法语义化:GET 查询、POST 新增、PUT 修改、DELETE 删除,不混用
分页统一用 MyBatis Plus 的 IPage:前端传
current/size,后端返回records/total/current/size批量删除用 body 传 ID 数组:而不是 query 拼接,避免 URL 过长
这 5 条约定写在文档最前面,Trae 生成代码时就会按这套规范来。
3.2 接口契约表(节选)
文档里我为每个模块都画了一张接口表。这里展示文本笔记模块的完整契约:
文本笔记 /note/text
| GET | ||||
| GET | ||||
| POST | ||||
| PUT | ||||
| DELETE | ||||
| GET |
这张表丢给 Trae,它能直接生成 NoteTextController 的完整骨架,连 @PreAuthorize 权限注解都帮你加上。
3.3 统一返回结构
我在文档里明确写了返回结构的样子:
{ "code": 0, "msg": "success", "data": { "records": [...], "total": 100, "current": 1, "size": 10 } }
这样 AI 生成前端 API 封装时,能正确解构 res.data.records,不会出现「数据出来了但页面空白」的诡异问题。
3.4 几个有意思的接口设计
接口 1 · 待办状态切换
PUT /note/todo/toggle 入参:{ id: 123, status: "completed" } 返回:R<Void>
为什么不用 PUT /note/todo 整体更新?因为状态切换是高频操作,每次都传整个 todo 对象太浪费带宽,而且容易把其他字段覆盖掉。单独抽一个 toggle 接口,更轻量也更安全。
接口 2 · 首页聚合统计
GET /note/home/stats
返回:{
todayCount: 3, // 今日记录数
totalCount: 128, // 总记录数
pendingTodoCount: 5, // 待办数
todayExpense: 56.50 // 今日支出
}
这个接口要聚合 5 张笔记表 + 待办表 + 记账表的统计。如果按常规思路,每个模块查一次,首页加载会卡。所以我用了 note_base 聚合索引表,一次查询搞定。
接口 3 · AI 整理
POST /note/ai-organized/organize 入参:{ sourceId: 123, sourceType: "voice", style: "detail" } 返回:R<NoteAiOrganized>
这个接口的特殊之处在于:它不是简单的 CRUD,而是触发一次 AI 调用。后端要先查原始笔记 → 拼接 Prompt → 调用 Spring AI → 解析返回 → 写入 note_ai_organized 表。是整个项目最复杂的接口。
四、数据表骨架:10 张核心表的关系图
4.1 设计原则
我在写数据表设计之前,定了 4 条原则:
审计字段统一:所有表都带
create_by/update_by/create_time/update_time/del_flag,用 pig 的MybatisBaseEntity自动填充主键统一雪花算法:bigint 类型,避免自增 ID 在分表时的冲突
逻辑删除统一用 del_flag:
0正常,-1删除,不物理删除用户数据JSON 字段慎用:只有当字段是变长数组时才用 JSON(如 note_photo.images),其他情况拆字段
4.2 表关系图
12 张表之间不是孤立的,它们有引用关系。我画了一张 ASCII 关系图:
┌─────────────────┐ │ note_base │ 聚合索引表 │ (note_type + │ (查询入口) │ note_id) │ └────────┬────────┘ │ 同步维护 ┌─────────────────────┼─────────────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ note_text │ │ note_voice │ │ note_photo │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └──────────┬─────────┴──────────┬─────────┘ │ source_id │ │ source_type │ ▼ ▼ ┌─────────────────────────────┐ │ note_ai_organized │ AI 整理表 │ (多版本 version) │ └──────────────┬──────────────┘ │ todos JSON ▼ ┌─────────────┐ │ note_todo │ 待办事项 └─────────────┘ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │note_account │ │ note_gift │ │note_ai_chat_msg │ 独立模块 └─────────────┘ └─────────────┘ └─────────────────┘ ┌─────────────────────┐ │ note_user_setting │ 用户偏好(1用户1行) └─────────────────────┘ ┌─────────────────────┐ │ note_view_history │ 浏览历史 └─────────────────────┘
4.3 字段设计的几个关键决策
决策 1 · note_photo.images 用 JSON 数组而不是拆表
我考虑过建一张 note_photo_item 子表存每张图片,但最终选了 JSON 数组。原因:
一条图片笔记通常 1-9 张图片,不会上百张
JSON 数组读取一次 IO,子表要 JOIN
查询时不需要按图片字段筛选
但如果未来要做「按图片内容搜索」(比如找所有含白板的图片),就要拆表加索引了。
决策 2 · note_ai_organized 用 source_id + source_type 而不是外键
为什么不用外键?因为来源可能是 note_text、note_voice、note_photo 三张表中的任意一张,没法建外键约束。用 source_type 字段标识来源表,应用层负责校验。
决策 3 · note_todo.due_date 用 bigint 时间戳而不是 datetime
因为我用的是 MyBatis Plus,bigint 时间戳在前端展示时更灵活,可以按不同时区格式化。pig 框架里所有业务表的时间字段都用时间戳。
决策 4 · note_account 用 record_date 而不是 create_time
create_time 是审计字段(记录什么时候入库的),record_date 是业务字段(用户实际记账的日期)。这两个时间可能差几天——比如用户补记昨天的账单。
4.4 字段示例:note_ai_organized
这里展示最复杂的一张表的完整字段:
这张表的设计要点:
source_id+source_type实现多态关联version支持同一原始笔记多次整理,对比不同风格tags用逗号分隔字符串而不是 JSON,方便 SQL LIKE 查询todos必须用 JSON,因为待办是结构化对象数组
五、把文档喂给 AI 的实战
5.1 第一次喂文档的失败
写完文档后,我兴奋地把整个 pig-note后端模块规划设计.md 丢给 Trae,对它说:
按这份文档帮我生成所有模块的 Controller / Service / Mapper / Entity 代码。
结果它确实按模块生成了 Entity、Mapper、Service、Controller 一整套文件,分包也分得挺规整。但我一跑起来就发现问题——代码能跑,但细节全是"裸奔"状态:
Controller 把 App 端接口和管理端接口混在一个类里,没区分
/note/text/*和/admin/note/text/*管理端 Controller 一个
@PreAuthorize权限注解都没加,谁都能调管理页面的分类、状态字段直接写死
<el-option>,没走useDict字典App 端查询没加
createBy = 当前用户的数据隔离
我意识到:整份文档丢进去,AI 能生成"形似"的代码,但"神不似"——缺的是业务细节约束。
5.2 改进:分模块喂 + 补细节约束
后来我改了策略,不是"一个模块一个模块地喂"那么简单,而是分模块喂的同时,把每个模块的业务约束讲清楚:
先说:「生成 note_text 模块,App 端接口和管理端接口要拆成两个 Controller:
NoteTextController放/note/text,AdminNoteTextController放/admin/note/text」再说:「管理端 Controller 每个 endpoint 都要加
@PreAuthorize("@pms.hasPermission('note_text_view')")这类权限注解,权限码对应 sys_menu 里的 permission 字段」然后说:「App 端查询必须
.eq(NoteText::getCreateBy, SecurityUtils.getUser().getUsername())做数据隔离,管理端才能查全部」最后说:「管理页面的 category 字段从字典
dict_note_type获取,用useDicthook 渲染下拉,不要写死<el-option>」
这样分步走,每个文件都准确无误,生成的代码直接能上线,不用自己再返工调一遍。一个完整的文本笔记模块(App 端 + 管理端 + 前端页面),10 分钟就生成完了。
5.3 一个意外收获:文档成了「活字典」
写完文档后,我发现自己开发时也经常翻这份文档。比如:
写前端 API 封装时,查接口路径和入参
写 SQL 脚本时,查字段类型和默认值
和自己讨论「这个字段叫什么名字」时,翻文档定夺
这份文档后来变成了项目的「活字典」,无论是 AI 还是人,都靠它对齐认知。
六、写完文档后我学到的事
6.1 AI 时代的「文档」是什么
以前写需求文档是为了给产品经理、设计师、开发、测试看,所以重点是「人能读懂」。
AI 时代,文档多了一个读者——AI。AI 读文档的方式和人不同:
人看文字描述就能脑补
AI 需要结构化数据(表格、代码块、JSON 示例)
所以我的写法是:文字描述写给人看,表格和示例写给 AI 看。一份文档兼顾两类读者。
6.2 不要追求"完美文档"
写第一版文档时,我纠结了一晚上:要不要画 UML 类图?要不要写时序图?要不要用 PlantUML?
后来想通了:能跑的文档就是好文档。Markdown 表格 + ASCII 图 + 代码示例,足够 AI 理解。UML 这些重型工具,等团队规模大了再用。
6.3 文档要随代码一起迭代
写完文档不代表结束。我后来开发过程中改了好几次设计:
note_todo 表加了一个
completed_time字段(一开始忘了)note_account 表的
category_icon字段是从字典 remarks 字段读的(一开始想直接存 emoji)note_ai_organized 表加了
style字段(一开始只有版本号,后来发现需要风格区分)
每次改动我都会同步更新文档。Git 里这份文档的 commit 历史比代码还多。
七、互动
写完这篇,我想问大家一个问题:
你在用 AI 编程时,是直接对着 AI 说需求,还是先写一份文档再喂给它?
我自己从「直接说」到「先写文档」的转变,是从一次 AI 自由发挥的翻车开始的。欢迎在评论区聊聊你的做法。
下一篇预告
《03 - Todo 功能详细规划:从模糊想法到可执行的开发清单》
这个模块是整个项目最复杂的,状态流转、重复规则、提醒模型,我吃了不少坑,值得单独开一篇细讲。
夜雨聆风