【免费下载链接】freehire
freehire — the open-source search engine for job seekers
本文围绕 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:314 | store.go |
| 从简历重置/重建 | cv_reset.go:132 | cv_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。设计文档给出三点理由:
- 可选、可删除——用户没保存过就没有行;
- 与
users行自身的迁移历史解耦; - 与
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基类。这里有两个值得学习的工程细节:
- 为什么是一个 store 而不是每页各自 state:API 一次读写三个字段,如果每个 pane 持有自己的副本,切 tab 就会重新拉取,并丢掉另一个 pane 尚未保存的编辑;放在共享 store 里,编辑能跨 tab 存活,任一 tab 的 Save 都写整条记录。
- 为什么必须记录加载失败:基类把加载失败当作“保留默认状态”,这对只读缓存合理,对表单却是陷阱——一个显示空白默认值的 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 in | 0.25 in – 1.5 in | 超出上下界钳到最近边界 |
font_size | 0 = 交给模板 | 8.5 pt – 12.0 pt | 先按 0.5 pt 步长四舍五入,再钳制 |
line_height | 0 = 交给模板 | 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.go | GET 回落、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 通过)。文档同时明确指出自动化不能证明的事:新页面与入口在真实浏览器中的渲染与交互行为,这仍是一个待补的缺口。这种“说清楚证据边界”的写法,比笼统声称“已验证”更有工程参考价值。
风险、权衡与迁移
四项已知权衡
- 改了默认值不会追溯影响已有 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.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
相关推荐
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桌面应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考