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

185 lines
16 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: 04391bbce2fcc59da2078265c020a8a845ca4c16
synchronized_at: 2026-08-20T16:52:38Z
<!-- 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 后端,业务 app 在 app/chorus/
├── admin-ui/ 固定 go-admin-ui 导入代码与定制页
└── migrations/ 所有生产表、sys_* 基线和配置种子
```
固定来源和提交见 [项目档案](Project-Profile.-)。`D:\github\goadmin` 只用于来源审查和首次导入,运行、CI 与部署不得依赖该路径。
## 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 与安全边界;最终页面与静态资源仍属于 #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。
### #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 不注册公开文件目录、注册、找回密码、管理员登录、点数或同步上游路由。
## 代码地图
| 想改什么 | 从哪里开始读 | 必要验证 |
|---|---|---|
| 页面、状态和响应式 | `portal/web/templates/`、`portal/web/static/` | 浏览器 E2E + 已确认原型 |
| 提交校验/幂等 | `portal/handler/generate.go` | handler 测试、跨用户测试 |
| HTMX 卡片轮询 | `portal/handler/card.go` | 终态停止、网络错误、认证失效 |
| 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 |
| 管理端 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。
- 生成链路不读写点数:无扣费、退款、余额校验或每日配额。
- `429/5xx/超时/连接错误` 才换下一家;`400/401/内容策略拒绝` 立即返回。
- 所有 Provider 请求、重定向和上游返回 URL 下载都必须经过 DNS 解析后、连接前的 DialContext 拦截;代理环境不能绕开检查。
- 生产数据库只接受 `migrations/`,不执行 AutoMigrate。
- `rendered_prompt`、`attempts`、失败 `error_code/error_message` 必须持久化。
- 输出文件先写临时文件并原子落位;图片必须有缩略图。原图、结果和缩略图访问均校验用户归属。