Day 19 / 共 20 天 · 第 4 周 前端与工程收官

动手:读懂并设计"改一处小功能"的路径

前 18 天都是"读"。今天换个视角——假设产品经理提了个需求:"Prompt 列表页要支持按类型筛选",我们以 已经存在的真实实现 ListPromptfilter_prompt_types 为教材,走一遍"该改哪一层、不改哪里、怎么保证不破坏现有接口"的完整决策过程。这是把前 18 天的分层知识串成"动手能力"的关键一天。

📍 你在整门课的位置 · 第 4 周 前端与工程收官(共 4 周 · 20 天)
D17 前端分层 D18 IDL 端到端 D19 动手改功能 D20 收官串讲
L01

今天的任务:读懂一个真实的筛选功能

🤔 痛点:需求文档只说"要能按类型筛选",代码里却要改好几个文件 很多新人拿到一个"小需求",第一反应是"随便找个地方加个 if 判断"——结果改完之后发现某个已有调用方悄悄坏掉了,或者改动散落在错误的层,未来没法复用。今天不是学新知识,而是练"改代码前先想清楚路径"这个习惯。
💡 选定教材:ListPromptfilter_prompt_types 参数 Coze Loop 的 Prompt 类型除了普通 Prompt,还有"片段"(snippet)等其他类型。"列表默认只显示普通类型,但支持传参数看其他类型"——这正是一个真实存在的、跨越 IDL→application→repo→DAO 四层的完整筛选功能,非常适合当作"如何设计一个筛选参数"的标准范本。
今天的读法:不是从上往下读,而是"顺着一个字段"读 前面 18 天大多是"打开一个文件,从头读到尾"。今天换一种更贴近真实开发的读法:拿住一个字段名(filter_prompt_types),在 IDE 里全局搜索它,把命中的每一处按"调用顺序"串起来看——这才是日常改 bug/加功能时最高效的读码方式。
L02

第一层: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)  // 交给下一层
💡 application 层在这里做的两件事:默认值兜底 + 类型转换 这正是 Day 11 提到的"application 层标准套路"的又一个例子——它不关心 SQL 怎么写,只负责"没传参数时该怎么办"(这是业务规则,注意 IDL 注释里写着"向前兼容"——这个参数是后来加的,不加默认值老客户端调用行为不能变),以及把外部 DTO 类型转换成内部 DO 类型再往下传。
L03

第二层: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
	...
}
💡 repo 层几乎不做业务判断,只做"再一次的类型转换 + 参数搬运" 这一层没有任何 if 分支去判断"该不该筛选"——那是 application 层已经决定好的事。repo 层的职责边界很窄:把 domain 层的实体类型转换成 infra 层(这里是 MySQL DAO)能理解的参数结构,纯粹是"翻译 + 转发"。

👶 小白问:为什么不干脆让 application 直接调 DAO,省掉 repo 这一层?

👨‍🏫 老师:因为 domain/repo 定义的是接口IManageRepo),infra/repo 才是实现——这样 application 层依赖的是接口而不是"MySQL 这个具体实现"。如果未来要把存储从 MySQL 换成别的(或者加一层缓存),只需要换 infra 层的实现,application 和 domain 的代码完全不用动。这正是 AGENTS.md 反复强调的"domain 定义接口,infra 实现接口"。

L04

第三层: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)
	...
}
💡 每个筛选条件都是"有值才加 WHERE,没值就跳过"的独立 if 块 这种写法(GORM Gen 生成的链式调用)让每个筛选条件互相独立、互不干扰——加一个新的筛选维度,就是照着这个模式再加一个 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
L05

设计练习:假如要新加一个"按创建时间范围筛选"

现在轮到你了。假设需求是"支持传 created_after/created_before 时间范围筛选 Prompt 列表",照着 L02-L04 的路径,你应该:

① IDL 加字段

ListPromptRequest 里加 15: optional i64 created_after16: 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 就是最好的参考模板,新功能只是在每一层"复制粘贴 + 改个字段名"。这也是为什么本教程反复强调"先读透一条真实链路,再动手写新功能"——读透的这条链路会成为你后续开发的模具。
L06

安全清单:哪些绝对不能碰

⚠️ 红线 1:永远不要手改生成代码 backend/kitex_gen/backend/loop_gen/backend/api/router_gen.go、任何 wire_gen.gofrontend/packages/loop-base/api-schema/——Day 18 学过,改了也会被下次生成覆盖,而且排查起来很痛苦。
⚠️ 红线 2:已发布的 IDL 字段编号不能改/复用 Thrift 靠字段编号做序列化,ARCHITECTURE.md 强调的"向前兼容"要求新字段必须是 optional、用全新的编号——如果复用一个曾经用过又废弃的编号,老客户端可能把新字段的数据错误解析成旧字段的类型,这是非常隐蔽的线上事故来源。
⚠️ 红线 3:不要在 domain 里 import infra,也不要跳过 crossdomain/本地客户端直接调别的模块 Day 15 学过的"模块间不直接互调"——加筛选功能时如果发现"我需要另一个模块的数据",正确做法是通过已有的"本地客户端"(lodataset/loauth 等)去拿,而不是直接 import 那个模块的 domain 包。
⚠️ 红线 4:SQL 表结构变更要"双路径同步" 如果新筛选字段需要给数据库加索引/加列,release/deployment/docker-compose/bootstrap/mysql-init/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/ 两处必须保持一致,还要在 patch-sql/ 补一条 ALTER 语句——mysql-schema-check CI 工作流会检查这一点(Day 20 细讲)。

👶 小白问:怎么快速验证我的改动"没有破坏公共接口"?

👨‍🏫 老师:三个动作——① 新字段必须是 optional,不传时走清晰定义的默认逻辑;② 跑一遍现有单测(manage_test.go 这类文件),确保老测试仍然通过;③ 搜索一下这个接口有没有其他调用方(前端多个页面、OpenAPI 对外接口),确认默认行为不会让它们的现有请求产生不同结果。

L07

今日小结 + 动手 + 预告

🧠 今天你应该能回答

  • filter_prompt_types 这一个参数,从 IDL 到 SQL 一共经过了哪四层?各层做什么?
  • 为什么 IDL 注释里强调"不传默认查 normal 类型"?这体现了什么设计原则?
  • 假如要新加一个筛选维度,五步计划分别是什么?
  • 改动前该检查哪四条安全红线?
  • 怎么用"全局搜索一个字段名"的方式快速读懂一条完整链路?
🎵 记忆口诀顺着字段名搜全链路,四层各管一段事,照猫画虎加新维度,四条红线心里记」——这是把前 18 天的知识转化为"能上手改代码"的能力的关键一课。

✋ 动手 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
明天预告 · Day 20(最后一天):20 天的学习旅程收官——我们会把六个后端模块、六层前端结构、IDL 契约、本地客户端、Adapter 模式全部串成一张全景图,过一遍 CI 工作流,聊聊接下来怎么继续深入这个仓库。
← 上一天 Day 18 下一天 · 收官串讲 →