Files
chorus/docs/02-architecture-and-code-map.md
T

43 KiB
Raw Blame History

generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.- wiki_revision: 2805f53ecd5f98fcf2a7602d47f4c89d55e40d7e synchronized_at: 2026-08-23T17:03:07Z

架构与代码地图

项目定位

chorus 是独立运行的 Go 生图生文服务:

  • 图生图:提示词 + 一张或多张原图 → 新图;
  • 文生文:提示词 → 文本。

portal 只负责接收、校验、持久化和展示;worker 才能调用上游。admin 配置 Provider/Model、路由池和查看记录。一个仓库包含两个二进制、一个纯领域核心和基础设施适配层:

chorus/
├── internal/core/              领域模型、接口、编排和状态规则;仅 GORM + 标准库
│   ├── model/
│   ├── provider/               协议接口与 chat/images/images_edits/gemini 领域语义
│   ├── router/                 候选、retryable 与选路规则
│   ├── generate/               prompt 合成和生成编排
│   ├── queue/                  租约、CAS、重试状态
│   ├── storage/                存储接口
│   └── crypto/                 密钥加密接口
├── internal/platform/          HTTP/SSRF、gobreaker、imaging、本地/S3 适配
├── portal/                     用户页面、HTMX 片段、JSON API、worker 组装
├── admin/                      固定 go-admin 后端的嵌套 Go module;生产入口 cmd/server.go,业务 app 在 app/chorus/
├── admin-ui/                   固定 go-admin-ui 导入代码与定制页
└── migrations/                 所有生产表、sys_* 基线和配置种子

固定来源和提交见 项目档案。D:\github\goadmin 只用于来源审查和首次导入,运行、CI 与部署不得依赖该路径。

#23 已落地的管理端后端

  • admin/ 是独立 Go module;从仓库根目录使用 go -C admin build . 和 go -C admin test ./...。admin/cmd/server.go 只注册生产 server 子命令,必须提供受保护的 go-admin settings 文件;Provider 运行时不再需要 CHORUS_MASTER_KEY。
  • admin/app/chorus/ 实现 Provider、ProviderModel、PromptTemplate、RoutePool、Generation 与终端用户查询 API。所有 Chorus API 经过固定 go-admin 的 JWT 与 Casbin 链;JWT 只从 Authorization: Bearer 读取,不接受 Query 或 Cookie token。
  • 原 go-admin 的 AutoMigrate、代码生成、Swagger、WebSocket 与静态文件路由未导入生产入口。/api/v1/chorus/ 不提供 DELETE;被引用记录使用启停而不是物理删除。
  • 通用操作日志对 /api/v1/chorus 完全跳过请求体与结果持久化,避免把凭据写入 sys_oper_log;配置变更和被拒绝的探测由 admin_audit_events 记录脱敏摘要。
  • 管理端 POST /api/v1/login 在 prod 与 dev 都只接收必填的 username、password;生产入口不注册验证码、代码生成、Swagger、WebSocket 或静态文件路由。

MVP-0 的最小形态

目录 MVP-0 实现 暂缓
internal/core/model users、providers、provider_models、prompt_templates、generations、输入/输出 点数、路由池、审计
internal/core/provider chat、images_edits images、gemini
internal/core/router 唯一启用 ProviderModel 加权、熔断、故障转移
internal/core/queue SKIP LOCKED、租约 token/owner、CAS 完成、失败落错 心跳续租、多次跨 Provider 重试、清理
internal/platform SSRF 安全 HTTP、本地存储、缩略图 gobreaker、对象存储
portal 种子用户登录、桌面双栏/移动单列、提交、轮询、结果 公开注册、点数/API Key 导航、无限滚动、移动抽屉
admin/admin-ui 不接入;配置由可重复种子命令完成 MVP-1 schema-first CRUD 与定制页

MVP-0 第一张上传图自动作为 primary,其余为 reference;不提供角色编辑、备注和拖拽。默认中性模板已于 2026-08-20 确认,使用 Go text/template 且只开放 {{.UserPrompt}},具体内容见业务规则;模板作为数据管理,不硬编码。

#6 已落地的共享基线

  • go.mod 固定 Go 1.26.5;internal/config 只从环境读取配置,生产缺少 DSN、会话密钥或存储目录时失败关闭且不输出值。
  • migrations/000001_mvp0_base 建立 users、providers、provider_models、prompt_templates、generations、generation_inputs、generation_outputs,生产启动不调用 AutoMigrate。
  • 迁移显式定义用户幂等唯一键、队列/历史索引、5 个外键和删除规则,以及状态、租约、错误、attempt 数组和输出内容检查约束。
  • internal/seed/defaults.json 保存已确认 Prompt 和 mock ProviderModel 数据;cmd/chorus-seed 在事务中幂等 upsert 构造用户、mock Provider/Model 和 Prompt,不保存或输出明文凭据。
  • portal/handler、service、auth 和 session 在 #11 落地用户 API 与安全边界;portal/web 在 #12 落地模板、静态资源和浏览器行为。portal/worker 负责异步上游调用,提交 handler 没有 Provider 依赖。

#7 已落地的核心契约

  • internal/core/model 的 7 个 GORM 模型按 #6 migration version 1 映射,schema 单测核对完整列集合;状态只允许 pending → running → succeeded|failed,终态不可回退。
  • internal/core/provider、queue、storage、crypto 和 model 仓储接口只表达 MVP-0 所需能力;core 不 import Gin、go-admin、gobreaker、imaging 或具体 HTTP/文件系统实现。
  • internal/core/generate 使用 Go text/template 解析数据模板,但只接受直接的 .UserPrompt 插值;空输入、非法语法、缺少/未知变量和额外模板构造均失败关闭。chat 保留用户原文,images_edits 渲染结果与已确认模板逐字一致。
  • 图片输入复制后按上传顺序写入 position,第一张为 primary、其余为 reference;调用方可直接把返回值和 rendered prompt 持久化。
  • internal/core/router 固定 HTTP 429/5xx、超时和连接错误为 retryable,400/401、内容策略拒绝及未知错误为不可重试;MVP-0 仍只调用唯一启用模型,不执行换家。
  • GORM 固定为用户指定 go-admin 提交使用的 v1.31.2;生产启动和 core 均没有 AutoMigrate。

#8 已落地的平台安全边界

  • internal/platform/http 是 Provider 请求和结果 URL 下载的统一入口:只允许 http/https 与显式端口,禁用环境代理,在 URL 校验和 DialContext 连接前分别解析 DNS,并直接连接已验证 IP,拒绝 IPv4/IPv6 私网、回环、链路本地、组播、未指定、文档与其他特殊地址。
  • 每次 redirect 重新校验 scheme、端口和 DNS,限制次数;响应总超时、响应头超时和最大字节数由构造配置强制提供。返回错误不包含原 URL 的路径、查询参数或 userinfo。
  • internal/platform/crypto 使用 AES-GCM;数据库信封字段是 version/key_id/nonce/ciphertext,version 和 key_id 进入 AAD。key ring 只用 active key 写入,同时可读取仍保留的旧 key_id,未知 key、篡改和错误版本失败关闭。
  • internal/platform/storage 在受保护根目录下把 content 与 metadata.json 写入同一临时目录,完整 flush 后在同文件系统原子 rename;metadata 保存 owner_id、generation_id、content_type 和 size。路径逐级拒绝符号链接和越界,失败只清理本次临时目录。
  • 图片保存前同时限制总字节、声明 MIME、实际解码格式和像素数;原图校验后保存,并生成最长边 256px 的 PNG 缩略图。文件不作为公开静态目录,授权判断仍由 portal 按 metadata 和数据库归属执行。
  • imaging 固定 v1.6.2,golang.org/x/image 提升并固定为与 go-admin 基线一致的 v0.41.0;测试只使用 mock DNS、net.Pipe、构造密钥和临时目录。

#9 已落地的 MySQL 租约队列

  • internal/core/queue.MySQLRepository 在事务中按 (status, lease_until, created_at) 使用 FOR UPDATE SKIP LOCKED 取一条 pending 或过期 running;租约比较和截止时间统一使用 MySQL NOW(6),避免应用时钟偏差。
  • claim 原子写 running/lease_owner/lease_token/lease_until 并追加 attempt 起始项;过期 running 重领不回退 pending,而是在同一 UPDATE 中把旧 attempt 标为 lease_expired 后追加新项。
  • worker 选定唯一启用 ProviderModel 后必须先用当前 token 调用 AssignProvider;成功/失败 CAS 只有当前 attempt 已绑定模型时才允许。重复绑定相同模型按当前 token 核对后视为幂等成功。
  • 成功在事务中先执行 id + status=running + lease_token CAS,再写 outputs;输出约束失败会回滚终态。失败用单条 UPDATE 同时写状态、分类 error 和脱敏限长 attempt。旧 token 的绑定、成功和失败均影响 0 行。
  • 同用户创建先锁 users 行,再查询 (user_id,idempotency_key),因此并发重放返回原 generation 且不写第二份 inputs;不同用户可使用相同 key。
  • queue.Controller.StopClaims 与 claim 使用读写锁,停止返回后不再进入新的数据库 claim;已经在途的最终写入仍必须通过 token CAS。
  • MySQL 8.4.8 集成测试在 REPEATABLE-READ 下覆盖双 worker、过期重领、attempt 链、旧 token、事务回滚、并发幂等和 fixture 清理;不使用 mock SQL 代替数据库门禁。

#10 已落地的 Provider 与内嵌 worker

  • internal/core/provider.OpenAI 实现 MVP-0 的 chat JSON 与 images_edits 多图 multipart 协议;支持文本、Base64 图片和结果 URL,extra_body 只接受显式白名单标量,不能覆盖 model、prompt 或 messages。
  • internal/platform/http.ProviderClient 让 POST、重定向和结果 URL 下载共用 #8 的 SSRF 防线;携带 Authorization 的请求禁止跨 origin 重定向,避免 Bearer Key 被转发到其他主机。
  • portal/worker.GORMCatalog 按 generation kind 读取 Provider/ProviderModel;Bearer Key 从 provider_credentials.api_key 取出后只在内存中交给协议 Client,不进入错误、响应或日志。
  • worker 认领后先用 lease token 绑定 ProviderModel,再读取并核对输入文件 owner/generation;文本直接形成 output,图片经格式/像素/字节校验后原子保存原图和缩略图。
  • 最终成功/失败使用独立短超时上下文执行 token CAS。陈旧 token 影响 0 行时,worker 只删除本 attempt 写入的输出对象;不会删除输入或其他 worker 文件。
  • 429/5xx/超时/连接错误以及 400/401/内容策略拒绝均映射为固定脱敏错误;MVP-0 记录 retryable 分类但不换 Provider、不自动重试。
  • internal/platform/mockprovider 和 cmd/chorus-mock-provider 提供本地协议 fixture;自动测试通过注入受控 DNS/dial 连接它,不放宽生产对回环/私网地址的 SSRF 拦截。
  • MySQL 8 集成测试分别完成一次 chat 和 images_edits,断言 rendered_prompt、attempt ProviderModel/latency、输出记录、原图和缩略图;全程不访问真实 Provider。

#15 portal 组合根与运行闭环

  • portal/main.go 现在实际组装 MySQL queue controller、SSRF 安全 HTTP client、Provider factory/catalog、本地存储和内嵌 worker;同步 handler 仍只写 pending,不持有 Provider 依赖。
  • worker 的 claim 必须经过 queue.Controller;收到退出信号后先阻止新 claim,HTTP server 停止接收请求,并等待在途 worker 在租约上下文内完成。HTTP 或 worker 任一组件意外结束都会让进程失败关闭。
  • 非生产默认 lease 60 秒、Provider HTTP 超时 45 秒、轮询 250ms、响应上限 32MiB;生产必须显式配置,且 lease 必须比 HTTP 超时多 5 秒以上。
  • CHORUS_TEST_DISABLE_WORKER=true 只允许 CHORUS_ENV=test,用于浏览器稳定观察状态的测试 fixture;开发和生产环境启用会直接报错。真实 Provider/worker 成功仍通过注入受控 DNS/DialContext 的 MySQL 集成测试验证,生产 SSRF 逻辑没有旁路。

#11 已落地的 portal 认证与用户 API

  • portal/session 使用服务端内存会话和 HMAC 不透明 Cookie;登录成功与退出都会轮换 session ID 和 CSRF token。Cookie 为 HttpOnly/SameSite=Lax,生产启用 Secure;写请求只接受 X-CSRF-Token,避免 multipart 在正文限流前被隐式解析。
  • MVP-0 会话仅支持单 portal 进程,重启后统一失效;会话数量有内存上限并在创建时清理过期项。多实例共享会话不在 MVP-0 范围。
  • internal/platform/password 固定 bcrypt:v1:<hash> 版本化编码;未知版本失败关闭。登录对未知账号、错误密码和禁用账号执行同类密码校验并返回同一错误,按远端地址做内存窗口节流。
  • portal/service 统一处理 prompt 渲染、用户作用域幂等、上传数量/单文件/总量、JPG/PNG/WebP 的声明与实际 MIME、扩展名、解码和像素校验;第一张图为 primary,其余为 reference。
  • CreateIdempotentPrepared 在事务插入 generation 后才执行 inputs 回调,因而文件 metadata 可使用真实 generation ID;幂等重放不执行回调,回调或数据库失败只补偿删除本次保存文件。
  • 同步提交只创建 pending、inputs 和 rendered_prompt,不 import 或调用 Provider/worker。JSON 与 HTMX 查询共享同一用户作用域服务,认证过期返回 401;HTMX 额外返回登录跳转提示,不修改 generation 状态。
  • 任务、原图、生成图和缩略图查询先用 generation.user_id 过滤,再核对文件 metadata 的 owner/generation;响应只给受控 URL、内容和安全文件名,不暴露 storage key 或文件系统路径。
  • Gin 1.12.0 与用户指定 go-admin 基线一致。portal 不注册公开文件目录、注册、找回密码、管理员登录或同步上游路由。

#12 已落地的 portal 用户界面

  • portal/web/templates 负责登录页、工作区和结果片段;portal/web/static 负责本地 CSS、交互脚本及固定版本的 HTMX、Alpine CSP 和 Lucide 产物,运行时不访问 CDN。
  • 首屏和状态片段由 html/template 输出;HTMX 只轮询和替换 #result-section,终态不再带轮询属性。Alpine 仅管理文本/图片模式,原生 JavaScript 负责提交、上传列表、复制、重试、历史搜索和认证失效。
  • 网络传输错误和非 401 HTTP 错误保留当前结果并显示恢复提示;401 停止当前交互、显示登录失效对话框,并保留任务路径作为登录返回地址。
  • 响应式断点为 375、768、1024 和 1440:手机按结果、输入、历史顺序单列;平板为历史加单列工作区;宽桌面为历史、结果、输入三列。交互目标至少 44px,支持键盘焦点、aria-live 和 reduced-motion。
  • portal/web/package.json 与 pnpm-lock.yaml 固定前端构建依赖;构建生成的静态文件嵌入 Go 二进制。MySQL handler 测试覆盖真实状态片段,Playwright 覆盖四视口、输入错误、上传错误、断网提示和认证失效。

#16 MVP-1 技术设计(2026-08-21 已确认)

本节是工单 #16 的已确认技术设计,用户于 2026-08-21 验收通过。它是 #17/#18 原型和后续生产实现工单的技术依据;两个 UI 原型仍须分别确认后才能放行对应生产实现。

能力、路由与 Prompt 快照

  • 请求能力固定为 text、image_generate、image_edit:chat 只提供 text,images 只提供 image_generate,images_edits 只提供 image_edit;gemini 必须通过 provider_model_capabilities 显式声明支持的能力,不能只按 Provider 名称推断。
  • 每个能力只有一个 active_routes 绑定,但可以有多个草稿/停用路由池。路由池引用协议无关的 PromptTemplate;同步提交只读取活动路由配置、渲染 Prompt 并保存路由快照,不调用 Provider。
  • generations 保存 route_pool_id、route_pool_version、prompt_template_id 和不含密钥的 route_snapshot。worker 使用快照中的成员与权重,并与当前 Provider/Model/成员启用状态求交集;运营停用配置可立即阻止未开始的调用,普通权重调整不改变已经提交的任务。
  • 候选按权重无放回抽取,随机源可注入以便测试;同一 ProviderModel 在一次 generation 中最多调用一次。max_failover 表示首次调用后的最多换家次数,因此真实上游调用总数不超过 1 + max_failover。被熔断、停用或能力不匹配的成员只跳过,不计一次上游尝试。

增量数据设计

MVP-1 使用三个按顺序执行、分别可逆的迁移:000002_admin_baseline 从锁定 go-admin 提取并审查所需 sys_* 基线;000003_mvp1_routing 增加业务表、字段、回填与约束;000004_mvp1_admin_seed 只写菜单、API 和权限种子。up 顺序固定为 000002→000003→000004,down 反向执行;每一步都保留 MVP-0 主键与历史数据。实际文件名可以补充短后缀,但编号和职责不得合并或交换:

表或变更 关键字段与约束 删除规则
provider_credentials provider_id、单调 credential_version、明文 api_key、`status=active retired、操作者和时间;UNIQUE(provider_id,credential_version)`
providers 使用 active_credential_id 指向当前凭据;普通更新不得清空凭据 被 Model/凭据引用时 RESTRICT;管理端只停用
provider_model_capabilities (provider_model_id, capability) 唯一,capability 仅允许三种固定值 Model 删除时 CASCADE;已有业务引用的 Model 仍不得删除
route_pools slug 唯一、capability、prompt_template_id、max_failover、version、enabled 被 generation 快照引用时 RESTRICT;平时停用
active_routes capability 主键、route_pool_id、操作者和更新时间,保证每种能力最多一个活动池 路由池删除时 RESTRICT
route_pool_members pool/model 唯一、正整数 weight、失败阈值、open 时长、half-open 探测数、启用和显示顺序 路由池删除 CASCADE;Model 删除 RESTRICT
route_member_runtime member 主键、closed/open/half_open、连续失败、open_until、探测租约和最后脱敏观测 member 删除 CASCADE;不保存凭据或响应正文
provider_connectivity_checks Model、操作者、running/succeeded/failed、脱敏错误、耗时、cooldown_until、时间 保留审计,不级联删除 Model
admin_audit_events 操作者、动作、目标、结果、request_id、脱敏 summary JSON、时间 追加写,不因目标停用而删除
generations 增加路由池/版本/template/snapshot、用户 role_rule、provider_attempt_count 路由池和模板使用 RESTRICT;用户删除仍沿用既有规则
generation_inputs 复用 position/role,增加限长 note;图片任务位置必须连续且恰好一个 primary 随 generation CASCADE
prompt_templates 增加能力和默认 role_rule;路由池显式引用版本 被路由池/generation 引用时 RESTRICT

关键字段统一使用 MySQL 8 类型:业务主键/FK 为 BIGINT UNSIGNED,版本/计数为 INT UNSIGNED,权重和 half-open 数为 SMALLINT UNSIGNED,时间为 DATETIME(6),布尔为 BOOLEAN,密文/快照/summary 为 JSON。capability、状态和 auth_type 使用 VARCHAR 加 CHECK;URL 保持 VARCHAR(2048),note 最多 VARCHAR(500),role_rule/Prompt 使用 TEXT。操作者 ID 是对 sys_user.id 的逻辑引用而不建物理 FK,以便管理员停用后审计仍可保留;响应通过单独查询解析显示名。

必须建立的索引/唯一键为:

  • provider_credentials UNIQUE(provider_id,credential_version)、INDEX(provider_id,status,created_at);providers.active_credential_id 唯一外键,避免一个凭据被多个 Provider 激活。
  • provider_model_capabilities PRIMARY KEY(provider_model_id,capability);route_pools UNIQUE(slug)、INDEX(capability,enabled);active_routes PRIMARY KEY(capability)。
  • route_pool_members UNIQUE(route_pool_id,provider_model_id)、INDEX(route_pool_id,enabled,position);runtime 以 member 为主键,并索引 (state,open_until)。
  • 连通性检查索引 (provider_model_id,started_at DESC)、(status,started_at);审计索引 (target_type,target_id,created_at)、(operator_id,created_at)、UNIQUE(request_id,action),用于幂等防重。
  • generations 新增 INDEX(user_id,created_at,id) 支撑游标,保留队列和幂等索引;路由池/template 引用分别建普通索引。JSON route_snapshot 和 attempts 不作为筛选条件,运行所需计数使用列维护。

attempt_count 继续表示 attempts JSON 中全部租约/Provider 事件数量,以兼容 MVP-0;新增 provider_attempt_count 只统计真实上游调用并用于故障转移上限。Provider attempt 增加 type、route_member_id、provider_ordinal、retryable 和结束原因。000003 内部顺序为:建新表 → 回填凭据/能力 → 增加可空引用 → 回填和校验 → 增加外键/检查约束 → 清空旧密钥列。000003.down 先从活动 credential 恢复旧密钥列和原索引,再删除新引用/表;000004.down 只删除本项目按稳定唯一键创建的菜单/API/权限,不按模糊名称删除;000002.down 只能在无管理员业务数据时执行。完整迁移集必须在空库和含 MVP-0 数据的隔离 MySQL 8 完成 up/down/up,校验行数、密文信封、外键、索引和 MVP-0 可读性。

worker 原子协议与熔断

ClaimLease(只取得租约并记录 lease 事件)
  → 从 generation 路由快照加载候选并过滤当前启停/能力
  → ReserveRouteMember(数据库原子跳过 open,half-open 只放行有限探测)
  → BeginProviderAttempt(校验 id + running + lease_token,追加 Provider attempt,递增 provider_attempt_count)
  → 使用同一 SSRF 安全 HTTP Client 调用
  → FinishProviderAttempt(同一 CAS 完成当前 attempt 并更新 runtime)
      成功 → Succeed CAS + outputs
      retryable 且仍有额度/候选 → 下一成员
      非 retryable 或耗尽 → Fail CAS + 脱敏终态错误
  • core 只定义选路、失败分类和 repository 接口,仍只依赖 GORM/标准库;gobreaker 及数据库 runtime 适配在 platform/worker。数据库 runtime 是多 worker 共享的排除依据,gobreaker 是进程内快速保护,二者都不能绕过 retryable 规则。
  • 429、5xx、超时、连接错误完成当前 attempt、计入熔断失败并允许换家。400 和内容策略拒绝不计熔断失败并立即终止;401 立即终止当前任务并把该成员打开,防止后续任务继续使用失效凭据,但同一任务不得换家。
  • 租约过期时,新 worker 只关闭未完成 attempt、替换 token 并从未调用候选继续;旧 token 的 Begin/Finish/Succeed/Fail 均影响 0 行。输出文件仍在 Succeed 前暂存,CAS 失败立即清理。
  • 没有活动路由、全部成员不可用或故障转移耗尽分别使用稳定脱敏码 route_not_configured、route_unavailable、failover_exhausted;终态仍只有 succeeded/failed。

Provider 协议与安全边界

api_type 固定请求形态 能力和结果
chat OpenAI-compatible /chat/completions 只接收 text;读取文本结果
images OpenAI-compatible /images/generations,强制单次单结果 image_generate;接受受限 base64 或经 SSRF 校验的结果 URL
images_edits OpenAI-compatible /images/edits multipart image_edit;至少一张图且恰好一个 primary
gemini 固定 models/{url-escaped-model}:generateContent 按 capability 发送 text/inlineData,读取 text/inlineData;不接受配置覆盖 endpoint

auth_type 只允许 none、bearer、x-goog-api-key,不允许管理员输入任意认证 header。extra_body 只合并白名单字段,不能覆盖 URL、认证、model、Prompt、输入、超时、输出数量或响应上限。四种协议的请求、重定向和结果下载全部复用 DNS 后、Dial 前 SSRF 拦截;Bearer 不跨 origin 转发,响应正文与密钥不写日志/attempt/audit。

管理端契约

管理 API 位于独立管理员认证/Casbin 下的 /api/v1/chorus/:

  • providers/models/templates 使用列表、详情、创建、更新和启停;users/generations 使用列表与必要状态操作。已被引用的记录不提供物理删除。Provider 列表和普通详情只给 has_credential 等元数据,不返回完整 API Key。
  • PUT providers/:id/credential 创建新的明文 credential 版本并切换活动版本;GET providers/:id/credential 只允许授权管理员在单 Provider 页面主动读取,响应禁止缓存;激活历史版本继续写脱敏审计。
  • route-pools 使用 version 乐观锁;发布活动绑定时校验能力、至少一个启用成员、正权重、PromptTemplate 兼容和 breaker 参数,不合格返回字段级错误。
  • POST provider-models/:id/connectivity-checks 有独立 Casbin API 权限,并且仅接受 Authorization Bearer JWT,不使用 Cookie/Query token,因此没有浏览器 Cookie CSRF 通道。事务锁定最近检查并预留 running 记录;冷却内返回 429 和 retry_after_seconds。未显式授权时 API 写一条脱敏拒绝审计并返回 403,绝不构造出站请求。只有运行时明确设置 CHORUS_ADMIN_ALLOW_CONNECTIVITY_PROBES=1,并同时提供正数冷却、HTTP 超时与响应上限,服务端才按 api_type 使用固定最小探针;不接收任意 Prompt/extra_body,仍经过同一 SSRF/认证/超时链路,完成后写脱敏结果与 audit。保存配置不自动测试,CI/日常调试只用 mock;首个真实 Provider、探针和冷却值仍是上线前人工确认门禁。

portal 契约

  • multipart 提交增加 metadata JSON,声明 capability、role_rule 和每个文件 client_id/position/role/note;每个文件 part 的字段名固定为 files[<client_id>],必须与 metadata.images[].client_id 一一对应且各出现一次,不接受未声明文件。image_generate 不接受图片或 role_rule,image_edit 至少一张且恰好一个 primary;position 必须为从 0 开始的连续唯一值,role 仅为 primary/reference,note 与 role_rule 做 UTF-8/大小校验。
  • Prompt 覆盖顺序固定为 PromptTemplate 默认 role_rule → 非空用户 role_rule → 按 position 排序的图片 role/note;最终文本一次性写入 rendered_prompt,worker 不重新解释用户字段。
  • GET /api/generations?limit=20&cursor=<opaque> 使用带 HMAC 的 base64url (created_at,id) 游标,limit 有服务端上限;签名包含 user_id 作用域并使用独立域标签,密钥材料和有效期分别复用受保护的 session key 与 session TTL。查询固定带 user_id,并按 (created_at DESC,id DESC) 取 limit+1。响应为 items、next_cursor、has_more;空、过期、篡改或跨用户游标返回稳定 400,不退回 offset,也不泄露其他用户记录。

必测矩阵

  • 数据:空库及含 MVP-0 数据的 up/down/up,凭据回填/回退、外键/删除、能力和路由发布约束。
  • 路由:权重边界、无放回、停用/open 跳过、half-open 并发、max_failover=0/N、无路由/全不可用/耗尽。
  • retryable 红线:429、5xx、超时、连接错误分别换家;400、401、策略拒绝分别不换家;401 打开后续 circuit;旧 lease token 所有最终写入均为 0 行。
  • 协议:四种 api_type 的请求/结果、能力不匹配、base64/URL 上限、Gemini inlineData、保留字段覆盖拒绝和完整 SSRF 回归;全部使用 mock。
  • 密钥/管理:明文保存、版本切换、空值不清除、列表/普通详情/日志/audit 不含完整值;单条读取权限与禁止缓存;探测权限、CSRF、冷却并发只产生一个真实检查预留。
  • portal:role_rule 覆盖、图片一一对应/连续顺序/唯一 primary、rendered_prompt 落库、游标稳定性/篡改/跨用户以及无限滚动错误恢复。

代码地图

想改什么 从哪里开始读 必要验证
页面、状态和响应式 portal/web/templates/、portal/web/static/ 浏览器 E2E + 已确认原型
提交校验/幂等 portal/handler/router.go、portal/service/service.go handler 测试、跨用户测试
HTMX 结果轮询 portal/handler/router.go、portal/web/templates/result.html、portal/web/static/app.js 终态停止、网络错误、认证失效
prompt 合成 internal/core/generate/prompt.go 默认模板、图片顺序与落库
上游协议 internal/core/provider/ mock 429/5xx/超时/连接/400/401/策略拒绝
SSRF/重定向/结果 URL internal/platform/http/ DNS、IPv4/IPv6、redirect、proxy 测试
选路与 retryable internal/core/router/ 四类红线用例
队列和陈旧 worker internal/core/queue/ MySQL 并发、租约过期、CAS
文件与缩略图 internal/platform/storage/ 临时文件、原子 rename、权限
表结构 migrations/ 最新 SQL 隔离 MySQL up/down
管理员首次初始化 cmd/chorus-admin-bootstrap、internal/adminbootstrap/ 隔离 MySQL 创建、重复执行、显式重置与脱敏输出
管理端 CRUD admin/app/chorus/、admin-ui/src/views/ 生成差异审查、生产路由检查

文件名是规划入口;实现工单调整后同步本页。

两条主要执行路径

用户提交生成(同步)

表单/HTMX/JSON 请求
  → 认证、CSRF(浏览器)、上传与业务校验
  → 保存输入文件
  → 事务写 generations(status=pending)、generation_inputs
  → 以 (user_id, idempotency_key) 保证幂等
  → 立即返回 loading 卡片或 202 JSON

同步链路不得调用上游,也不得把文件系统真实路径返回给浏览器。HTMX 请求返回可替换的片段;JSON 请求返回稳定错误码与字段级错误,认证失效明确返回 401/登录引导,不能用空白片段吞错。

worker 执行生成(异步)

事务内 SKIP LOCKED 选 pending 或租约已过期的 running
  → 原子写 running、lease_owner、lease_token、lease_until,attempt_count + 1
  → 合成 rendered_prompt 并落库
  → 选 ProviderModel,使用安全 HTTP 适配器调用
  → 结果写临时文件,校验后原子 rename;图片生成缩略图
  → 用 id + lease_token + status=running 做 CAS
  → 成功写 outputs/status=succeeded;失败写 status=failed/error/attempts

状态对外始终是 pending → running → succeeded|failed。过期任务由新 worker 在一次原子更新中替换租约,不把状态退回 pending。旧 worker 的 token 已失效,最终 CAS 必须影响 0 行,不能覆盖新 worker 结果。租约时长必须大于上游请求超时、下载/存储和安全余量之和;具体值是部署前配置门禁。

attempts 与 attempt_count 在同一事务/原子更新中追加,错误内容先分类、脱敏和截断。worker 收到退出信号后停止取新任务,等待在途任务到设定期限;无法完成时让租约自然过期,不能无条件提交。

管理端数据库与代码生成

固定 go-admin-ui 的实际流程是:

编写并验证可逆 SQL
  → 在隔离开发库执行 up
  → /db/tables/page 读取现有表
  → /sys/tables/info 导入 sys_tables/sys_columns 元数据
  → 配置、预览、生成 Go/Vue CRUD
  → 人工审查路径、权限、字段和差异
  → 菜单/API 配置改写为可逆 SQL
  → 重新从空库执行全部 migrations 验证

go-admin 的 AutoMigrate 只能在隔离、可丢弃数据库中用于研究固定提交的初始化结构,不能嵌入应用启动或部署。go-admin-ui 的“生成迁移脚本”不是业务表 DDL;/gen/todb 会直接改菜单,/gen/toproject 会写源码。dev-tools 路由在固定提交中仅有 JWT 且绕过 Casbin,生产必须通过构建/路由注册检查确保不存在,不能只在菜单中隐藏。

不可破坏的边界

  • internal/core 只依赖 GORM 和标准库;Gin、go-admin、gobreaker、imaging 都在外层。
  • 管理员 sys_user 与终端用户 users 分表,不复用密码、Cookie、JWT 或 API Key。
  • chorus-admin-bootstrap 只读取外部环境变量并直接使用固定 go-admin 兼容 bcrypt;它不导入 go-admin、不会执行 AutoMigrate、迁移或 seed,且仅允许对已启用、未删除、绑定 chorus_operator 的既有管理员进行显式密码重置。
  • 本项目完全不包含用户点数、余额、扣费、退款、充值、发放或配额;数据库、接口、生成链路和界面均不得引入。
  • 429/5xx/超时/连接错误 才换下一家;400/401/内容策略拒绝 立即返回。
  • 所有 Provider 请求、重定向和上游返回 URL 下载都必须经过 DNS 解析后、连接前的 DialContext 拦截;代理环境不能绕开检查。
  • 生产数据库只接受 migrations/,不执行 AutoMigrate。
  • rendered_prompt、attempts、失败 error_code/error_message 必须持久化。
  • 输出文件先写临时文件并原子落位;图片必须有缩略图。原图、结果和缩略图访问均校验用户归属。

#24 管理端已交付实现(2026-08-22,已验收)

  • admin/app/chorus 提供 Provider、模型、Prompt、路由池、生成记录、终端用户状态和 Provider 健康 API;所有路由位于管理员 JWT/Casbin 边界。
  • admin-ui/src/views/chorus 提供四类 CRUD 与路由池、Provider 健康、生成详情定制页,用户页不包含点数、余额或配额。
  • migrations/000005_provider_plaintext_credentials 切换明文凭据、菜单和权限;遇到已有加密 credential 时 fail closed,要求人工受控转换。
  • Casbin adapter 绑定 sys_casbin_rule 并关闭 AutoMigrate;管理端启动不会创建或修改表结构。
  • 列表与普通 Provider 详情不返回完整 Key;只有单条 credential 接口回显并设置 Cache-Control: no-store、Pragma: no-cache。真实值仍禁止进入日志、审计、错误、文档、工单和截图。

MVP-2 API 开放与治理技术设计草案(#36,待验收)

本节对应 #35 和技术设计 #36。它用于审核和拆分实施,当前不是已放行的生产实现依据;只有 #36 与原型 #37 同时经用户明确确认后,才能建立和执行生产单元工单。

范围和运行边界

  • 面向现有受控 users 开放 API Key 和版本化生成接口;不开放注册、找回密码或匿名访问。
  • 保持浏览器 Cookie/CSRF、管理员 JWT/Casbin、终端用户 API Key 三条认证链相互独立,不能相互回退或混用。
  • 限流只控制请求速率和上游调用速度,不读取或写入点数,不形成计费、每日配额或额度余额。
  • 当前支持单 portal 进程内嵌单 worker 的部署形态;限流器在同一进程和所有 worker goroutine 之间共享。MVP-2 不宣称支持多 portal 实例;未来横向扩容前必须另行设计共享限流存储。
  • 不实施生成物、审计或 API Key 的自动删除;API Key 只撤销,审计追加写。

模块边界

portal 浏览器路由 /api/*
  -> session + CSRF -> 既有 generation service

程序路由 /openapi/v1/*
  -> Authorization: Bearer <API Key>
  -> API Key 认证 -> user/key 限流 -> 既有 generation service
  -> 立即返回 202 或幂等重放 200,不调用上游

worker
  -> 取得 generation lease
  -> 路由和 circuit reservation
  -> provider 限流
      放行 -> BeginProviderAttempt -> 安全 HTTP Client -> 既有结果/失败协议
      拒绝 -> 不调用上游、不写 Provider attempt、不改变 circuit,释放租约并延后领取

API Key 记录和 repository 接口属于 internal/core,只依赖 GORM/标准库;随机令牌生成、SHA-256/常量时间校验和限流器位于 platform/portal 外层;Gin middleware 只在 portal。OpenAPI handler 复用现有 portal/service 的提交、历史、详情和文件归属校验,不复制生成业务规则。

API Key 数据和安全协议

新增 api_keys:id BIGINT UNSIGNED、user_id、name VARCHAR(80)、唯一 public_id VARCHAR(24)、key_prefix VARCHAR(32)、secret_hash BINARY(32)、可空 expires_at、last_used_at、revoked_at、created_at/updated_at。user_id 对 users 使用 RESTRICT;索引覆盖 (user_id,revoked_at,created_at)、public_id 唯一和 last_used_at。不存原始 Key,也不提供恢复字段。

令牌固定为 chorus_<public_id>_<secret>:public_id 和 secret 均由 crypto/rand 生成,secret 至少 256 bit,使用无填充 base64url;数据库只保存 secret 的 SHA-256。认证先按 public_id 定位候选,再对请求 secret 计算哈希并用常量时间比较;随机 secret 具有足够熵,不能使用用户密码式慢哈希。错误响应统一为 invalid_api_key,不区分不存在、已撤销、已到期或用户停用。

用户端接口位于 session/CSRF 下:GET /api/api-keys、POST /api/api-keys、PATCH /api/api-keys/:id 和幂等撤销 DELETE /api/api-keys/:id。创建响应设置 Cache-Control: no-store 与 Pragma: no-cache,完整 Key 只在该次 201 响应中出现;列表、详情、后续读取、日志、审计和管理端永远只返回名称、前缀、状态和时间。轮换流程为先创建新 Key,再撤销旧 Key,不增加会泄露旧值的“重新显示”或“旋转并返回旧值”接口。

管理员 API 位于既有 JWT/Casbin 边界:GET /api/v1/chorus/api-keys 查看用户和 Key 元数据,POST /api/v1/chorus/api-keys/:id/revoke 幂等撤销。管理员不能创建用户 Key、不能代用户读取完整 Key。用户自行撤销写 API 安全审计;管理员撤销同时写 admin_audit_events 和 API 安全审计。

OpenAPI v1 契约

程序接口仅接受 Authorization: Bearer,拒绝 query 参数、Cookie 和管理员 JWT。认证成功后把 user_id 与 api_key_id 放入请求 context。响应使用既有 {error:{code,message}} 错误信封,时间统一为 UTC RFC3339,返回 X-Request-ID,认证和限流响应禁止缓存。

方法与路径 内容类型 行为
POST /openapi/v1/generations/text JSON 提交文本生成,202;幂等重放 200
POST /openapi/v1/generations/image multipart 复用已确认 metadata/files 契约,提交图片生成或编辑
GET /openapi/v1/generations query 复用 user 作用域 HMAC cursor 历史
GET /openapi/v1/generations/:id - 查询本人任务详情
GET /openapi/v1/generations/:id/inputs/:inputID - 读取本人输入原图
GET /openapi/v1/generations/:id/outputs/:outputID - 读取本人输出原图
GET /openapi/v1/generations/:id/outputs/:outputID/thumbnail - 读取本人缩略图
GET /openapi/v1/openapi.json - 返回与实现同版本的 OpenAPI 3.1 描述

OpenAPI 提交只从必填 Idempotency-Key 请求头读取幂等键,规则为去空格后 8 至 128 个可打印 ASCII 字符;不同时接受 body 同名字段。已有 (user_id,idempotency_key) 唯一约束继续保证浏览器和 API Key 跨渠道重放属于同一用户。下载 URL 必须指向 /openapi/v1,不能要求 API 客户端持有浏览器 Cookie。

关键状态码:参数/图片错误 400,缺失或无效 Key 401,跨用户对象统一 404,幂等冲突 409,速率限制 429 并带整数秒 Retry-After,路由未配置或服务暂不可用 503。接口规范以仓库中的 OpenAPI 3.1 文件为源,并用 handler 合约测试防止路径、状态码和 schema 漂移;MVP-2 不交付 Swagger UI、SDK、webhook、批量、取消或流式接口。

三维限流和异步延后

采用固定窗口限流器,配置必须分别给出正整数容量和窗口:用户提交维度、单 API Key 总请求维度、单 Provider 上游 attempt 维度。生产环境缺少任一配置时启动失败;开发和测试必须在 fixture 中显式设置,技术设计不臆造生产阈值。用户和 Key 同时检查,任一拒绝即返回 429;Provider 桶按 Provider ID 分开,但共享同一组策略值。

限流状态只保存在当前 portal 进程内,重启会重置窗口;这是流量保护而不是配额或计费账本。日志和审计不记录原始 Key。HTTP 429 包含 Retry-After,错误正文不披露 Provider、内部路由或其他用户的桶状态。

Provider 限流检查发生在真实上游调用前。放行后才执行 BeginProviderAttempt,因此本地限流不计入 provider_attempt_count、attempts、熔断或故障转移。若当前候选受限,可继续检查尚未调用的其他候选;全部候选都只因本地 Provider 限流不可用时,使用最早 retry 时间重新排队,而不是写失败终态。

为此 generations 增加非空 available_at DATETIME(6),默认创建时间;队列只领取 pending AND available_at <= NOW(6) 或租约过期的 running。新增带 lease token 的 Defer CAS:把当前 running 任务改回 pending,清空 lease,设置 available_at,但不增加 Provider attempt、错误或 circuit;记录一条脱敏 local_rate_limit 调度事件。该事件不能被 usedRouteMembers 当作已调用候选。延后时间取最早允许时间并设置上限,避免异常配置长期占用任务。已有外部 429 仍按既有红线完成真实 Provider attempt、计入熔断并换下一家,两者不得合并。

审计、迁移和验证

新增 api_audit_events 作为追加写安全审计:id、可空 user_id/api_key_id/generation_id、action、result、request_id、可空 status_code/error_code、脱敏 summary JSON、created_at。记录 Key 创建/改名/用户撤销/管理员撤销、程序提交成功或拒绝、认证成功后的限流拒绝;普通成功查询不逐条入库,last_used_at 用节流更新。未知 Key 的认证失败只写受控安全日志和指标,避免攻击者制造无限审计行。summary 禁止 Prompt、文件内容、完整 Key、Authorization、Cookie、响应正文和原始 IP。

000006_mvp2_openapi_governance 创建两张表、available_at/队列索引和管理端菜单/API/Casbin seed;down 按稳定键删除 seed、索引、列和新表。必须在固定隔离 MySQL 8 上完成空库及含 MVP-1 数据的 up/down/up,并验证现有 generation 仍可领取。回退前停止 portal/worker;回退会删除 API Key 和新增审计,属于需人工确认的数据丢失操作,不能在生产自动执行。

必测矩阵包括:随机格式/哈希/常量比较、完整 Key 只出现一次、撤销/到期/用户停用、三条认证链不混用、CSRF、跨用户 404、幂等重放/冲突、JSON 与 multipart 边界、历史 cursor、文件归属、429 与 Retry-After、user/key/provider 三维桶、重启边界、Provider 本地限流不调用上游且不改变 retryable/circuit/attempt、Defer CAS 和旧 lease、审计脱敏、管理员权限、OpenAPI schema 合约、MySQL up/down/up,以及 mock 上游完整链路。不得使用真实 Provider 额度做回归。