52 KiB
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: 2fac5b4b6c936d234e63c11ad57aba40777f9d7a synchronized_at: 2026-08-25T08:47:41Z
架构与代码地图
项目定位
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使用 Gotext/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;租约比较和截止时间统一使用 MySQLNOW(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_tokenCAS,再写 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 的chatJSON 与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 提交增加
metadataJSON,声明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-KeyHeader,限定 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_tokenCAS 把任务恢复为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可用性;工作台优先选择可用的图片编辑模式、禁用没有活动路由的模式,两种路由都不可用时禁用提交。该页面提示只是前置反馈,提交服务仍执行独立路由校验。