Day 09 / 共 20 天 · 第 2 周 分层与契约

Prompt 读路径纵切

前 8 天分别拆开了路由、Handler、DDD 分层、IDL、Foundation 这些"横切面"。今天换个角度——跟着"新建一个 Prompt"这一件事,从最外层的 HTTP 请求一路纵向切到 MySQL 落库,把散落的知识点串成一条完整的因果链。

📍 你在整门课的位置 · 第 2 周 分层与契约(共 4 周 · 20 天)
D7 IDL 契约 D8 Foundation D9 Prompt 读路径 D10 调试与 LLM
L01

纵切一条完整链路

🤔 为什么要"纵切",不是"横切"? 前 8 天每天讲一个"层"(路由层、Handler 层、DDD 分层……),像切蛋糕的横截面,你知道每一层长什么样,但可能没体会到"一个请求真实穿过所有层"是什么感觉。今天反过来,固定一件事(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 已经分段学过,今天把它拼成一整条。
L02

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 层

L03

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)
	// ...
}
DTO vs DOmanage.CreatePromptRequest 是 DTO(Data Transfer Object,接口层的数据形状,由 IDL 生成),entity.Prompt 是 DO(Domain Object,领域层的数据形状)。convertor.PromptDTO2DO 这一步转换,正是 Day 06 讲的"依赖方向"在数据层面的体现——领域层不认识、也不依赖接口层的数据结构。
看到了吗:这一层调用了 authRPCProvider(Day 08 的 Foundation 鉴权)和 auditRPCProvider(内容审核),但完全没有碰 MySQL——它是"业务用例的编排者",不是"业务规则的实现者"。
L04

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
}
💡 为什么 Application 层的校验和 Domain 层的校验不一样? Application 层校验的是"这个请求有没有权限、有没有过审"——跟"谁在问"有关;Domain 层校验的是"这个 Prompt 本身是不是一个合法的 Prompt"(SpaceID 有效、PromptKey 非空)——跟"是什么"有关,与谁调用它无关。这正是 Day 06 讲的 DDD 分层的价值:把"谁能做"和"这件事本身合不合法"彻底分开,Domain 层的代码可以脱离 HTTP、脱离权限系统单独写单元测试。
L05

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
	})
}
DO → PO 又是一次转换entity.Prompt(DO)到这里又转成 basicPO(PO),对应 GORM 模型(infra/repo/mysql/gorm_gen/model/)。全链路走过了 DTO → DO → PO 三种数据形状,每一层只认识自己这一层的形状,这就是分层架构"宁可多写一次 convertor,也不让上下层数据结构互相污染"的代价与好处。
⚠️ 坑:为什么要开事务? 创建一个 Prompt 往往同时要写 prompt_basic(基本信息)和 prompt_draft(草稿内容)两张表,如果第一条成功、第二条失败,就会出现"有 Prompt 但没有草稿内容"的脏数据。d.db.Transaction(ctx, func(tx *gorm.DB) error {...}) 保证这一组写入全部成功才提交,任何一步出错就整体回滚。
L06

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"`  // 已提交的版本内容
}
🍼 类比:Git 的工作区和提交历史 PromptDraft 像 Git 的"工作区"(还没 commit,随时可能改动),PromptCommit 像"某一次 commit"(有版本号 Version、有提交说明 Description,不可再变)。这也解释了为什么创建 Prompt 时通常只填 PromptDraft——正式"发布"一个版本(生成 PromptCommit)是后续单独的动作,超出了今天的范围。
L07

今日小结 + 动手

🧠 今天你应该能回答

  • 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
明天预告 · Day 10:Prompt 建好之后,用户还会在页面上"调试"它——输入变量、点击运行、看模型返回的结果。这背后是 debug.go/execute.go 加上 llm 模块的 Runtime,最终通过 Eino 框架把请求发给火山方舟等真正的大模型。明天专门拆开这条"调试执行链路"。
← 上一天 · Foundation 下一天 · Prompt 调试与 LLM Runtime →