ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

freehire 中 jobtracking 服务层提取实战:把 Handler 里的业务规则变成可单元测试的 Service + Repository

freehire 中 jobtracking 服务层提取实战:把 Handler 里的业务规则变成可单元测试的 Service + Repository
  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

本篇以归档变更openspec/changes/archive/2026-06-14-extract-job-tracking-service的任务清单为主体,详解 freehire 如何将"用户-岗位交互"(view/apply/save/unsave/track)这一组用例从user_jobs.gohandler 中剥离为独立的jobtracking服务层。读完后你能掌握一套可复制的重构路径:先固化现有行为(characterization test),再引入窄Repository接口与Service,最后把 handler 瘦身成纯传输层,并知道如何保证 HTTP 线格式(wire format)零漂移。

1. 背景:HTTP 层混杂了三种职责

变更提案 proposal.md 指出的核心问题:HTTP 层同时承担了三类职责——

  • handler 直接持有pgxpool.Pool和具体的*db.Queries;
  • 每用户的岗位跟踪规则(stage 校验、"nil 字段保持不变的"部分更新语义、slug→id 解析、pgtype↔ JSON 转换)内联在user_jobs.go中;
  • 这些规则只能通过完整的 Fiber 请求来驱动,无法被单元测试,也无法在非 HTTP 场景复用。

这是整个"服务层抽取"计划中第一个、风险最低的切片:先在一个低风险面上确立service + repo interface + thin handler的模式,之后再处理风险更高的 auth/OAuth 代码。设计文档 design.md 明确了 Goals(业务规则可在无 Fiber、无数据库的条件下用 fakeRepository单测;HTTP 行为逐字节不变)与 Non-Goals(不改 SQL/schema、不加新查询、不动路由和中间件)。

2. 任务清单全景:五个阶段的 TDD 重构

核心执行计划记录在 tasks.md,所有任务均已勾选完成,脉络分为五个阶段:

阶段 1:Pin current behavior(固化现有行为)

  • 1.1确认既有的user_jobs集成测试(//go:build integration,真实 Postgres)覆盖了成功路径的 wire shape:job_id+ RFC3339/null 时间戳、save/unsave 契约、404。它是端到端的 no-drift 守卫,合并前必须通过go test -tags=integration ./internal/handler/(需要 Docker),且不得重写它。
  • 1.2映射函数提取后(任务 4.x),补一个无数据库的单测,把Interaction → interactionResponse的 JSON 编码钉死:精确字段名、RFC3339 时间戳字符串、viewed/saved/applied/stage/notes 缺席时的null。

阶段 2:jobtracking 包——契约与规则(TDD + fake repo)

  • 2.1建internal/jobtracking包,定义领域Interaction结构体(JobID int64,ViewedAt/SavedAt/AppliedAt *time.Time,Stage/Notes *string)与哨兵错误ErrJobNotFound、ErrInvalidStage、ErrEmptyTrack;
  • 2.2在包内定义窄Repository接口(JobIDBySlug、RecordView、MarkApplied、SaveJob、UnsaveJob、TrackJob);
  • 2.3用 fakeRepository写失败单测:record view / mark applied / save 各自返回映射后的Interaction;
  • 2.4实现Service.RecordView/MarkApplied/SaveJob(经 repo 做 slug→id 解析;未知 slug 返回ErrJobNotFound)使 2.3 通过;
  • 2.5写失败单测验证Unsave幂等(无行 → 零值Interaction{JobID},nil error)并实现;
  • 2.6写失败单测验证Track:非法 stage →ErrInvalidStage;stage 和 notes 都没有 →ErrEmptyTrack;仅 stage / 仅 notes / 两者都给时,另一个字段保持不变。用userjob.ValidStage实现校验。

阶段 3:持久化适配器

  • 3.1在internal/jobtracking内加一个包装*db.Queries的Repository适配器,完成db.UserJob(pgtype)→Interaction的转换,以及db错误 → 包内哨兵错误的映射(pgx.ErrNoRows→ slug 查找时的ErrJobNotFound;unsave 的 no-row 情形在 service 中处理);
  • 3.2确认包可构建且go test ./internal/jobtracking/全绿。

阶段 4:Handler 瘦身

  • 4.1在handler.Register中构造 service(jobtracking.New(jobtracking.NewQueriesRepository(queries)))并挂到Handler上;
  • 4.2重写RecordView/MarkApplied/SaveJob/UnsaveJob/TrackJob:读auth.UserID、解析 body(仅 track 需要)、以(userID, slug, …)调 service、把领域错误映射为 HTTP 状态码(ErrJobNotFound→404,ErrInvalidStage/ErrEmptyTrack→400)、把Interaction渲染为既有的interactionResponseJSON;
  • 4.3删除user_jobs.go里已死的内联规则(interactionParams/trackRequest等被 service 取代的管道代码),这些端点不再调用h.queries。

阶段 5:验证

  • 5.1go test -tags=integration ./internal/handler/(真实 Postgres)确认 1.1 的 user_jobs wire 契约保持绿色。若开发环境没有 Docker,需记录该套件必须先在 CI 通过再合并;映射单测(1.2)则是开发循环内的守卫;
  • 5.2go build ./...、go vet ./...、go test ./...全部通过;
  • 5.3自审:确认internal/jobtracking中没有pgtype/pgx/fiber导入,且user_jobs.go中不再残留任何业务规则(stage 校验、部分更新、幂等性、slug 解析)。

3. 领域契约:Interaction 与哨兵错误

任务 2.1 定义的领域模型,在当前 jobtracking.go 中落地(Interaction定义于第 19–28 行):

// Interaction is the storage-agnostic result of a per-user job interaction. type Interaction struct { JobID int64 ViewedAt *time.Time SavedAt *time.Time AppliedAt *time.Time // DismissedAt 为后续变更加入 DismissedAt *time.Time Stage *string Notes *string }

设计决策 2(见 design.md)解释了为什么返回领域Interaction而不是db.UserJob:pgtype是持久化细节,不允许泄漏进与传输无关的 service 契约;被否决的替代方案是返回db.UserJob——那会让 service 和 handler 双双重新耦合到 pgtype。

哨兵错误(jobtracking.go第 145–160 行)是 service 契约的一部分。对照 tasks.md 5.3 的要求,service 返回的语义化错误让 handler 的 404/400 决策留在 tracking 领域内,而不是把pgx.ErrNoRows透传给中央ErrorHandler:

哨兵错误语义handler 映射
ErrJobNotFound未知 slug404
ErrInvalidStagestage 不在受控词表中400
ErrEmptyTrackstage/notes 均未提供400
ErrNoInteraction无可清除的交互行(不外泄,由 Service 转为零值成功)—

stage 词表由 stages.go 中的userjob.Stages/ValidStage提供,是单一事实来源:preparing/applied/screening/responded/interview/offer/accepted/rejected/withdrawn/expired,track 端点据此校验,前端列表也从它生成。

4. 窄 Repository 接口与持久化适配器

Repository接口(jobtracking.go 第 162 行起)遵循设计决策 1:由消费者定义接口。任务清单 2.2 中的最初版本只有 6 个方法;当前接口已随后续切片扩展(dismiss、filter 列表、pipeline、application-id 寻址的写操作等),但"接口最小化、方法必须被 service 实际调用"的原则保持不变。

适配器 repository.go 完成了任务 3.1 描述的全部映射,关键实现点:

  • 编译期证明(第 22 行):var _ Repository = (*QueriesRepository)(nil);
  • slug 解析(第 38–47 行):pgx.ErrNoRows→ErrJobNotFound;
  • pgtype 转换:textPtr把pgtype.Text转为*string(NULL → nil);textFromPtr反向转换,nil 产生 invalid(NULL)Text,使 SQL 的COALESCE保留列原值——这就是 track 端点"nil 字段保持不变"部分更新语义的存储层落点;
  • 事务化 apply(markApplied,第 133–155 行):先LockJobForApply锁岗位行再写,使并发 apply 串行化、applied_count不会重复自增,且applied账本事件与计数器在同一事务、同一谓词下落库,二者不可能分叉;
  • unsave 的 no-row 语义(第 167–176 行):pgx.ErrNoRows→ErrNoInteraction,由 Service 侧转成零值成功(任务 2.5 的幂等性规则)。

5. 业务规则在 Service 中的位置

对照任务 2.3–2.6 的规则要求,jobtracking.go 中的关键行为:

  • slug→id 解析:RecordView/SaveJob/Unsave等每个写用例都先经s.repo.JobIDBySlug解析,未知 slug 直接返回ErrJobNotFound,不触及业务写;
  • Track 的预校验(第 558–570 行):stage == nil && notes == nil→ErrEmptyTrack;stage != nil && !userjob.ValidStage(*stage)→ErrInvalidStage。校验发生在 slug 查找之前——坏请求永远不碰数据库;
  • Unsave 幂等(第 490–505 行):repo 返回ErrNoInteraction时,service 将其替换为Interaction{JobID: jobID}, nil——"已经没保存"是成功而非错误;
  • 部分更新:stage/notes 以*string指针传入 repo,nil 即"不动"。

单测 jobtracking_test.go 用内存 fakefakeRepo(第 15–191 行,无 Fiber、无数据库)逐一钉死了上述规则,且测试断言了短路行为:例如TestTrack_NilNil_ReturnsErrEmptyTrack在断言错误的同时检查repo.slugCalls == 0,确保校验确实在数据库之前短路。TestUnsave_NoInteraction_ReturnsZero则验证零值交互除JobID外全为 nil。

6. 薄 Handler:错误映射与线格式

重构后的传输层在 user_jobs.go(变更文档写作时路径为internal/handler/user_jobs.go,后随仓库结构调整迁移到internal/api/handler/)。五个 tracking handler 现在做且仅做四件事:取auth.UserID、解析 body、调 service、渲染{"data": …}。

  • wire shape(第 89–100 行):interactionResponse省略user_id,时间戳字段用*time.Time、stage/notes 用*string,未发生交互时序列化为null;
  • 错误映射(trackingError,第 119–138 行):ErrJobNotFound→404 "job not found",ErrInvalidStage/ErrEmptyTrack→400,其余错误透传给中央RenderError落 500;
  • 组合根(handler.go 第 798 行):tracking := jobtracking.New(jobtracking.NewQueriesRepository(queries, cfg.Pool), jobtracking.WithReminders(reminders))——service 只构造一次。

7. 双层守卫:集成测试 + 映射单测

设计决策 3 解释了 wire 漂移风险的处置:把pgtype.Timestamptz/pgtype.Text换成*time.Time/*string可能引入微妙的 JSON 格式漂移,而 handler 单测跑在&Handler{}+ nil DB 上,只能覆盖到打数据库之前的拒绝路径,无法刻画成功路径的 JSON。因此用两层守卫覆盖:

  1. 端到端:user_jobs_integration_test.go(//go:build integration,真实 Postgres)把成功响应解码进{job_id, viewed_at, saved_at, applied_at}的*string时间戳结构体,并断言 save/unsave 契约与 404——这正是 tasks.md 1.1 要"不得重写"的 no-drift 守卫。运行命令为go test -tags=integration ./internal/api/handler/(需 Docker)。其三个子测试分别是:无交互行时 unsave 返回 200 零状态(且user_jobs表不产生新行)、save→unsave 往返(unsave 后saved_at为 null 但viewed_at保留)、未知 slug 返回 404;
  2. 单元测试:钉住提取出的Interaction → interactionResponse映射——字段名、RFC3339 字符串、缺席字段的null,无数据库、跑在快速套件里。

这个拆分的价值:单测在开发循环内快速捕捉格式/字段漂移,集成测试是合并前(CI 或本地有 Docker 时)的权威端到端检查。

8. 验证清单与后续演化

tasks.md 第 5 节的验证标准值得作为任何"提取服务层"类重构的收尾清单:

  • go test -tags=integration相关包全绿(wire 契约无漂移);
  • go build ./...、go vet ./...、go test ./...全绿;
  • 依赖自审:service 包内不得出现pgtype/pgx/fiber导入;handler 中不得残留 stage 校验、部分更新、幂等、slug 解析等业务规则。

从源码结构看,这次提取确立的模式已被后续变更持续复用:jobtracking包在 5 个初始用例之外,扩展了 dismiss/undismiss、filter 列表与分页(ListTracked)、pipeline 统计、reminder 副作用端口(WithReminders,让 in-app assistant 等非 HTTP 调用方也能触发同样的提醒语义),以及面向"岗位被删除后孤儿申请"的 application-id 寻址写操作(TrackApplication/UntrackApplication/ClearApplicationProgress)——jobtracking_test.go 中TestSaveJob_SchedulesTheReminder等用例注释明确写道,这正是"把规则从 HTTP 门口移到 service"的收益:经由 assistant 直接调 service 的保存,从此也能正确产生提醒行。换言之,extract-job-tracking-service这份任务清单不只是一次孤立的重构,而是 freehire 服务层模式(窄接口 + 领域哨兵错误 + 双层测试守卫)的起点。

  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

相关推荐

上一篇:如何快速构建超强安全架构浏览器Ladybird:图像解码与网络连接完全隔离的终极指南
下一篇:DFlash架构深度解析:从滑动窗口注意力到因果SWA层的技术演进

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进