185 lines
16 KiB
Markdown
185 lines
16 KiB
Markdown
<!-- 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` 必须持久化。
|
||
- 输出文件先写临时文件并原子落位;图片必须有缩略图。原图、结果和缩略图访问均校验用户归属。
|