【免费下载链接】freehirefreehire — 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、行高emMargins{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 长这样”。三个硬编码的系统默认种子点设计文档指出当前恰好有三个调用点会用硬编码系统默认值为新的基础CVjob_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/*导航入口不改变 tailoredjob-boundCV 的创建流程。非目标并非随手排除而是这个设计的“低成本”来源一旦允许回写已有 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。设计文档给出三点理由可选、可删除——用户没保存过就没有行与users行自身的迁移历史解耦与cvs结构一致因此同一套 jsonb 编解码/清洗辅助函数可以直接复用。落地的迁移见migrations/0128_cv_appearance_defaults.sqlCREATE 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.sqlINSERT 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.gorow, 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.godefaults, _, 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.godefaults, _, 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.svelteCvAppearancePane lead{s.lead} TemplateGallery bind:value{cvAppearance.templateId} / /CvAppearancePaneCvAppearancePane lead{s.lead} StyleSettings bind:style{cvAppearance.style} fonts{cvAppearance.fonts} / section classspace-y-2 h2.../h2 MarginSettings bind:margins{cvAppearance.margins} / /section /CvAppearancePane“页边距与字体放在同一个 tab”也有明确理由它们回答的是同一个问题——文字如何落在页面上而一个只有四个数字输入框的 tab 没人会打开。共享状态放在web/src/lib/cvAppearance.svelte.ts采用UserResource基类。这里有两个值得学习的工程细节为什么是一个 store 而不是每页各自 stateAPI 一次读写三个字段如果每个 pane 持有自己的副本切 tab 就会重新拉取并丢掉另一个 pane 尚未保存的编辑放在共享 store 里编辑能跨 tab 存活任一 tab 的 Save 都写整条记录。为什么必须记录加载失败基类把加载失败当作“保留默认状态”这对只读缓存合理对表单却是陷阱——一个显示空白默认值的 pane 会诱使用户保存从而用空白覆盖真实记录。因此 store 单独记录loadFailedpane 在加载成功前拒绝渲染控件。保存实现也直接呼应“整份替换”的存储语义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.gotype AppearanceDefaults struct { TemplateID string Style Style Margins Margins }它只承载“一份新 CV 的创建调用点否则会硬编码的那三样东西”不含任何文档内容。写入方法appearance_defaults.go先解析模板、再清洗、再序列化最后 upserttmpl, 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.gotemplateID : 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不同包只能直接调导出方法GetAppearanceDefaultshelper 实际只被Store.Tailor使用。最终版本删除了 helper 及其两个只重复测试GetAppearanceDefaults的测试让Store.Tailor也直接调用GetAppearanceDefaults。于是GetAppearanceDefaults才真正成为唯一的共享读取入口其文档注释现在也如实这么写。这个结果对设计文档的原始风险项给出了一种更轻的缓解不靠“一个 helper 包办”而靠“一个导出方法被所有调用点直接调用”。对跨包调用点来说后者才是真正可行的收敛方式。HTTP 表面两个端点与错误映射路由注册在internal/api/handler/cv.go两者都是 cookie-onlyapi.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 写全量的原因。错误映射沿用mapCVErrorcv.gocv.ErrUnknownTemplate→ HTTP 400unknown 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 -tagsintegration ./internal/api/handler/tasks.md第 7 节诚实地记录了人工验证被跳过的原因机器上 5432/8080/8090 端口已有 docker-compose 栈在运行很可能属于同一仓库上的并发会话搭建隔离栈既不安全也耗时因此用户选择依赖自动化覆盖前端另有svelte-check0 错误与pnpm test1376 通过。文档同时明确指出自动化不能证明的事新页面与入口在真实浏览器中的渲染与交互行为这仍是一个待补的缺口。这种“说清楚证据边界”的写法比笼统声称“已验证”更有工程参考价值。风险、权衡与迁移四项已知权衡改了默认值不会追溯影响已有 CV。这是刻意的但设计文档要求在设置界面的文案里明说否则会被读成 bug。当前实现把这条说明放在两个 pane 共享的leadSuffix里一次维护、两处生效。三个调用点必须保持同步。如果将来出现第四条基础 CV 创建路径就会漏掉默认值。原本计划用一个 helper 消解实践后收敛为“所有调用点直接调用同一个导出方法GetAppearanceDefaults”见上文简化迭代。GET 无保存时返回系统默认客户端无法区分“没存过”与“存的值恰好等于系统默认”。设计文档判定可接受因为本特性没有需要该区分的“恢复系统默认”开关。事实上连ok布尔值都已被处理器忽略。模板注册表收缩会让已保存的模板失效。这不是理论风险而是审查阶段实际发现的缺陷最终以读取时自愈解决。迁移计划设计文档给出的迁移方案是纯增量新表、新端点、新路由、新的可选组件属性。没有既有数据变更没有既有端点响应形状变化。一次部署即可上线回滚也不需要越过标准 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.sqlsqlc 查询cv_appearance_defaults.sql领域类型与读写方法appearance_defaults.go校验与钳制规则cv.go模板注册表template.go简历播种创建路径store.go重建创建路径cv_reseed.go显式创建路径与路由cv.goHTTP 处理器cv_appearance_defaults.go前端共享状态cvAppearance.svelte.ts共享设定外壳CvAppearancePane.svelte模板/排版页面与分栏my/cvs/layout.svelte受控模板画廊TemplateGallery.svelte结语这份设计真正示范了什么这个改动的代码量不大但它把三类容易出错的决策处理得很干净。第一类是层与归属当“业务上属于用户”与“依赖方向上必须待在候选域”冲突时选择让依赖方向获胜并用同包复用换取校验逻辑的单一来源而不是在低层复制一份会漂移的注册表。第二类是语义边界Sanitize的含义是“填充确实未设置的值”因此 per-user 默认不能塞进它只有创建入口有权读取用户偏好编辑路径继续把“未设置”理解为系统默认。这条边界一旦破掉保存默认值就会悄悄改写用户已有的文档。第三类是改动的可回退性独立表、独立端点、显式保存、受控组件属性、纯增量迁移——每一项都让这次上线可以被简单回滚而不需要数据修复脚本。tasks.md中那次 helper 删除同样值得记住设计文档提出的缓解措施在真实的分层与包边界面前未必成立当注释宣称的“唯一入口”事实上不成立时正确做法是让代码回到真实收敛点并同步修正注释而不是保留一个看起来优雅、实际只覆盖三分之一的抽象。赞分享【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载相关推荐better-sqlite3 表设计实战指南主键、默认值与外键约束的最佳实践better sqlite3 表设计实战指南主键、默认值与外键约束的最佳实践 本文源自 better sqlite3 https://link.gitcode数据库嵌入式数据库freehire 的 internal/ai 分层架构第 3 层 AI 能力块的边界、依赖约束与双重强制校验freehire 的 internal/ai 分层架构第 3 层 AI 能力块的边界、依赖约束与双重强制校验 internal/ai 在 freehire 的10 分钟跑通 FreeLLMAPI一键免费 LLM API 聚合路由新手指南10 分钟跑通 FreeLLMAPI一键免费 LLM API 聚合路由新手指南 FreeLLMAPI 把 34 家 AI 提供商的免费额度聚合成一个 Open人工智能LLM 网关后端前端CLI桌面应用上一篇REFramework 使用指南RE 引擎游戏 MOD 框架、VR 支持与脚本平台快速上手下一篇Cap录完即得分享链接的开源录屏工具创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考