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

303 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- gitea-wiki-mirror:start -->
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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
## 项目定位
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:<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`、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=<opaque>` 使用带 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` 必须持久化。
- 输出文件先写临时文件并原子落位;图片必须有缩略图。原图、结果和缩略图访问均校验用户归属。