☰
解构 freehire 第 6 层 internal/application:候选人—职位关系的分层边界与 22 个包
2026/10/23 2:48:16 网站建设 项目流程

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

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

internal/application是 freehire 十一个代码块(block)中的第 6 层,负责“候选人与一个职位之间随时间演进的关系”:跟踪状态与阶段、应用事件账本,以及为它们提供事实的邮件与日历链路。本文以该块的自述文档为主线,结合internal/platform/arch/layering的分层表、.golangci.yml的 depguard 规则和包内实现,讲清这层为什么存在、边界如何被双向强制、22 个包各自回答什么问题,以及新增包时最容易踩的坑。

对正在阅读或修改 freehire 的工程师来说,读完可以掌握三件事:能准确判断一段“申请人跟踪/邮件/日历”代码该落在哪一层;能解释application与search同层却互不可见的真实原因;能在本地复现 CI 的两道架构守卫并读懂它们给出的违规信息。

一、这层在八层架构中的坐标

块(block)是internal/<block>/<pkg>/的目录组织,层(layer)是块之间的单向依赖顺序。application位于第 6 层,与search同层,其下是job(5)、candidate(4)、ai/identity(3)、dict(2)、platform(1),其上是engage/ingest(7)和api(8)。

权威表格在 blocks.go:

var Layers = map[string]int{ "platform": 1, "dict": 2, "ai": 3, "identity": 3, "candidate": 4, "job": 5, "application": 6, "search": 6, "engage": 7, "ingest": 7, "api": 8, }

规则只有一条:一个块只能导入严格低于自身所在层的块,同一层的不同块之间双向都不允许。把这个规则套到application上,就得到块文档开头那两行“可以导入 / 禁止导入”:

方向块说明
允许platform、dict、ai、identity、candidate、job、application自身严格低于第 6 层,或同块内部
禁止search同层、不同块
禁止engage、ingest高于第 6 层
禁止api高于第 6 层,且是最上层

为什么同层的search也必须禁

这一条最容易被误解成“层号写反了”。块文档对此给了一句几乎可以直接作为设计原则引用的话:

search与本块同层,禁令双向生效:两个能互相看见的块,就是同一个块的两个名字。

换句话说,允许application ↔ search互相导入,等于把两个块合并成一个环形依赖,第 6 层就不再是一条清晰的边界,而是一团可以继续生长的耦合。设计文档 2026-08-22-internal-module-split-design.md 把这条规则表述为“两个共享同一层的块在任何方向上都不能互相看见”,并把它作为depguard规则可写性的前提。

依赖方向的实际形态

这层的绝大多数依赖向下:jobtracking同时导入 userjob、job/applydate 与 job/jobview,把“职位卡片长什么样”交给第 5 层决定;mailmatch只用dict/normalize做公司名归一化;deliverywindow是一个纯time计算的叶子包。

值得注意的是deliverywindow:它的包注释明确写着被internal/engage/notify、internal/engage/reminder、internal/engage/nudge导入。这是合法的反向引用——第 7 层的块可以向下导入第 6 层。这也解释了它为什么独立成包:时间判断逻辑只写一次,三个通知引擎各自在自己的投递循环里调用它。

二、邮件与投递为什么是同一个块

application的块文档给出了一条不走寻常路的分块理由:

邮件与跟踪被放进同一个块,是因为一封被分类的邮件会推进一个阶段,而跟踪器又会读取分类器的结果。

这不是把两个“差不多相关”的模块塞在一起,而是一个可验证的双向依赖:

  • mailclassify→userjob:classification.go 的AdvanceStage直接调用userjob.Forward(current, target)判断“这封邮件是否足以自动推进阶段”;
  • jobtracking→mailclassify:suggestion.go 用mailclassify的状态词汇把“最新一封已分类邮件”翻译成一个可接受的阶段建议。

如果强行拆成两个块,这两条边会立刻变成跨块环。设计文档对此的结论很干脆:拆开需要一个不产生任何收益的接口接缝(interface seam),“它们本来就是一个领域”。

顺带一提,mail相关包并非都在第 6 层:mailpreview(出站邮件的开发预览)因为导入 8 个engage包而被放在第 7 层;mailtpl(出站邮件共用外壳)留在第 6 层,因为它只被engage向下引用。判断依据始终是“它导入谁”,而非“它名字里有 mail”。

三、22 个包的分工地图

blocks.go中的application块列出了 22 个包(blocks.go)。按职责可以分成七组:

职责组包核心问题
邮件接入gmailsync、mailingest、mailbox邮件怎么进来:Gmail OAuth 同步、SES 入站守护、托管邮箱地址合成
分类与关联mailclassify、mailmatch、maillink、mailrecall、inbox这封邮件是什么、属于哪个申请、要不要自动关联、如何历史回溯
时间线ical、calmatch、calsync日历事件怎么读、属于哪个申请、如何同步
事件账本appevent、apptimeline发生了什么、何时发生、如何按范围读出
跟踪与阶段userjob、jobtracking阶段词汇是什么、当前在哪、是否可以自动前进
派生视图joblists、viewlog、replyrate(在userjob内)、deliverywindow、followup用户自建列表、浏览计数、回复率基准、投递时间窗、跟进信
自动投递autoapply、autoapplyorchestrate无人工提交的队列语义与跨进程编排

3.1 邮件接入:三个入口,各有边界

  • gmailsync负责 OAuth 授权与拉取,connector.go、gmailapi.go、worker.go分工明确;grant.go管授权,senders.go归一化发件人。它通过platform/safehttp发出请求,不直接持有 HTTP 细节。
  • mailingest是 SES 入站邮件的写入路径,source_ses.go只在它内部出现;parse.go负责把原始邮件解析成可存储的形状。
  • mailbox只做一件事:把账号的用户名拼成<username>@<domain>的托管邮箱地址,且只在首次使用时惰性分配(UsernameEnsurer接口)。它的注释特意强调“释放邮箱会停止投递,但不动用户名”,说明地址与账号是两个生命周期。

3.2 分类与关联:从“像什么”到“属于谁”

mailclassify的第一段注释就划清了职责:它持有受控状态词汇和“把分类结果映射到阶段流水线”的纯规则,LLM 适配器在旁边的classifier.go里。词汇包括acknowledgement、screening、interview_invitation、assessment、offer、rejection、info_request,以及incomplete_application、other。文件本身承担两件事:拒绝把词表外的值持久化,以及对不可信邮件正文做提示注入防护。

mailmatch的取舍写在包注释里,且值得单独记住:它从不用发件人域名做匹配。原因是收件箱里的邮件来自 ATS 中继域(ashbyhq.com、greenhouse-mail.io这类),而不是雇主域名。它只用线程连续性和发件人名称/主题里的公司名这两个确定性信号,把概率性的尾部留给 LLM 调用方。

maillink把这个判断收敛成一个可测的阈值结构(decide.go):

type thresholds struct { autoLink float64 stage float64 } func (t thresholds) autoLinks(m mailmatch.Match) bool { deterministic := m.Tier == mailmatch.TierThread || m.Tier == mailmatch.TierName return deterministic && m.Confidence >= t.autoLink }

注释解释了为什么这必须是一个谓词而不是两处判断:两个调用方(要不要为此花一次 LLM 消歧、要不要把结果落库)问的是同一个问题,写两遍就会漂移——要么为没人读的候选付费,要么把刚刚交给模型消歧的邮件又自动关联掉。同时,只有确定性层级够格自动关联;LLM 的“自信选择”一律降级为建议,因为它读的是攻击者可控的文本。

inbox则是这组的“用例层”,它的存在理由同样是一个架构判断:邮件能力有两个读者——/me/inbox、/me/emails的 HTTP 调用方,以及在应用内直接以会话所有者 id 调用服务、根本不发 HTTP 请求的内置助手。如果规则写在 Fiber handler 里,第二个读者就永远碰不到它。因此规则住在inbox,只有渲染写了两遍。

3.3 时间线:只信任一个信号

ical明确声明自己不是 RFC 5545 实现,“唯一理解的属性是 UID”。这不是偷懒:UID 正是让日历里的会议与邮箱里的邀请可证明是同一场meeting 的唯一凭据。

calmatch因此只保留一档:会议带有“邮件匹配器已绑定到某个申请”的邀请标识,链路闭合,无任何推断。包注释记录了一个被删除的弱化档位——按事件标题里的公司名匹配——以及它同时犯的三个错误:拿标题比对连字符形式的company_slug(acme-inc永远不会出现在日历里);单 token 时是无锚点子串匹配(“Q3 ramp-up planning”会被读成与 Ramp 的面试);且产物无法确认或驳回,错误是永久的。结论是:一个很少触发、触发即错、且无法纠正的档位,比没有档位更糟。

calsync只读primary日历,理由写在常量旁:其他日历是订阅、共享团队日程与节假日,而候选人接受的面试会落在主日历上。

3.4 派生视图:语义住在 Go,不住在 SQL

  • joblists:有名字的职位集合(与单标记的“收藏”无关),支持成员管理与只读公开 slug,持有名称/描述长度边界和每用户上限的校验,持久化与 Postgres 错误到哨兵值的映射交给Repository。
  • viewlog:把 nginx 访问日志解析成按职位、按天的浏览计数,且完全在请求路径之外。读路径不写任何计数器,cmd/rollup-views离线喂给它一天日志,再把结果应用到jobs.view_count与job_daily_views。
  • replyrate(位于userjob):回复率基准的闸门与“从全局中剔除调用者自身贡献”的算法,属于统计口径而非 SQL。
  • deliverywindow:判断“现在”是否是向某个本地时区账号投递通知的正确时刻;无法识别的时区回退到 UTC,绝不因为时区缺失而阻塞投递。
  • followup:生成“申请沉默后候选人的跟进信”。它被明确规定为确定性、无 I/O、不调用模型,语气规则(不道歉、不写 “just checking in”、必须给出具体问题)由测试钉死。

viewlog里还有两条可直接当运维规范的细节。第一,任何非空的Sec-Purpose头都不算一次浏览:前端设置了data-sveltekit-preload-data="hover",指针划过列表会为每张卡片请求/jobs/<slug>/__data.json,若计入就会把没人打开的职位刷高。判定选择“任何非空值都拒绝”,而不是枚举已知值,因为该头存在本身就在说“这不是真实导航”。第二,部署顺序不可颠倒:解析器必须先于 nginx 的log_format变更上线;解析器同时容忍新旧格式,先上解析器是空操作,先上 nginx 则会喂进旧模式无法匹配的行,一整天静默计零。

3.5 自动投递:不猜、不重试、不重复提交

autoapply是cmd/auto-apply背后的领域逻辑,自身不依赖 pgx/Fiber:Store、AnswerSource、SidecarClient都是端口,真实实现在cmd/auto-apply里。三条不变量值得完整继承:

  1. 只有每个必答问题都有答案才提交。Submit只返回StatusApplied或StatusParked,不存在“部分填写”的结果;必答字段无已知答案就整单停放,代码从不猜测。
  2. Park不是重试。停放需要的是新数据而不是再试一次,因此它被排除在回收之外(blocked_at),不消耗MaxAttempts;只有真正的瞬时失败(Fail)才花重试预算。
  3. 两种结果立即进死信,都走deadLetterImmediately(即Fail(..., maxAttempts=1)):真实提交但本地记录失败(Store.Submit在侧车已报StatusApplied之后出错),以及无法确认的提交(StatusUnconfirmed,侧车点了提交但无法判断雇主是否接受)。共同理由是普通重试路径最终会重新武装该行,从而可能对一份也许已经投递成功的职位再次调用浏览器驱动,而“绝不重复提交”是硬约束。文档还诚实记录:第二种情况是代码评审发现的,早期版本把“未确认”映射成普通 error,走了可重试路径。

另一个容易被忽略的约束来自队列入口:Store.Claim只返回已审核通过的条目,真实 SQLClaimAutoApplyBatch要求tailored_cv_id IS NOT NULL AND review_decision = 'approved'。没有定制简历、或候选人尚未审核的条目只是躺在队列里不被认领——这是第 6 层内部对“谁能被自动投递”的回答。

此外还有一条上游闸门:唯一写入auto_apply_queue的internal/api/handler的PostJobAutoApply,会在入队前调用internal/candidate/hardconstraint.Evaluate,当 (候选人, 职位) 对被判定存在未满足的work-authorization或location-and-work-mode阻断项时拒绝入队;由AUTO_APPLY_ELIGIBILITY_ENFORCE控制,未设置时为影子模式(只记录、照常入队)。因为闸门在api(第 8 层),autoapply这一层永远看不到被拒绝的行。

autoapplyorchestrate是这层唯一的 Inngest 函数:触发定制、然后暂停等待候选人审核决定,跨进程重启仍可恢复。它有两个值得记住的常量:ReviewWaitTimeout = 7 * 24 * time.Hour,以及一次内部 API 调用的超时必须高于定制端点自己的 10 分钟预算——注释记录了上一版五分钟导致的真实故障:定制通道最多 30 次模型调用、每次最长 180 秒,五分钟不是“跑完之后”,而是“跑在中间”,于是生产上每次都在通道中途挂断,CV 修改已写入却无人得知。

四、阶段词汇:一个事实源,四张表,四种问题

userjob是这层最典型的“规则包”。它不含数据库访问,只持有被 handler、契约生成器和助手共同使用的词汇与规则。10 个阶段定义在 stages.go:

var Stages = []string{ "preparing", "applied", "screening", "responded", "interview", "offer", "accepted", "rejected", "withdrawn", "expired", }

围绕同一份列表,四张表回答四个不同问题,每张都被一个测试绑定:

文件回答的问题守卫测试
stages.go有哪些阶段、是否合法(Stages、ValidStage)—
pipeline.go走了多远、是否已落定(activeRank、terminalStages、Forward、IsTerminal)TestEveryStageIsRankedOrTerminal
silence.go(今在internal/job/silence)可以沉默多久TestSilenceThresholdsCoverExactlyTheActiveStages
groups.go粗粒度列怎么显示、叫什么(Groups、GroupOf、Label)TestEveryStageBelongsToExactlyOneGroup

counts.go把按阶段的行折叠成流水线快照(CountByStage),测试要求阶段计数之和恒等于申请总数,未知阶段要被折算而不是丢失。

4.1Forward:只向前,只在活跃阶段之间

func Forward(current, target string) bool { if IsTerminal(current) { return false } next, ok := activeRank[target] if !ok { return false } return next > activeRank[current] }

三个细节都带着历史教训。activeRank刻意不等于Stages的下标:accepted、rejected、withdrawn排在offer之后,但它们是结局而非位置,申请不会“穿过”它们,因此没有 rank。current为空或未排名时低于所有阶段,所以第一封被分类的邮件可以播种阶段。目标是终态或未知时不构成自动推进——判定结果是拒绝、录用还是撤回,是候选人的决定或明确动作,绝不从一封邮件推断;只有expired是“无人决定也已落定”(雇主没回、或职位消失),且它同样是候选人的结论,没有任何阈值自动算出它。

pipeline.go的注释还记录了这个状态机曾经住在mailclassify里造成的真实漂移:往Stages里插入一个阶段,它会拿到 rank 0,排名低于applied且不在终态集合中,于是任何向前信号都会把它“向前推进”到倒退的位置。现在失败的是测试,而不是生产数据。

4.2groups.go:把“一个状态的第三个名字”删掉

Groups定义五列,按流水线顺序:preparing、applied(含 applied/screening/responded)、interview、offer、closed(含 accepted/rejected/withdrawn/expired)。注释说明了为什么三个结局共用一列:并列 Accepted/Rejected/Withdrawn 会得到三个几乎总是空的列,紧挨着一个装着大部分历史的列;卡片保留自己的阶段标签,粗列与精确结局同时可读。这也解释了交互上的一条约束:把卡片拖进Closed必须追问具体结局,因为分组本身不决定阶段。

GroupOf对词表外的阶段返回空串而不是回退到applied——一个无法归位的阶段不能作为“雇主已沉默”的证据,而看板的沉默徽章正是从 applied 组读这个事实。

这套标签此前在前端有四处副本(board.ts的STAGE_COLUMN、七个流水线桶、漏斗自己的列表、HomeFunnel.svelte里的第四份),导致一个已落定的申请在抽屉里显示Rejected、在看板上显示Closed、在桶里显示rejected,而in_progress、declined这两个桶名在别处根本不存在。现在四处都从STAGE_GROUPS/STAGE_LABELS派生,由cmd/gen-contracts生成。而“词语属于 Go 而不属于 TypeScript”还有第二个理由:内置助手直接调用jobtracking、从不经过 Fiber。

4.3 沉默阶梯为什么不在这个块里

本层的userjob曾持有silenceThresholds,如今阶梯已外移到 internal/job/silence。原因是一个跨层事实:ghost 信号要按“针对某职位的申请沉默了多久”来判断职位,而职位不是申请,所以阶梯必须坐在两者之下。第 6 层的实现通过测试(pipeline_test.go)与它对齐:有 rank 的阶段必须恰好有阈值,反之亦然——加一个活跃阶段却不加阈值,等于让它永远容忍沉默;加一个阈值却没有 rank,则沉默状态无人消费。

沉默阶梯本身值得完整抄录其“来源标注”,因为它示范了如何让一组数字不被误读为测量结果:applied21 天是实测(92 个观测申请,标记其中 16%);screening18、responded15 是在实测锚点之间等距插值(每次 3 天,让推导出的台阶一眼可辨);interview12 是实测(6 个观测,标记 3 个;从 7 提高,因为 7 会标记 6 个中的 5 个——几乎每张卡都有徽章等于没人读);offer5 是判断(样本中没有任何申请到过该阶段);preparing21 两者都不是——它复制applied的值只为满足覆盖测试,实践中不可达(TrackedJob.Silence()在AppliedAt == nil时短路,而该阶段保证如此)。

还有两条不变量:阈值是最后一个可容忍的日子,不是第一个越界日;已落定的申请报告空状态而不是active,看板必须能区分“无事可等”与“在等且响应及时”。TestSilenceThresholdsGrowStricter守住阶梯方向——单个场景永远发现不了整体倒置。

五、事件账本:列回答“现在”,账本回答“发生过什么”

阶段列会被覆盖,因此这层引入了application_events,并明确规定:聚合读的是账本,不是这些列。文档列出了在被覆盖时丢失的三类事实:stage没有变更时间;followed_up_at只存一次催促,第二次会抹掉第一次;删除一封邮件会抹掉“有过回复”的唯一记录。

appevent只持有词汇与规则,不做数据库访问,形状与userjob对阶段的做法一致,好让每个调用方在写入前校验,规则无需连接池即可测试。事件种类刻意窄且稳定——“它说的是发生了什么”,而Signal说的是里面有什么;把两者合并,会让每一次分类器新增都变成对一张追加表的修改:

const ( KindApplied = "applied" KindEmployerReply = "employer_reply" KindFollowUpSent = "follow_up_sent" KindStageSet = "stage_set" KindInterviewScheduled = "interview_scheduled" )

来源词汇同样成对出现:三种邮件存储(mail_gmail、mail_hosted、mail_external)、两种候选人自述(user、assistant)、calendar_google、system与auto_apply。其中SourceSystem被明确标注为不可用于日数计算:它记录的是“我们何时注意到”,而不是雇主侧的时间事实;SourceAutoApply单独存在,是为了让“自动投递提交了这条”成为可过滤的显式契约,而不是SourceSystem恰好只与applied搭配的巧合。

账本的两条信任规则值得记住:

  • 追加写入,只有一个被批准的例外:RedateApplication可以改applied事件的occurred_at。迁移 0062 声明了追加规则,其余一切仍然有效——撤回是打戳而不是删除,第二次催促是第二行。重定日期不是新事实,而是对已记录事实的修复;若只改applications.applied_at而不改事件,卡片会读一个月、所有聚合读另一个月,因为聚合读的是occurred_at。它不写第二个applied事件,也绝不触碰recorded_at,于是“何时发生”与“何时得知”始终可分。
  • 事件写入骑在已经决定变更的那条语句里:MarkJobApplied在提升applied_count的同一谓词下写入applied事件;TrackJob只在阶段真的移动时写stage_set;邮件路径的AdvanceUserJobStage自 2026-09-06 起同样如此,且用消息自身的received_at而非now()定日期。最后这条曾是例外,代价是整个面试准备提醒失效:ListInterviewPrepCandidates读账本,而“只由邮件推动”的阶段对它不可见。

apptimeline是账本的第一个“带日期的读者”:按范围读某位调用者的全部事件,从旧到新,连接两张jobtracking不碰的表。它不是jobtracking的方法,因为后者围绕单个 (user, job) 对的变更组织,而这是跨全部申请的区间读、背后没有变更。规则同样不写在 Fiber handler 里:内置助手直接用会话所有者 id 调服务,“上个月发生了什么”对它显然有用。

六、跟踪用例:卡片的形状是一条性能约束

jobtracking承载 view/apply/save/unsave/track 的用例,与 Fiber、pgx 解耦,由调用方提供Repository。几条与本层边界相关的事实:

  • 列表接口返回的是卡片(jobview.Card),不是职位全量:雇主、职位、声明的事实、技能、集合、生效发布日期,以及服务端切好的blurb。注释给出了量化理由:500 条申请时,嵌入整行jobs让 2.83 MB 的响应里 2.37 MB 是没有任何行会渲染的描述文本,还附带每行的 TOAST 读取。TestMeasureBoardLoad守住这条线——描述重新出现在列表里或载荷越过上限就失败。
  • 看板/已投递/全部视图是“一个窗口上的两次查询”:有职位支撑的行来自ListUserJobs,职位已删除的申请来自ListOrphanedApplications(这张表已无法再 join)。ListInteractions把请求的(limit, offset)在两者间分配:向第二个查询要第一个剩下的量、从第一个结束的偏移继续,页面已满时根本不问。注释记录了 2026-09-06 之前的缺陷:两者拿到同一个窗口,导致一页最多返回2*limit行,且越过“有职位支撑”的总数后会出现空页——而meta.total还说有更多。这不是观感问题:SPA 的Paginator按响应长度前进,翻倍的一页会跳过无人可达的窗口;对 API-key 客户端或走看板的助手来说,就是死循环。
  • user_jobs.followed_up_at记录“候选人自己催过”,且刻意置于计时之外。last_activity_at是GREATEST(applied_at, 最新关联入站邮件),衡量对方沉默了多久;把跟进加进这个GREATEST会在最需要徽章的时刻把徽章清掉。因此它被放在沉默判断旁边传递(jobtracking.TrackedJob、列表响应、GET /me/tracking/:slug),从不进入其输入——SilenceStateFor拿不到它,也就读不到它;TestAFollowUpDoesNotStopTheSilenceClock会在未来有人把它接进去时失败。
  • 跟进信由followup组装(确定性、无 LLM、不消耗额度),并且拒绝任何状态不是silent的申请,复用SilenceStateFor,使“提供跟进”与“沉默徽章”永不互相矛盾;POST记录这次催促。这条路径不发任何邮件。

七、两道守卫:一行报错 vs 全图报告

本层的边界被两套机制同时强制,且职责互补。

7.1 depguard:精确到违规的 import 行

.golangci.yml 中每个块一条规则,把本层及以上层的块全部列入 deny:

block-application: files: ["**/internal/application/**"] deny: - pkg: github.com/strelov1/freehire/internal/search desc: "application is layer 6; search is layer 6. A block imports only blocks strictly below it." - pkg: github.com/strelov1/freehire/internal/engage desc: "application is layer 6; engage is layer 7. A block imports only blocks strictly below it." - pkg: github.com/strelov1/freehire/internal/ingest desc: "application is layer 6; ingest is layer 7. A block imports only blocks strictly below it." - pkg: github.com/strelov1/freehire/internal/api desc: "application is layer 6; api is layer 8. A block imports only blocks strictly below it."

规则由blocks.go的同一张表生成,因此desc里那句“block imports only blocks strictly below it”不是套话,而是可以直接由层号验证的陈述。对search块也有一条对称规则拒绝internal/application——双向禁令在配置层面就是两条规则。

关键前提在文件顶部:run.build-tags必须列出integration与llmlive。否则 golangci-lint 根本不会解析仓库中带//go:build integration的文件(文档记录为 222 个),depguard 恰好在分层测试原本覆盖的位置失明。.github/workflows/ci.yml里golangci-lint-action@v9以--new-from-merge-base=origin/main运行,即棘轮模式:只对本次变更触及的行报错。

7.2 layering 守卫:一次报告整张图

layering.go 是一个对“图 + 表”的纯函数,不读文件系统,因此可以用夹具驱动,也可以被仓库级守卫用真实go list输出驱动。它定义四类违规:

Kind含义
unassigned包直接位于internal/下,不属于任何块
unknown-block包所在的块名不在层表里
upward导入了一个更高层的块
same-layer导入了同层的另一个块(双向禁止)

Check会跳过指向非internal/路径的导入(cmd/、services/不受管辖),跳过块内自引用,并对导入列表排序去重,保证每次运行输出一致——失败信息本身可读且可复现。

repo_test.go 把真实图接进来,四个测试构成完整验收:

  1. TestEveryPackageSitsWhereAssignmentSaysItShould:包必须恰好位于Assignment指定的路径。注释解释了为什么这比“在某个块下”更严:被丢进错误块仍算“在块下”,而分层检查只有当这个错误恰好跨层时才会发现;两个相邻层的块会把它完全藏起来。
  2. TestEveryPackageInTheRepoIsAssignedToABlock:仓库里存在但不在任何块中的包 → 失败。这正是“新增包必须加表”的强制点。
  3. TestBlockTableNamesNoPackageThatDoesNotExist:表里写了不存在的包 → 失败(防止改名后留下幽灵条目)。
  4. TestRepoRespectsTheLayering:一次性报告全图的所有违规,让大范围问题可诊断。

图只加载一次(sync.OnceValues),并用自定义分隔符格式抓取ImportPath、Imports、TestImports、XTestImports四组——测试导入被刻意包含,因为一个_test.go可以制造生产构建永远不会暴露的跨层依赖。buildTags常量的注释解释了两件事:为何必须命名(否则看不到带标签的图),以及为何这样做是可靠的(仓库没有否定约束、没有遗留// +build行,所以带标签构建是严格超集)。若go list返回空图,守卫直接报错,避免“空转通过”。

7.3 在本地复现

# 只跑仓库级分层验收(会调用 go list,需要可编译的模块图) go test ./internal/platform/arch/layering/ -v # 只跑全图检查 go test ./internal/platform/arch/layering/ -run TestRepoRespectsTheLayering -v # 只对本层跑 linter(含 depguard) golangci-lint run ./internal/application/... # 手动查看本层的内部导入边,便于判断一条新依赖是否越界 go list -tags integration,llmlive -f '{{.ImportPath}} {{join .Imports " "}}' ./internal/... \ | grep 'internal/application/' | grep 'internal/'

7.4 新增一个包的正确动作

  1. 决定它属于哪个块——判断依据是“它导入谁”,不是名字。llm是platform而非ai、ghost是job而非application,都是这条原则的产物。
  2. 把它加进 blocks.go 的blocks表;子包按全名登记(如auth/oauth、mentorship/busysync),并沿用父包所属块。
  3. 若它落在application,确认新导入的所有块都低于第 6 层,且没有碰到search。
  4. 跑上面两条命令。一条“包在仓库里但不在任何块中”的失败,说明第 2 步漏了。

一个已被测试钉住的细节:blocks_test.go的TestPlacementsThatContradictTheirNameArePinned逐条固定了那些“与名字直觉相反”的位置。它们存在的意义是让“把它改回看起来更自然的位置”变成一次测试失败,而不是一次环依赖的静默回归。

八、阅读这层时的三个判断准则

  1. 同层即禁区。看到application想导入search,正确做法不是加白名单,而是判断这段逻辑到底属于哪一侧:如果它读的是“我们的索引”,它属于search;如果它读的是“某人对某职位的动作”,它属于application。searchping之所以留在job,正是因为它公告的是职位的公开地址,而search指的是我们自己的索引。
  2. 规则包不带 I/O。userjob、appevent、ical、calmatch、followup、deliverywindow都是纯规则或纯计算。这不是风格偏好,而是让“助手不经 HTTP 也能拿到同一套规则”成为可能——凡是写在 handler 里的规则,第二个读者永远遇不到,这个错误在 CV 定制的联系人守卫上已经犯过一次。
  3. 邮件链路只信任可证明的链条。UID 闭环(ical→calmatch)、确定性匹配阈值(maillink)、按消息自身received_at定日期的事件(appevent):每一处都在把“推断”换成“证据”,并把无法证明的部分降级为建议或直接放弃。

本层的边界不是纸面约定:.golangci.yml让违规在一行 import 上失败,internal/platform/arch/layering让整张图在一次测试中说话,而build-tags的配置决定了这两道守卫是否真的看得见测试文件。修改这层代码之前,先读 internal/application/AGENTS.md 的“可以导入 / 禁止导入”两行,再读 blocks.go 里对应包的注释——那些注释记录了每一次“看起来该放这里、其实必须放那里”的取舍,也是这套结构能长期成立的原因。

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载
上一篇:如何用TokenTracker盯住Claude、Codex、Cursor的额度:17家AI订阅配额实时监控完整指南
下一篇:security-checker打包发布实战:Box配置构建独立PHAR的完整教程

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询