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: 8c1c40529eb6da550fdfc395ec878a0431c9ef30 synchronized_at: 2026-08-21T15:02:17Z # 架构与代码地图 ## 项目定位 chorus 是独立运行的 Go 生图生文服务: - **图生图**:提示词 + 一张或多张原图 → 新图; - **文生文**:提示词 → 文本。 portal 只负责接收、校验、持久化和展示;worker 才能调用上游。admin 配置 Provider/Model、路由池和查看记录。一个仓库包含两个二进制、一个纯领域核心和基础设施适配层: ```text 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_* 基线和配置种子 ``` 固定来源和提交见 [项目档案](Project-Profile.-)。`D:\github\goadmin` 只用于来源审查和首次导入,运行、CI 与部署不得依赖该路径。 ### #23 已落地的管理端后端 - `admin/` 是独立 Go module;从仓库根目录使用 `go -C admin build .` 和 `go -C admin test ./...`。`admin/cmd/server.go` 只注册生产 `server` 子命令,必须提供受保护的 go-admin settings 文件和 `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` 记录脱敏摘要。 ## 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、会话密钥、Provider 主密钥或存储目录时失败关闭且不输出值。 - `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 从 AES-GCM 信封解密后只在内存中交给协议 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、AES-GCM key ring、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:` 版本化编码;未知版本失败关闭。登录对未知账号、错误密码和禁用账号执行同类密码校验并返回同一错误,按远端地址做内存窗口节流。 - `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`、AES-GCM `api_key_enc`、可检索 `key_id`、`status=active|retired`、操作者和时间;`UNIQUE(provider_id,credential_version)` | MVP-1 不物理删除;轮换只退役,回退重新激活旧版本 | | `providers` | 增加 `active_credential_id`;旧 `api_key_enc` 在验证回填后清空,down 时从活动凭据恢复 | 被 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 原子协议与熔断 ```text 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`、活动 credential 版本和 `key_id`,永不返回 `api_key_enc`/明文或密文信封。 - `PUT providers/:id/credential` 只接收新明文一次,事务内加密、创建新版本、切换 active、退役旧版本并写审计;`POST providers/:id/credential/:version/activate` 可审计回退。普通 Provider 更新省略 secret 即保持不变,不能用空字符串意外清除。 - 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,声明 `role_rule` 和每个文件 client_id/position/role/note;文件 part 必须与 metadata 一一对应。image_generate 不接受图片,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=` 使用带 HMAC 的 base64url `(created_at,id)` 游标,limit 有服务端上限;查询固定带 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/` | 生成差异审查、生产路由检查 | 文件名是规划入口;实现工单调整后同步本页。 ## 两条主要执行路径 ### 用户提交生成(同步) ```text 表单/HTMX/JSON 请求 → 认证、CSRF(浏览器)、上传与业务校验 → 保存输入文件 → 事务写 generations(status=pending)、generation_inputs → 以 (user_id, idempotency_key) 保证幂等 → 立即返回 loading 卡片或 202 JSON ``` 同步链路不得调用上游,不校验点数或配额,也不得把文件系统真实路径返回给浏览器。HTMX 请求返回可替换的片段;JSON 请求返回稳定错误码与字段级错误,认证失效明确返回 401/登录引导,不能用空白片段吞错。 ### worker 执行生成(异步) ```text 事务内 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 的实际流程是: ```text 编写并验证可逆 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` 必须持久化。 - 输出文件先写临时文件并原子落位;图片必须有缩略图。原图、结果和缩略图访问均校验用户归属。