动手:读懂并设计"改一处小功能"的路径
前 18 天都是"读"。今天换个视角——假设产品经理提了个需求:"Prompt 列表页要支持按类型筛选",我们以 已经存在的真实实现 ListPrompt 的 filter_prompt_types 为教材,走一遍"该改哪一层、不改哪里、怎么保证不破坏现有接口"的完整决策过程。这是把前 18 天的分层知识串成"动手能力"的关键一天。
今天的任务:读懂一个真实的筛选功能
ListPrompt 的 filter_prompt_types 参数
Coze Loop 的 Prompt 类型除了普通 Prompt,还有"片段"(snippet)等其他类型。"列表默认只显示普通类型,但支持传参数看其他类型"——这正是一个真实存在的、跨越 IDL→application→repo→DAO 四层的完整筛选功能,非常适合当作"如何设计一个筛选参数"的标准范本。filter_prompt_types),在 IDE 里全局搜索它,把命中的每一处按"调用顺序"串起来看——这才是日常改 bug/加功能时最高效的读码方式。第一层:application 层怎么"编排"这个筛选
IDL 定义(idl/thrift/coze/loop/prompt/coze.loop.prompt.manage.thrift:146):
14: optional list<prompt.PromptType> filter_prompt_types // 向前兼容,如果不传,默认查询normal类型的Prompt
应用层实现(backend/modules/prompt/application/manage.go:408-418):
// Default filtering behavior: if no filter_prompt_types specified, only show normal prompts
filterPromptTypes := request.GetFilterPromptTypes()
if len(filterPromptTypes) == 0 {
filterPromptTypes = []prompt.PromptType{prompt.PromptTypeNormal} // 关键:不传参数时的默认行为
}
var entityFilterPromptTypes []entity.PromptType
for _, pt := range filterPromptTypes {
entityFilterPromptTypes = append(entityFilterPromptTypes, convertor.PromptTypeDTO2DO(pt)) // DTO → DO 转换
}
listPromptParam := repo.ListPromptParam{
SpaceID: request.GetWorkspaceID(),
FilterPromptTypes: entityFilterPromptTypes,
// ...其他字段
}
listPromptResult, err := app.manageRepo.ListPrompt(ctx, listPromptParam) // 交给下一层
第二层:repo 层怎么"传递"这个参数
backend/modules/prompt/infra/repo/manage.go:448-473(节选):
func (d *ManageRepoImpl) ListPrompt(ctx context.Context, param repo.ListPromptParam) (result *repo.ListPromptResult, err error) {
var promptTypes []string
for _, pt := range param.FilterPromptTypes {
promptTypes = append(promptTypes, string(pt)) // entity.PromptType → 裸 string,为了给 DAO 用
}
listBasicParam := mysql.ListPromptBasicParam{
SpaceID: param.SpaceID,
PromptTypes: promptTypes,
// ...
}
basicPOs, total, err := d.promptBasicDAO.List(ctx, listBasicParam) // 交给最底层 DAO
...
}
👶 小白问:为什么不干脆让 application 直接调 DAO,省掉 repo 这一层?
👨🏫 老师:因为 domain/repo 定义的是接口(IManageRepo),infra/repo 才是实现——这样 application 层依赖的是接口而不是"MySQL 这个具体实现"。如果未来要把存储从 MySQL 换成别的(或者加一层缓存),只需要换 infra 层的实现,application 和 domain 的代码完全不用动。这正是 AGENTS.md 反复强调的"domain 定义接口,infra 实现接口"。
第三层:DAO 层真正拼出 SQL 的 WHERE 条件
最底层,backend/modules/prompt/infra/repo/mysql/prompt_basic.go:166-201:
func (d *PromptBasicDAOImpl) List(ctx context.Context, param ListPromptBasicParam, opts ...db.Option) (basicPOs []*model.PromptBasic, total int64, err error) {
q := query.Use(d.db.NewSession(ctx, opts...))
tx := q.WithContext(ctx).PromptBasic
tx = tx.Where(q.PromptBasic.SpaceID.Eq(param.SpaceID))
if len(param.CreatedBys) > 0 {
tx = tx.Where(q.PromptBasic.CreatedBy.In(param.CreatedBys...))
}
if !lo.IsEmpty(param.KeyWord) {
likeExpr := field.Or(
q.PromptBasic.PromptKey.Like(fmt.Sprintf("%%%s%%", param.KeyWord)),
q.PromptBasic.Name.Like(fmt.Sprintf("%%%s%%", param.KeyWord)),
)
tx = tx.Where(likeExpr)
}
if len(param.PromptTypes) > 0 {
tx = tx.Where(q.PromptBasic.PromptType.In(param.PromptTypes...)) // 就是这一行!真正的 WHERE prompt_type IN (...)
}
total, err = tx.Count()
tx = tx.Order(d.order(q, param.OrderBy, param.Asc)).Offset(param.Offset).Limit(param.Limit)
...
}
if len(param.XXX) > 0 { tx = tx.Where(...) } 代码块,不需要改动已有的任何一行。这是"开闭原则"(对扩展开放,对修改封闭)在实际业务代码里的体现。IDL:filter_prompt_types契约定义 + 向前兼容说明application/manage.go默认值兜底、DTO→DO 类型转换infra/repo/manage.goDO→DAO 参数结构转换(纯搬运)infra/repo/mysql/prompt_basic.go真正拼 GORM 查询条件,落地成 SQL WHERE设计练习:假如要新加一个"按创建时间范围筛选"
现在轮到你了。假设需求是"支持传 created_after/created_before 时间范围筛选 Prompt 列表",照着 L02-L04 的路径,你应该:
① IDL 加字段
在 ListPromptRequest 里加 15: optional i64 created_after、16: optional i64 created_before,记得写清楚"不传时的默认行为"注释。
② 跑生成脚本
Day 18 学的 kitex_tool.sh + rush update-api,让 Go/TS 代码自动跟上,不要手写这两处生成代码。
③ application 层加编排逻辑
决定"两个参数都传/只传一个/都不传"分别怎么处理,转换成 repo 层参数结构里的新字段。
④ repo 层加参数搬运
在 ListPromptParam/ListPromptBasicParam 里加对应字段,纯转发不加业务判断。
⑤ DAO 层加 WHERE 条件
照着 PromptType.In(...) 的模式,加 if param.CreatedAfter != nil { tx = tx.Where(q.PromptBasic.CreatedAt.Gte(...)) }。
filter_prompt_types 就是最好的参考模板,新功能只是在每一层"复制粘贴 + 改个字段名"。这也是为什么本教程反复强调"先读透一条真实链路,再动手写新功能"——读透的这条链路会成为你后续开发的模具。安全清单:哪些绝对不能碰
backend/kitex_gen/、backend/loop_gen/、backend/api/router_gen.go、任何 wire_gen.go、frontend/packages/loop-base/api-schema/——Day 18 学过,改了也会被下次生成覆盖,而且排查起来很痛苦。ARCHITECTURE.md 强调的"向前兼容"要求新字段必须是 optional、用全新的编号——如果复用一个曾经用过又废弃的编号,老客户端可能把新字段的数据错误解析成旧字段的类型,这是非常隐蔽的线上事故来源。lodataset/loauth 等)去拿,而不是直接 import 那个模块的 domain 包。patch-sql/ 补一条 ALTER 语句——mysql-schema-check CI 工作流会检查这一点(Day 20 细讲)。👶 小白问:怎么快速验证我的改动"没有破坏公共接口"?
👨🏫 老师:三个动作——① 新字段必须是 optional,不传时走清晰定义的默认逻辑;② 跑一遍现有单测(manage_test.go 这类文件),确保老测试仍然通过;③ 搜索一下这个接口有没有其他调用方(前端多个页面、OpenAPI 对外接口),确认默认行为不会让它们的现有请求产生不同结果。
今日小结 + 动手 + 预告
🧠 今天你应该能回答
filter_prompt_types这一个参数,从 IDL 到 SQL 一共经过了哪四层?各层做什么?- 为什么 IDL 注释里强调"不传默认查 normal 类型"?这体现了什么设计原则?
- 假如要新加一个筛选维度,五步计划分别是什么?
- 改动前该检查哪四条安全红线?
- 怎么用"全局搜索一个字段名"的方式快速读懂一条完整链路?
✋ 动手 5 分钟(可选)
# 1. 顺着字段名搜出完整链路(体验今天讲的读码方法)
grep -rn "filter_prompt_types\|FilterPromptTypes\|PromptTypes" backend/modules/prompt/ idl/thrift/coze/loop/prompt/
# 2. 看默认值兜底逻辑的真实位置
sed -n '408,418p' backend/modules/prompt/application/manage.go
# 3. 看最底层拼 SQL 条件的真实位置
sed -n '176,196p' backend/modules/prompt/infra/repo/mysql/prompt_basic.go
# 4. 找一下相关的单测,感受"改动后要过哪些测试"
grep -n "FilterPromptTypes\|PromptType" backend/modules/prompt/application/manage_test.go | head -10