- 后端
- 前端
- 搜索引擎
- 网页爬虫
- 数据工程
- AI 应用
- 可观测性
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
本篇以归档变更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用 fake
Repository写失败单测: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.1
go test -tags=integration ./internal/handler/(真实 Postgres)确认 1.1 的 user_jobs wire 契约保持绿色。若开发环境没有 Docker,需记录该套件必须先在 CI 通过再合并;映射单测(1.2)则是开发循环内的守卫; - 5.2
go 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 | 未知 slug | 404 |
ErrInvalidStage | stage 不在受控词表中 | 400 |
ErrEmptyTrack | stage/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。因此用两层守卫覆盖:
- 端到端: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; - 单元测试:钉住提取出的
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
相关推荐
freehire 认证服务抽离实战:把 OAuth 账号解析策略从 HTTP Handler 中剥离为可单测的 Service + Repository
freehire 认证服务抽离实战:把 OAuth 账号解析策略从 HTTP Handler 中剥离为可单测的 Service + Repository 本篇技
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire OAuth 账户解析重构:把最危险的认证策略从 Handler 抽成可单元测试的 Service + Repository
freehire OAuth 账户解析重构:把最危险的认证策略从 Handler 抽成可单元测试的 Service + Repository 这篇技术文章基于
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire 职位跟踪服务层抽离:用「用例 Service + 窄 Repository 接口 + 薄 Handler」重建 Handler 分层
freehire 职位跟踪服务层抽离:用「用例 Service + 窄 Repository 接口 + 薄 Handler」重建 Handler 分层 本文基于
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考