Clone
49
Architecture-and-Code-Map
ila edited this page 2026-08-29 21:42:47 +08:00
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.

架构与代码地图

项目定位

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 依赖。Portal 启动必须显式传入 --config <settings.yml>;Provider/worker 参数从严格 YAML 读取,同名环境变量优先覆盖。
  • 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。出站端口由 CHORUS_PROVIDER_ALLOWED_PORTS 显式控制,默认仅 80,443;Portal worker 与管理端连通性探针使用同一白名单。扩展端口不会放宽 IP、DNS、重定向或结果 URL 校验。

管理端契约

管理 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,2026-08-24)

本节对应 #35 和技术设计 #36。用户已于 2026-08-24 同时确认 #36 与原型 #37 v1;本节是后续拆分和执行生产实现单元工单的设计依据。

范围和运行边界

  • 面向现有受控 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 额度做回归。

#41 已实现的数据与凭据核心(2026-08-24,已验收)

  • migrations/000006_mvp2_openapi_governance 已实现 api_keys、api_audit_events、generations.available_at、新队列索引以及管理端 API Key 菜单/API/Casbin 种子。public_id 固定 24 字符、可见前缀固定 32 字符、哈希固定 32 字节,并包含用户状态、最近使用时间和唯一定位索引。
  • internal/platform/apikey 使用 crypto/rand 生成 256 bit secret,令牌格式为 chorus_<public_id>_<secret>;只计算并保存 secret 的 SHA-256,认证比较使用常量时间。完整令牌与哈希均被排除在默认 JSON 序列化之外。
  • internal/core/apikey 提供按 user_id 隔离的创建、读取、列表、改名和撤销仓储。改名与撤销可安全重放;跨用户读取不会返回其他用户记录。
  • 000006 的 down 在 api_keys 或 api_audit_events 存在数据时主动失败,防止静默丢失安全数据。不得在生产用 force 绕过;只有完成备份、停服、风险确认和数据处置后才能回退。

#42 用户 API Key 生命周期与 Portal 页面

  • portal/handler 提供会话认证的 /api-keys 页面,以及 GET/POST /api/api-keys、PATCH/DELETE /api/api-keys/:id。写操作沿用 Portal CSRF;这些页面与响应统一禁止缓存。
  • portal/service 在事务内锁定并确认终端用户仍为 active,再按 user_id 调用 internal/core/apikey 仓储;改名、撤销和查询均不能越过用户边界。
  • 创建时由 internal/platform/apikey 生成凭据,数据库只保存 public id、可识别前缀与 secret hash。完整 token 只存在于单次创建响应,列表、改名、撤销和刷新响应均不返回。
  • portal/web/templates/api_keys.html 与 static/api-keys.js 实现加载、空、错误、停用、限流、一次展示、改名和不可恢复撤销状态;移动端改为卡片式行布局,桌面端保持紧凑表格。
  • OpenAPI Bearer 认证和生成接口属于后续工单,不在 #42 中从 API Key 页面直接调用上游。

#43 OpenAPI v1 认证与生成接口

  • portal/handler 将浏览器路由放在 session middleware 分组内,将 /openapi/v1/* 放在独立 API Key middleware 分组内;程序请求不会创建浏览器 Session Cookie,也不接受 Cookie、Query token 或管理员 JWT 回退。
  • API Key 认证解析固定 chorus_<public_id>_<secret> 格式,按 public id 定位后常量时间比较 secret hash,并在同一事务确认 Key 未撤销、未到期且 users.status=active。不存在、错误、撤销、到期和用户停用统一返回 401 invalid_api_key;成功使用时间最多每分钟写一次。
  • v1 提供文本与图片异步提交、HMAC cursor 历史、详情、输入、输出、缩略图和受认证的 openapi.json。全部响应带随机 X-Request-ID 并禁止缓存,资源 URL 固定指向 /openapi/v1。
  • 提交只读取必填 Idempotency-Key Header,限定 8 至 128 个可打印 ASCII 字符。相同用户跨浏览器/API 渠道重放相同请求返回 200;同键但 kind、Prompt、capability、图片角色元数据或文件内容不同返回 409。
  • 同步提交继续只做认证、严格校验、路由快照和事务落库,返回 202;没有 Provider 调用或 attempt。上游仍只由 worker 调用。
  • 仓库契约源为 portal/openapi/openapi.json,由 portal/openapi 嵌入二进制;合约测试固定 OpenAPI 3.1、八条路径、方法和 Bearer security。

#50 Portal 仅账号登录(2026-08-24)

  • migrations/000008_portal_username_login 为 users 增加非空唯一 username,历史用户确定性回填为 user_<id>;down 只删除账号约束、索引和字段,不修改邮箱、密码哈希或业务数据。
  • Portal POST /api/session/login 只接收 account 与 password,认证服务规范化账号后只查询 users.username;email 请求字段和把邮箱值作为账号提交都不能登录。
  • users.email 继续作为受控用户资料保留;管理员 sys_user 与终端用户 users 继续分表,密码、Cookie、JWT 和 API Key 均不复用。
  • cmd/chorus-seed 要求 CHORUS_SEED_USER_USERNAME、邮箱、密码和 mock Provider 地址;账号冲突失败关闭,命令不输出凭据。

#51 终端用户密码编码边界(2026-08-24)

  • internal/platform/password.Encode 接受 6 至 1024 个字符并输出 bcrypt:v1:<hash>;5 个字符及以下失败关闭。
  • Verify 的版本检查和 bcrypt 验证保持不变,因此已有密码无需迁移;管理员认证使用独立 go-admin 链路,不受此边界影响。
  • 回退最小长度不会使已有 6 至 11 位哈希失去验证能力;强制升级密码需另行设计重置流程。

MVP-2 三维限流与队列延后(#44)

  • internal/platform/ratelimit 提供进程内固定窗口限流器;一次 Take 可原子检查并占用多个 bucket,被拒绝时不会部分消耗其他 bucket。调用上游前因本地资源不可用而取消 reservation 时会退还本次占用。
  • Portal 浏览器生成提交按终端用户限流;OpenAPI 所有已认证请求按 API Key 限流,其中生成提交同时原子占用 API Key 和同一终端用户 bucket。超限统一返回 HTTP 429、rate_limited 和向上取整的整数 Retry-After。
  • worker 在调用 Provider 前按 Provider ID 限流。本地 Provider 限流不创建真实 Provider attempt、不更新熔断状态,也不进入 retryable/failover 判定;同一任务的其他可用 Provider 仍可继续尝试。
  • 所有候选 Provider 都只因本地限流暂不可用时,queue.MySQLRepository.Defer 以 running + lease_token CAS 把任务恢复为 pending,将 available_at 设置为最早可重试时间,并记录 local_rate_limit 队列事件。该事件不增加 provider_attempt_count。
  • ClaimNext 仅认领 available_at <= NOW(6) 的 pending 任务,同时保留过期 running 租约的恢复逻辑。原有上游 429 / 5xx / 超时 / 连接错误 与 400 / 401 / 内容策略拒绝 分类没有改变。

MVP-2 API 安全审计与管理端 API(#45)

  • internal/core/apiaudit 是 Portal 与 Admin 共用的追加写入口,只依赖 GORM、标准库和 core model。审计 Summary 是强类型结构,只允许 source、kind、created、already_revoked、operator_id;调用方没有字段可写入 Prompt、文件、完整 Key、Authorization、Cookie、响应正文或原始 IP。
  • Portal 的 API Key 创建、改名和用户撤销在原业务事务内写 api_audit_events;审计失败会回滚对应生命周期变更。幂等撤销记录 already_revoked,但不返回或记录凭据材料。
  • OpenAPI 认证成功后,提交审计中间件包围限流和提交 handler:成功、幂等重放、参数/幂等拒绝、限流拒绝和内部失败都记录同一 openapi.generation.submit 动作,并仅保存 kind、是否新建、响应状态与脱敏错误码。普通查询不逐条审计。
  • 缺失、格式非法、未知、过期、已撤销或所属用户停用的 Key 都返回同一 401。认证失败不会写数据库审计行,避免攻击者制造无界写入;受控安全日志/指标由后续运维采集处理。成功认证的 last_used_at 最多每分钟实际变更一次。
  • 管理端继续复用 go-admin JWT/Casbin:GET /api/v1/chorus/api-keys 支持 keyword/status/page/page_size,GET /api/v1/chorus/api-keys/:id 返回元数据和最近 20 条脱敏安全事件,POST /api/v1/chorus/api-keys/:id/revoke 幂等撤销。列表和详情不返回 public_id、secret_hash 或完整 Key,并设置 no-store。
  • 管理撤销用 FOR UPDATE 锁定 Key,在一个事务中更新 revoked_at,同时追加 admin_audit_events 的 api_key.revoke 和 api_audit_events 的 api_key.admin_revoked。Chorus 管理 API 只把合法 UUID 用作审计 request_id,其他客户端值替换为服务端 UUID,避免误把凭据写入关联字段。

OpenAI-compatible 图片编辑协议(#60)

  • Provider 的 base_url 保存到兼容 API 根路径(通常为 /v1);internal/core/provider.OpenAI 根据 api_type=images_edits 固定拼接 /images/edits,不得把完整端点重复写入 base URL。
  • images_edits 使用 multipart:标量字段包含 model、prompt、固定 n=1 和白名单 extra_body;每张输入图使用重复的 image 文件字段。至少一张输入图且恰好一个 primary 的产品约束保持不变。
  • images 文生图与 images_edits 图片编辑是两个独立协议和能力,不能只改 URL 相互替代;ProviderModel、PromptTemplate、RoutePool 和活动路由必须按 image_generate / image_edit 分开配置。
  • portal/service.ImageCapabilities 通过活动路由快照向工作台暴露 image_generate 与 image_edit 可用性;工作台优先选择可用的图片编辑模式、禁用没有活动路由的模式,两种路由都不可用时禁用提交。该页面提示只是前置反馈,提交服务仍执行独立路由校验。

#65 Generation 时间字段与耗时派生

  • Portal 浏览器 Generation 响应和 OpenAPI Generation schema 均包含可空 started_at;created_at / started_at / completed_at 统一输出 UTC ISO 8601。浏览器详情的 output 同步返回 mime_type / size_bytes / width / height / created_at。
  • Admin GenerationView 返回 started_at、终端用户 username / display_name 和结构化 attempts。服务层按本批 generation 涉及的用户、ProviderModel、Provider ID 批量查询名称,避免逐行查询;未知引用保留 ID 兜底。
  • 终态总耗时为 completed_at-created_at;非终态总耗时为当前时刻减 created_at;排队为 started_at-created_at;处理为终态 completed_at-started_at 或运行中当前时刻减 started_at;上游耗时只汇总 type=provider 的 latency_ms。
  • attempts JSON 解析失败时 Admin 返回空时间线而不是使列表接口失败。系统事件不使用数组下标冒充 Provider 尝试序号;Provider 时间线使用持久化的 provider_ordinal。
  • 所有耗时均为响应/界面派生值;本变更不修改 migration、worker、路由、重试、租约或生成状态机。

#66 管理端四分组菜单与权限基线

Migration 000009 将原“Chorus 运营”平铺导航改为四个一级分组:/chorus/configuration(生成配置)、/chorus/monitoring(运行监控)、/chorus/access(用户与访问)和 /chorus/system(系统管理)。现有 8 个业务页面保持原 URL 和组件路径,只调整父菜单、paths、排序和 breadcrumb;/chorus/users 的显示名改为“终端用户”。

系统管理新增管理员账号、角色权限、菜单结构、接口清单和登录日志 5 个入口,组件分别复用 admin/sys-user/index、admin/sys-role/index、admin/sys-menu/index、admin/sys-api/index 和 admin/sys-login-log/index。Migration 只为菜单结构、接口清单和登录日志登记 GET API 与 Casbin 权限,不登记写权限;管理员和角色页面登记后续生产适配所需的受控写接口。

chorus_operator 的菜单、菜单 API 和 Casbin 基线全部由 migration 管理。up 在删除旧父菜单前拒绝未知子菜单,down 在删除四个新父菜单前同样拒绝未知子菜单;down 恢复“Chorus 运营”父菜单、8 个业务入口原顺序和“用户管理”旧显示名。生产仍禁止 AutoMigrate。#67 完成后才能开放系统管理页面,因为现有 go-admin handler 的写路由和管理员保护仍需收紧。

#67-#69 管理端系统管理边界

管理端导航由 migration 000009 维护,固定分为“生成配置、运行监控、用户与访问、系统管理”四组,共 13 个叶子入口。系统管理只恢复管理员账号、角色权限、菜单结构、接口清单和登录日志五个模块;部门、岗位、字典、参数、操作日志、任务、监控和开发工具不属于 Chorus 管理端交付范围。

后端入口位于 admin/app/admin/router、admin/app/admin/apis 和 admin/app/admin/service。管理员账号只使用账号、昵称、角色、状态、密码和备注;不依赖部门、岗位、手机号或邮箱。菜单、接口、登录日志只注册 GET 路由;管理员和角色保留受保护的写操作。前端页面位于 admin-ui/src/views/admin/sys-*,动态组件仍由 admin-ui/src/store/modules/permission.js 的视图索引解析。

管理员与终端用户继续分表:管理端使用 sys_user,Portal 使用 users,两者凭据和权限不得复用。保护判断在后端 service 中执行,前端禁用按钮只是交互提示。

Admin 生成记录媒体查看(#72)

Admin 生成记录列表仍只返回任务摘要。管理员点击“查看”或双击列表行后,前端调用 GET /api/v1/chorus/generations/:id 取得输入、图片输出和文本输出的安全元数据,再通过以下受保护端点读取媒体:

  • GET /api/v1/chorus/generations/:id/inputs/:inputID
  • GET /api/v1/chorus/generations/:id/outputs/:outputID
  • GET /api/v1/chorus/generations/:id/outputs/:outputID/thumbnail

请求沿用 go-admin JWT 与 Casbin。服务先核对 generation 与 input/output 的数据库关系,再核对存储对象的 owner、generation 和 MIME 元数据;响应不包含 storage_key、缩略图 key 或本地路径。Admin 只注入 storage.Reader 的 Open 能力,不持有 Put 或 Delete。前端通过带 Authorization header 的 Axios Blob 请求读取图片,并在关闭详情时释放对象 URL,不把 Token 拼入媒体 URL。

管理员创建受控终端用户(#73)

  • Portal 继续只注册账号密码登录,不提供公开注册、找回密码或邮件验证入口。
  • Admin 在既有 JWT 与 Casbin 边界内提供 POST /api/v1/chorus/users 和 PUT /api/v1/chorus/users/:id/password;sys_user 与 users 仍然分表且不复用凭据。
  • 创建用户统一规范化账号和邮箱,复用 internal/platform/password 的 bcrypt:v1 编码;密码重置只替换哈希,响应、错误、日志和审计不返回密码或哈希。
  • 终端用户列表和生成记录关联查询显式选择公开字段,不读取 password_hash。创建、启停和密码重置写入脱敏的 admin_audit_events。
  • 000011_admin_portal_user_management 只增加两条 Admin API、菜单关联和 chorus_operator Casbin 权限,down 不删除或修改任何终端用户数据。

可控自助注册调用路径(#79–#82)

  • migrations/000012_portal_registration.*.sql 使 users.email 可空,创建单例策略表 portal_registration_policy、最小化认证事件表 portal_auth_events,并写入 Admin API、菜单关联和 Casbin 权限。生产仍不使用 AutoMigrate。
  • internal/core/registration 读取策略、在共享行锁内执行注册事务,并写不含身份原文和网络指纹的认证事件。Admin 的策略更新使用 enabled + version CAS;策略更新与 admin_audit_events 在同一事务提交。
  • portal/auth 负责账号规范化、6–1024 字符密码校验、bcrypt 编码、唯一冲突分类和注册事务。portal/handler 暴露 GET /register 与受 CSRF、会话和独立 IP 限流保护的 POST /api/session/register;成功后旋转并建立 Portal 会话。
  • portal/handler.ClientIPResolver 只信任配置 CIDR 内的直接代理,并从 X-Forwarded-For 右向左跳过可信代理;不可信或格式错误时回退到直接对端地址。
  • admin/app/chorus 暴露受 go-admin JWT/Casbin 保护的 GET/PUT /api/v1/chorus/registration-policy。终端用户页内嵌状态、更新时间和二次确认开关,不增加独立菜单入口。