☰
freehire 中 per-user CV 外观默认值的架构设计:模板、排版与页边距的复用与分层约束
2026/10/23 2:39:06 网站建设 项目流程

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

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

本文围绕 freehire 仓库中的add-cv-appearance-defaults设计文档,解析一项典型的多层改动:如何在不复制校验逻辑、不侵入既有 CV 文档路径的前提下,为每个用户保存一份“新 CV 默认外观”。文章完整继承原设计文档的上下文、目标与非目标、七项关键决策、风险与迁移计划,并结合仓库中已经落地的迁移 SQL、Go 领域代码、HTTP 处理器与 Svelte 前端组件,说明每个决策在代码中的真实形态与验证方式。读者读完后可以掌握:分层架构下“用户级偏好”应放在哪一层、为什么把默认值写在创建入口而非 sanitizer、以及如何把自持久化组件改造成受控组件而不破坏原有调用方。

背景:一个只在单份 CV 里存在的设置

设计文档的Context一节先界定了既有状态。CV 的外观由三部分组成,并且这三部分都存放在每一条cvs行上:

  • template_id:模板标识;
  • Style{font_family, font_size, line_height}:字体族、字号(pt)、行高(em);
  • Margins{top, right, bottom, left}:四边页边距(英寸)。

这三者由internal/candidate/cv负责校验与钳制(Style.sanitized()、Margins.sanitized(),以及template.go中的模板注册表)。同时,设计文档明确了一条重要的所有权规则:internal/candidate/cvedit是已有CV 文档的唯一写入方。

问题在proposal.md(变更提案)中被直白地描述:外观只能在 tailoring 工作区内逐份 CV 编辑,每份新 CV 都从同一组硬编码值开始——classic-ats模板、0.5 英寸页边距、模板自带排版。已经调好一次外观的候选人,每新建一份基础 CV 都需要重来一遍;系统里没有地方表达“我始终想让我的 CV 长这样”。

三个硬编码的系统默认种子点

设计文档指出,当前恰好有三个调用点会用硬编码系统默认值为新的基础CV(job_id IS NULL)播种:

调用点设计文档标注位置当前仓库实现
Store.Tailor的简历播种分支internal/candidate/cv/store.go:314store.go
从简历重置/重建cv_reset.go:132cv_reseed.go(文件已更名)
CreateCV处理器internal/api/handler/cv.go约 356-390 行cv.go

而 tailoring(绑定职位的)副本始终从基础 CV 继承template_id/Style/Margins(通过CreateTailored与Align),因此它们不需要任何改动。这一点决定了整个改动的边界:只需处理“基础 CV 诞生的一瞬间”。

目标与非目标:先划清不可越界的范围

设计文档用 Goals / Non-Goals 把改动锁在一个很小的面上。

Goals:

  • 一条持久的、按用户存储的外观默认值记录,并使用与 CV 文档完全相同的规则校验;
  • 在三个既有的基础 CV 创建调用点上非侵入地应用它;
  • 原样复用StyleSettings/MarginSettings,把TemplateGallery泛化而不是分叉。

Non-Goals(明确不做):

  • 不把默认值重新应用到已存在/已打开的 CV(即 tailoring 工作区里不做“重置为我的默认值”);
  • 不新增顶层/my/*导航入口;
  • 不改变 tailored(job-bound)CV 的创建流程。

非目标并非随手排除,而是这个设计的“低成本”来源:一旦允许回写已有 CV,“保存默认值”就会变成一次批量文档迁移,需要处理版本历史、并发编辑与用户预期。

关键决策逐条解析

决策一:数据放在internal/candidate/cv,而不是internal/identity/userprofile

这是整份文档中最有架构价值的一条。userprofile已经持有一个类似的 per-user 偏好LocationPreferences,是“用户级默认值”最接近的既有范式。按直觉,外观默认值也应放进userprofile。

但仓库的分层规则(internal/platform/arch/layering)禁止identity(第 3 层)导入candidate(第 4 层)的类型。而Style、Margins、模板注册表与字体注册表都住在internal/candidate/cv。这意味着放在identity里就必须复制一份校验与注册表逻辑;设计文档进一步借用仓库自身normalize包对公司法律形式词表的告警来说明后果:副本一定会漂移。

选择同包新文件(appearance_defaults.go)后,SetAppearanceDefaults调用的就是 CV 文档路径已经在用的Style.sanitized()、Margins.sanitized()、ResolveTemplate/TemplateIDs()——不存在第二份实现。这是“依赖方向决定数据归属”而非“业务语义决定数据归属”的典型案例。

决策二:新增一张表,而不是给users加列

cv_appearance_defaults镜像了cvs表的template_id/style jsonb/margins jsonb形状,主键是user_id,但没有title与job_id。设计文档给出三点理由:

  1. 可选、可删除——用户没保存过就没有行;
  2. 与users行自身的迁移历史解耦;
  3. 与cvs结构一致,因此同一套 jsonb 编解码/清洗辅助函数可以直接复用。

落地的迁移见migrations/0128_cv_appearance_defaults.sql:

CREATE TABLE IF NOT EXISTS cv_appearance_defaults ( user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, template_id text NOT NULL, style jsonb NOT NULL, margins jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now() );

ON DELETE CASCADE让删号时这行自然消失,正好对应设计文档所说的“optional and deletable”。查询侧使用 upsert,语义是“整份替换”而非字段级 patch,见internal/platform/db/queries/cv_appearance_defaults.sql:

INSERT INTO cv_appearance_defaults (user_id, template_id, style, margins, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (user_id) DO UPDATE SET template_id = EXCLUDED.template_id, style = EXCLUDED.style, margins = EXCLUDED.margins, updated_at = now() RETURNING user_id, template_id, style, margins, updated_at;

由于一用户一行,ON CONFLICT的更新分支就等价于“把这一行整体换成新的完整集合”,无需在应用层区分首次写入与覆盖写入。

决策三:GET 永远返回具体形状

设计文档规定:没有保存过默认值时,GetAppearanceDefaults返回(系统默认值, false, nil),而不是零值或缺失值。这样设置界面永远有内容可渲染,也永远有东西可以 diff“未保存的改动”,客户端不必特判空状态。

实现中这一点体现得很直接(appearance_defaults.go):

row, err := s.repo.GetAppearanceDefaults(ctx, userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return AppearanceDefaults{TemplateID: DefaultTemplateID, Style: Style{}, Margins: DefaultMargins()}, false, nil } return AppearanceDefaults{}, false, err }

注意Style{}的零值不是疏漏。cv.go中明确写着:Style的零值表示“使用当前模板自带的排版”,且Sanitize会保留零值;这与Margins把零边距解析为具体 0.5in 的行为刻意相反。原因是模板应保持为一个完整的设计选择——切换到行距更松的模板时,行距应当真的变松,因为那个值从未被冻结进文档。所以在“系统默认外观”里,排版就是“交给模板决定”。

布尔返回值ok用于区分“真的存过”和“回落到系统默认”,但在本特性中客户端并不需要这个区分,设计文档也把它列为可接受的信息损失。

决策四:应用点在创建时的 seed 值,而不是给Sanitize加钩子

这是改动最容易被做错的一处。诱人的做法是让Sanitize知道 per-user 状态,在清理时顺便套用用户默认值。设计文档拒绝了它,理由是这会让Sanitize的语义——“把一个确实未设置的值填成系统默认”——在其它所有调用路径上被污染。尤其关键的是:对已有CV 的编辑必须继续把“未设置”理解为 0.5in/模板默认,而绝不能理解成“这个用户保存的默认值”。只有创建时的首次播种才读用户默认。

因此三个调用点各自在调用Store.Create/CreateTailored之前查一次默认值,替换掉今天的硬编码值,然后Sanitize照旧对到达的值运行。

Store.Tailor的播种分支(store.go):

defaults, _, err := s.GetAppearanceDefaults(ctx, userID) if err != nil { return Meta{}, Meta{}, false, err } doc := Seed(st) doc.Style = defaults.Style doc.Margins = defaults.Margins meta, err := s.Create(ctx, userID, defaultBaseTitle, defaults.TemplateID, doc)

重建处理器(cv_reseed.go)在同一位置做同一件事,只是它还会写一条版本历史里程碑:

defaults, _, err := h.cvStore.GetAppearanceDefaults(c.Context(), userID) ... seeded.Style = defaults.Style seeded.Margins = defaults.Margins meta, err := h.cvStore.Create(c.Context(), userID, "My CV", defaults.TemplateID, seeded)

需要注意的是,reseeds只在没有基础 CV时才读默认值;当已存在基础 CV 时,函数走CommitDocument路径并保留既有外观(TemplateID: base.TemplateID),这与“保存默认值从不回写已有 CV”的规格要求一致。

决策五:CreateCV请求里显式template_id优先

CreateCV的请求本就接受调用方传入的template_id(例如“先建空白、再选模板”的客户端流程)。设计文档规定:显式指定优先;未指定时才用保存的默认模板;而排版与页边距今天没有 per-request 覆盖字段,所以它们始终来自保存的(或系统的)默认值。

实现见cv.go:

defaults, _, err := h.cvStore.GetAppearanceDefaults(c.Context(), userID) if err != nil { return err } requestedTemplateID := in.TemplateID if requestedTemplateID == "" { requestedTemplateID = defaults.TemplateID } tmplID, err := validCVTemplate(requestedTemplateID) ... doc.Style = defaults.Style doc.Margins = defaults.Margins meta, err := h.cvStore.Create(c.Context(), userID, cvTitle(in.Title), tmplID, doc)

这段代码的注释甚至专门说明了它为什么不能直接依赖validCVTemplate("")——那条路径会一路落回classic-ats,从而在用户已保存默认模板时悄悄忽略它。代码审查阶段的修复(tasks.md第 8 项)也顺带更正了createCVRequest.TemplateID上过时的文档注释。

决策六:TemplateGallery.svelte增加受控模式,而不是分叉

TemplateGallery原本绑定cvId,点击时通过api.setCvTemplate自行持久化。设计文档的做法是新增可选value/onChange属性:

  • 传入cvId:行为与今天逐字节相同(拉取 + 自持久化);
  • 传入value:纯展示,渲染同一套网格与缩略图,点击只调onChange(id),不发 API 请求。

组件顶部注释把这两种模式写得很清楚(TemplateGallery.svelte):

// - cvId given: self-persisting, exactly as the tailoring workspace has always used it // - cvId omitted: controlled — the highlighted thumbnail mirrors the bindable `value`, // and picking one only updates `value`; no API call.

好处是“有哪些模板、缩略图长什么样”这件事仍然只有一个组件在维护。这是把自持久化组件改造成受控组件的标准手法:保留旧属性作为行为开关,新增受控属性,而不是复制组件。

决策七:设置界面显式保存,而不是工作区式自动保存

仓库里其它/my/*设置界面(如 profile 表单)都在显式动作时保存;tailoring 工作区的 800ms 防抖自动保存,是针对“用户正在实时编辑的活文档”的特例。默认值界面没有实时预览来为自动保存的响应性辩护,因此遵循更常见的/my/*模式。

落地后这一决策进一步演化:外观默认值从“一个由按钮进入的 Settings 页”变成了 CV 区块的两个视图(/my/cvs/template与/my/cvs/typography),由web/src/routes/my/cvs/+layout.svelte的 TabStrip 承载。两个页面共享同一个记录,因此保存逻辑被提到共享外壳CvAppearancePane.svelte中——它负责加载门控、说明文案与唯一的 Save 按钮,只有控件本身留在各自页面里。

页面本身非常薄(web/src/routes/my/cvs/template/+page.svelte、web/src/routes/my/cvs/typography/+page.svelte):

<CvAppearancePane lead={s.lead}> <TemplateGallery bind:value={cvAppearance.templateId} /> </CvAppearancePane>
<CvAppearancePane lead={s.lead}> <StyleSettings bind:style={cvAppearance.style} fonts={cvAppearance.fonts} /> <section class="space-y-2"> <h2>...</h2> <MarginSettings bind:margins={cvAppearance.margins} /> </section> </CvAppearancePane>

“页边距与字体放在同一个 tab”也有明确理由:它们回答的是同一个问题——文字如何落在页面上;而一个只有四个数字输入框的 tab 没人会打开。

共享状态放在web/src/lib/cvAppearance.svelte.ts,采用UserResource基类。这里有两个值得学习的工程细节:

  1. 为什么是一个 store 而不是每页各自 state:API 一次读写三个字段,如果每个 pane 持有自己的副本,切 tab 就会重新拉取,并丢掉另一个 pane 尚未保存的编辑;放在共享 store 里,编辑能跨 tab 存活,任一 tab 的 Save 都写整条记录。
  2. 为什么必须记录加载失败:基类把加载失败当作“保留默认状态”,这对只读缓存合理,对表单却是陷阱——一个显示空白默认值的 pane 会诱使用户保存,从而用空白覆盖真实记录。因此 store 单独记录loadFailed,pane 在加载成功前拒绝渲染控件。

保存实现也直接呼应“整份替换”的存储语义:

const result = await api.setCvAppearanceDefaults({ template_id: this.#templateId, style: this.#style, margins: this.#margins, }); this.apply({ defaults: result, fonts: this.#fonts });

由于PUT返回的就是清洗后实际保存的值,客户端不需要再发一次 GET 回读——这正是tasks.md第 8.2 项把SetAppearanceDefaults的签名从“只返回 error”改为返回(AppearanceDefaults, error)的原因。

服务端实现:校验规则的唯一来源

类型与两个方法

领域类型刻意极简(appearance_defaults.go):

type AppearanceDefaults struct { TemplateID string Style Style Margins Margins }

它只承载“一份新 CV 的创建调用点否则会硬编码的那三样东西”,不含任何文档内容。

写入方法(appearance_defaults.go)先解析模板、再清洗、再序列化,最后 upsert:

tmpl, err := ResolveTemplate(defaults.TemplateID) if err != nil { return AppearanceDefaults{}, err } saved := AppearanceDefaults{ TemplateID: tmpl.ID, Style: defaults.Style.sanitized(), Margins: defaults.Margins.sanitized(), }

这里体现了两条与 CV 文档完全一致的约束:未知模板被拒绝(ResolveTemplate返回ErrUnknownTemplate),越界值被钳制而非拒绝(sanitized())。

钳制规则的准确取值

由于复用了同一套函数,默认值记录享有与 CV 文档一字不差的边界。相关常量在internal/candidate/cv/cv.go:

维度默认行为允许范围其它规则
Margins单边0(未设置)→ 0.5 in0.25 in – 1.5 in超出上下界钳到最近边界
font_size0 = 交给模板8.5 pt – 12.0 pt先按 0.5 pt 步长四舍五入,再钳制
line_height0 = 交给模板0.3 em – 0.9 em直接钳制
font_family空 = 交给模板必须是字体注册表中的 id未知字体被清空,而不是报错

Style.sanitized()的语义值得单独强调:只钳制被设置过的值,零值保持为零。代码注释解释了这样做的两个理由——所有在Style类型出现之前写入的 CV 都没有 style 块,因此渲染结果不变、无需迁移;以及模板作为整体设计选择不应被冻结的行距值悄悄破坏。

相反,Margins.sanitized()会把每个为 0 的边解析成defaultMargin。这意味着通过 API 提交{"top":0,...}保存的边距记录会是 0.5,而不是 0——这是“边距属于文档内容”的语义在默认值上的自然延续。

模板注册表与陈旧模板的自愈

模板注册表在internal/candidate/cv/template.go,当前包含 9 个模板:classic-ats(默认)、centered、modern-sans、timeline、compact、mono-tech、sidebar、portrait、headshot。扩展方式是追加注册项并放置同名templates/<id>.typ,再用make cv-previews重新生成画廊缩略图——这也是“新增模板无需 schema 变更”的具体含义。

代码审查阶段发现了一个真实缺陷:注册表可能在用户保存默认值之后收缩。而Store.Create从不校验它的templateID参数,因此一个已失效的 id 会被静默带入新 CV,直到渲染时才失败。修复方式是在读取时自愈(appearance_defaults.go):

templateID := row.TemplateID if _, err := ResolveTemplate(templateID); err != nil { templateID = DefaultTemplateID }

排版与页边距不受影响,只有模板会回落到系统默认。对应测试为TestStoreGetAppearanceDefaultsHealsAStaleTemplate。

从三层 helper 收敛到一个共享读取点

tasks.md第 2.5/2.6 项记录了一次有价值的“简化迭代”,值得在设计类文章中保留,因为它展示了设计文档与实现之间的一次纠偏。

设计文档的 Risks 一节提出:把“为新 CV 解析有效外观”的逻辑收敛到一个 helper,让三个调用点都调它,以避免第四个创建路径出现时忘记同步。初版据此实现了Store.effectiveAppearance,并在注释里声称自己是“所有调用点读取该值的唯一位置”。

但这个说法是假的:三个调用点中有两个位于internal/api/handler(不同包),只能直接调导出方法GetAppearanceDefaults;helper 实际只被Store.Tailor使用。最终版本删除了 helper 及其两个只重复测试GetAppearanceDefaults的测试,让Store.Tailor也直接调用GetAppearanceDefaults。于是GetAppearanceDefaults才真正成为唯一的共享读取入口,其文档注释现在也如实这么写。

这个结果对设计文档的原始风险项给出了一种更轻的缓解:不靠“一个 helper 包办”,而靠“一个导出方法被所有调用点直接调用”。对跨包调用点来说,后者才是真正可行的收敛方式。

HTTP 表面:两个端点与错误映射

路由注册在internal/api/handler/cv.go,两者都是 cookie-only:

api.Get("/me/cv-appearance-defaults", mw.cookie, h.GetCVAppearanceDefaults) api.Put("/me/cv-appearance-defaults", mw.cookie, h.SetCVAppearanceDefaults)

处理器在internal/api/handler/cv_appearance_defaults.go。请求与响应形状一致,都是template_id/style/margins,并统一包在{"data": ...}中:

{ "data": { "template_id": "sidebar", "style": { "font_family": "inter", "font_size": 10.5, "line_height": 0.55 }, "margins": { "top": 0.75, "right": 0.5, "bottom": 0.75, "left": 0.5 } } }

PUT的语义是整份替换,而不是部分更新;处理器注释明确写了“a save always replaces the complete set, never a partial patch”。这一点的实际影响是:客户端必须把三个字段完整提交,只在某一 tab 提交自己那一部分会清空另一部分——这正是前端用一个共享 store 承载整条记录、由任一 tab 的 Save 写全量的原因。

错误映射沿用mapCVError(cv.go),cv.ErrUnknownTemplate→ HTTP 400"unknown template";请求体解析失败 → 400。因此“未知模板被拒绝且不保存任何默认值”这条规格由ResolveTemplate在写库之前失败来保证,而不是靠事后回滚。

处理器读取时丢弃了ok布尔值(defaults, _, err := ...),因为它对客户端没有可观察影响:无论有无保存过,返回的都是可直接渲染的具体形状。

测试与验证策略

规格文档中的每条要求都对应可执行的测试:

层测试文件覆盖点
领域层appearance_defaults_test.go回落系统默认、读取已保存值、陈旧模板自愈、按用户隔离、写入往返、拒绝未知模板、越界钳制
HTTP 层cv_appearance_defaults_integration_test.goGET 回落、GET 已保存、PUT 持久化合法输入、拒绝未知模板、越界钳制、重复 PUT 覆盖
创建路径cv_integration_test.go、cv_reseed_integration_test.go播种/空骨架两条路径使用保存的默认值;未保存时仍用系统默认;显式template_id优先

集成测试使用真实 Postgres(通过 testcontainers)并经由实际 HTTP 处理器驱动,运行方式在测试文件头部注明:

go test -tags=integration ./internal/api/handler/

tasks.md第 7 节诚实地记录了人工验证被跳过的原因:机器上 5432/8080/8090 端口已有 docker-compose 栈在运行,很可能属于同一仓库上的并发会话,搭建隔离栈既不安全也耗时,因此用户选择依赖自动化覆盖(前端另有svelte-check0 错误与pnpm test1376 通过)。文档同时明确指出自动化不能证明的事:新页面与入口在真实浏览器中的渲染与交互行为,这仍是一个待补的缺口。这种“说清楚证据边界”的写法,比笼统声称“已验证”更有工程参考价值。

风险、权衡与迁移

四项已知权衡

  1. 改了默认值不会追溯影响已有 CV。这是刻意的,但设计文档要求在设置界面的文案里明说,否则会被读成 bug。当前实现把这条说明放在两个 pane 共享的leadSuffix里,一次维护、两处生效。
  2. 三个调用点必须保持同步。如果将来出现第四条基础 CV 创建路径,就会漏掉默认值。原本计划用一个 helper 消解,实践后收敛为“所有调用点直接调用同一个导出方法GetAppearanceDefaults”(见上文简化迭代)。
  3. GET 无保存时返回系统默认,客户端无法区分“没存过”与“存的值恰好等于系统默认”。设计文档判定可接受,因为本特性没有需要该区分的“恢复系统默认”开关。事实上连ok布尔值都已被处理器忽略。
  4. 模板注册表收缩会让已保存的模板失效。这不是理论风险,而是审查阶段实际发现的缺陷,最终以读取时自愈解决。

迁移计划

设计文档给出的迁移方案是纯增量:新表、新端点、新路由、新的可选组件属性。没有既有数据变更,没有既有端点响应形状变化。一次部署即可上线;回滚也不需要越过标准 revert——如果先回滚代码,新表会保持为空且无人读取。

这一性质在很大程度上来自两条早期决策:把数据放在独立表而非users列(不触碰用户行的迁移历史),以及把应用点放在创建入口而非Sanitize(不改变既有文档路径的行为)。

实现落点速查

关注点文件
设计决策全文design.md
变更动机与影响面proposal.md
可执行任务与审查修复记录tasks.md
能力规格(新增)specs/cv-appearance-defaults/spec.md
能力规格(CV 构建器改动)specs/cv-builder/spec.md
建表迁移0128_cv_appearance_defaults.sql
sqlc 查询cv_appearance_defaults.sql
领域类型与读写方法appearance_defaults.go
校验与钳制规则cv.go
模板注册表template.go
简历播种创建路径store.go
重建创建路径cv_reseed.go
显式创建路径与路由cv.go
HTTP 处理器cv_appearance_defaults.go
前端共享状态cvAppearance.svelte.ts
共享设定外壳CvAppearancePane.svelte
模板/排版页面与分栏my/cvs/+layout.svelte
受控模板画廊TemplateGallery.svelte

结语:这份设计真正示范了什么

这个改动的代码量不大,但它把三类容易出错的决策处理得很干净。

第一类是层与归属:当“业务上属于用户”与“依赖方向上必须待在候选域”冲突时,选择让依赖方向获胜,并用同包复用换取校验逻辑的单一来源,而不是在低层复制一份会漂移的注册表。

第二类是语义边界:Sanitize的含义是“填充确实未设置的值”,因此 per-user 默认不能塞进它;只有创建入口有权读取用户偏好,编辑路径继续把“未设置”理解为系统默认。这条边界一旦破掉,保存默认值就会悄悄改写用户已有的文档。

第三类是改动的可回退性:独立表、独立端点、显式保存、受控组件属性、纯增量迁移——每一项都让这次上线可以被简单回滚,而不需要数据修复脚本。

tasks.md中那次 helper 删除同样值得记住:设计文档提出的缓解措施,在真实的分层与包边界面前未必成立;当注释宣称的“唯一入口”事实上不成立时,正确做法是让代码回到真实收敛点,并同步修正注释,而不是保留一个看起来优雅、实际只覆盖三分之一的抽象。

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载
上一篇:REFramework 使用指南:RE 引擎游戏 MOD 框架、VR 支持与脚本平台快速上手
下一篇:Cap:录完即得分享链接的开源录屏工具

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

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

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

立即咨询