Prompt 读路径纵切
前 8 天分别拆开了路由、Handler、DDD 分层、IDL、Foundation 这些"横切面"。今天换个角度——跟着"新建一个 Prompt"这一件事,从最外层的 HTTP 请求一路纵向切到 MySQL 落库,把散落的知识点串成一条完整的因果链。
纵切一条完整链路
POST /api/prompt/v1/prompts 创建 Prompt),纵向切一刀,看请求怎么从上到下走完全程。Handler(prompt_manage_service.go) → invokeAndRender → Application(manage.go CreatePrompt) → Domain Service(manage.go CreatePrompt) → Repo(infra/repo/manage.go) → MySQL 事务——这条链路你在 Day 04-06 已经分段学过,今天把它拼成一整条。Handler 层:入口与校验
回顾 Day 05,backend/api/handler/coze/loop/apis/prompt_manage_service.go 里的 CreatePrompt 只有几行——真正的活儿都在 invokeAndRender 里完成,它会:①反序列化 HTTP Body 成 CreatePromptRequest;② 调用 Day 05 讲的"本地 Kitex 客户端" local_promptmanageservice.go;③ 把返回值序列化成 HTTP 响应。
1. Hertz Handler
解析 HTTP → CreatePromptRequest 结构体(字段由 Day 07 IDL 生成)
2. 本地 Kitex 客户端
进程内直接函数调用(非网络 RPC),跳到 application 层
Application 层:用户与权限
backend/modules/prompt/application/manage.go 第 111 行开始的 CreatePrompt,是这条链路里"胶水代码最集中"的一层——它自己不做核心业务判断,而是负责把周边的用户/权限/审核串起来:
func (app *PromptManageApplicationImpl) CreatePrompt(ctx context.Context, request *manage.CreatePromptRequest) (r *manage.CreatePromptResponse, err error) {
// 用户:从 ctx 取当前登录用户(Day 08 讲的 Session→User)
userID, ok := session.UserIDInCtx(ctx)
if !ok || lo.IsEmpty(userID) {
return r, errorx.NewByCode(prompterr.CommonInvalidParamCode, errorx.WithExtraMsg("User not found"))
}
// 权限:调用 Foundation 检查"这个用户在这个空间下能不能创建 Prompt"(Day 08 讲的三要素)
err = app.authRPCProvider.CheckSpacePermission(ctx, request.GetWorkspaceID(), consts.ActionWorkspaceCreateLoopPrompt)
if err != nil {
return r, err
}
// ... 组装 DTO,填充默认值 ...
promptDO := convertor.PromptDTO2DO(promptDTO) // DTO → DO 转换
// 审核:调用内容安全审核
err = app.auditRPCProvider.AuditPrompt(ctx, promptDO)
if err != nil { return r, err }
// 真正创建:转交给 domain service
promptID, err = app.promptService.CreatePrompt(ctx, promptDO)
// ...
}
manage.CreatePromptRequest 是 DTO(Data Transfer Object,接口层的数据形状,由 IDL 生成),entity.Prompt 是 DO(Domain Object,领域层的数据形状)。convertor.PromptDTO2DO 这一步转换,正是 Day 06 讲的"依赖方向"在数据层面的体现——领域层不认识、也不依赖接口层的数据结构。authRPCProvider(Day 08 的 Foundation 鉴权)和 auditRPCProvider(内容审核),但完全没有碰 MySQL——它是"业务用例的编排者",不是"业务规则的实现者"。Domain 层:业务规则校验
继续往下,backend/modules/prompt/domain/service/manage.go 第 338 行的 PromptServiceImpl.CreatePrompt 才是真正的"业务规则守门员":
func (p *PromptServiceImpl) CreatePrompt(ctx context.Context, promptDO *entity.Prompt) (promptID int64, err error) {
if promptDO == nil {
return 0, errorx.New("promptDO is empty")
}
// 业务规则:SpaceID 必须有效
if promptDO.SpaceID <= 0 {
return 0, errorx.New("spaceID is invalid: %d", promptDO.SpaceID)
}
// 业务规则:PromptKey 不能为空(它是 Prompt 的唯一业务标识)
if promptDO.PromptKey == "" {
return 0, errorx.New("promptKey is empty")
}
if promptDO.PromptBasic == nil {
return 0, errorx.New("promptBasic is empty")
}
// 解析并校验 snippet(Prompt 模板里可复用的片段)
err = p.parseAndValidateSnippets(ctx, promptDO)
if err != nil { return 0, err }
// 通过 repo 接口落库——注意 domain 只认 repo.IManageRepo 这个接口
promptID, err = p.manageRepo.CreatePrompt(ctx, promptDO)
return promptID, err
}
Infra 层:事务写 MySQL
最后一跳,backend/modules/prompt/infra/repo/manage.go 里的 ManageRepoImpl.CreatePrompt 实现了 domain 定义的 IManageRepo 接口(Day 06 讲过的依赖倒置),真正把数据写进 MySQL:
func (d *ManageRepoImpl) CreatePrompt(ctx context.Context, promptDO *entity.Prompt) (promptID int64, err error) {
promptID, err = d.idgen.GenID(ctx) // 分布式 ID 生成器,不用数据库自增
// ... 如果有草稿也生成 draftID ...
return promptID, d.db.Transaction(ctx, func(tx *gorm.DB) error { // 开事务:多张表要"全成功或全失败"
opt := db.WithTransaction(tx)
basicPO := convertor.PromptDO2BasicPO(promptDO) // DO → PO(Persistent Object,数据库表结构)
basicPO.ID = promptID
err = d.promptBasicDAO.Create(ctx, basicPO, opt) // 写 prompt_basic 表
if err != nil { return err }
if promptDO.PromptDraft != nil {
draftPO := convertor.PromptDO2DraftPO(promptDO)
draftPO.ID = draftID
draftPO.PromptID = promptID
err = d.promptDraftDAO.Create(ctx, draftPO, opt) // 写 prompt_draft 表
if err != nil { return err }
}
// ... 如果有 snippet 还会写 relation 表 ...
return nil
})
}
entity.Prompt(DO)到这里又转成 basicPO(PO),对应 GORM 模型(infra/repo/mysql/gorm_gen/model/)。全链路走过了 DTO → DO → PO 三种数据形状,每一层只认识自己这一层的形状,这就是分层架构"宁可多写一次 convertor,也不让上下层数据结构互相污染"的代价与好处。prompt_basic(基本信息)和 prompt_draft(草稿内容)两张表,如果第一条成功、第二条失败,就会出现"有 Prompt 但没有草稿内容"的脏数据。d.db.Transaction(ctx, func(tx *gorm.DB) error {...}) 保证这一组写入全部成功才提交,任何一步出错就整体回滚。Entity:贯穿全链路的数据形态
backend/modules/prompt/domain/entity/prompt.go 定义的 Prompt 结构体,是 Domain 层和 Infra 层之间传递的"通用语言":
type Prompt struct {
ID int64 `json:"id"`
SpaceID int64 `json:"space_id"`
PromptKey string `json:"prompt_key"`
PromptBasic *PromptBasic `json:"prompt_basic,omitempty"` // 名称/描述/创建人等基础信息
PromptDraft *PromptDraft `json:"prompt_draft,omitempty"` // 未提交的草稿内容
PromptCommit *PromptCommit `json:"prompt_commit,omitempty"` // 已提交的版本内容
}
PromptDraft 像 Git 的"工作区"(还没 commit,随时可能改动),PromptCommit 像"某一次 commit"(有版本号 Version、有提交说明 Description,不可再变)。这也解释了为什么创建 Prompt 时通常只填 PromptDraft——正式"发布"一个版本(生成 PromptCommit)是后续单独的动作,超出了今天的范围。今日小结 + 动手
🧠 今天你应该能回答
- CreatePrompt 完整链路的 5 个环节是什么?(Handler → Application → Domain Service → Repo → MySQL)
- Application 层和 Domain 层的校验分别管什么?(前者管"谁能做",后者管"是否合法")
- 数据形状经过了几次转换?(DTO → DO → PO,各层各自的语言)
- 为什么创建 Prompt 要开事务?(多表写入要保证原子性)
✋ 动手 5 分钟
# 1. 顺着调用链搜索,验证今天讲的每一跳都是真的
grep -n "func.*CreatePrompt" backend/modules/prompt/application/manage.go
grep -n "func.*CreatePrompt" backend/modules/prompt/domain/service/manage.go
grep -n "func.*CreatePrompt" backend/modules/prompt/infra/repo/manage.go
# 2. 看 entity.Prompt 的完整定义
sed -n '1,40p' backend/modules/prompt/domain/entity/prompt.go
# 3. 看事务写入涉及了哪几张表(DAO)
grep -n "DAO.Create" backend/modules/prompt/infra/repo/manage.go
debug.go/execute.go 加上 llm 模块的 Runtime,最终通过 Eino 框架把请求发给火山方舟等真正的大模型。明天专门拆开这条"调试执行链路"。