Files
cpa-plugin/docs/modules/core.md
T

431 lines
21 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.
# Core 核心业务模块
## 1. 定位
Core 是 `cpa-ext` 的应用层和核心功能入口。它不代表某一种数据,也不等于某一个 CPA callback;它把多个领域模块组合成一个完整、可执行、可恢复的业务流程。
Core 负责回答:
- 管理员创建一个用户和 Key 时,哪些模块按什么顺序执行;
- 一个下游请求进入后,如何完成认证、金额准入、账户路由、采集和结算;
- 失败、取消、重试、迟到 Usage 和插件重启时,状态如何收敛;
- 管理 API 或 CPA 回调触发的动作应调用哪些领域能力;
- 哪些步骤必须处于同一事务,失败时系统选择放行还是拒绝。
Core 不拥有计价公式、SQL、CPA ABI、图表算法或 UI。它通过清晰接口编排这些能力。
```text
CPA callbacks / Management handlers / maintenance triggers
Core use cases
┌───────────────┼────────────────┐
▼ ▼ ▼
Auth & Access Billing/Route Query/Statistics
└───────────────┼────────────────┘
Repository / Outbox
```
## 2. Core 拥有什么
Core 拥有:
- 应用用例和调用顺序;
- 请求业务状态机;
- 跨模块事务边界;
- 失败策略和稳定业务错误码;
- 已认证主体到权限 scope 的转换;
- 管理操作的审计编排;
- 维护任务的触发依赖和恢复顺序;
- 模块组合后的只读 facade。
Core 不拥有:
| 内容 | 所属模块 |
| --- | --- |
| RPC JSON/C ABI、能力方法分发 | `dev.md` 运行时适配层 |
| Request/Usage 字段定义 | `data.md` |
| CPA payload 解析和关联 | `collection.md` |
| SQL、migration、WAL、备份 | `persistence.md` |
| 价格来源、版本、模型匹配和 tier 政策 | `pricing.md` |
| Usage 金额计算、套餐、余额和账本 | `billing.md` |
| CPA Auth、priority、配额和账户池目录 | `upstream-accounts.md` |
| Key 状态规则和 Auth 候选选择 | `access-routing.md` |
| 小时/日聚合和查询分析 | `statistics.md` |
| HTTP route/DTO/权限/错误 | `api.md` |
| HTML、React 页面 | `ui.md` |
Core 可以依赖这些模块暴露的接口,但领域模块不能反向依赖 Core 的具体实现。
## 3. 下游身份与 Key 主路径
### 3.1 架构决断
第一版主路径由 `cpa-ext` 自己管理下游 Key,并通过 CPA `frontend_auth_provider` 完成认证。生产配置使用 `frontend_auth_provider_exclusive: true`,在插件**已经成功加载且未被 fuse**时阻止 CPA 原生 Key 或其他认证 provider 与它并行生效。exclusive 不是“插件必需加载”的启动保证;插件缺失/失败时的部署门禁见 [dev.md](dev.md)。
原因:
- Key 创建、禁用、过期、轮换可以立即生效;
- Key、用户金额账户和上游路由绑定可以处于同一事务;
- 不需要让插件反向修改 CPA `config.yaml`
- 不需要在事件和管理 API 中保存或传播明文 Key;
- 可以把稳定 `credential_id` 交给 CPA;同一 Credential 的 secret version 轮换仍返回相同 Principal,因此 CPA 生成的 `caller_scope` 不变。
CPA 原生 `api-keys` 只作为显式兼容/迁移模式。兼容模式必须有清晰策略:映射成功才进入本地账户;未映射 Key 默认拒绝,不能自动成为无限额度 Key。
### 3.2 Key 格式与保存
推荐格式:
```text
cpa_<public_key_id>_<random_secret>
```
- `random_secret` 至少来自 256 bit CSPRNG
- 创建成功时只展示一次完整 Key
- 一个稳定 Credential 可以拥有多个 `CredentialSecretVersion`;每个 version 保存 public ID、校验摘要、`hmac_key_id`、脱敏预览和状态,不保存可恢复明文;
- 使用实例级 secret 对 Key 做 HMAC-SHA-256 lookup/verification,并用常量时间比较;
- 实例级 secret 不得写入 SQLite、日志或 UI
- secret 轮换为同一 Credential 新增 versionoverlap 后只撤销旧 version;只有管理员明确签发独立 Key 才创建新 Credential
- 认证成功返回稳定 `credential_id` 作为 CPA Principal,而不是返回明文 Key。
下游 Key 是高熵机器凭证,不需要为每次请求使用昂贵的密码哈希;若未来支持人类口令登录,口令必须使用独立的 Argon2id/bcrypt 体系,不能复用 API Key 校验逻辑。
实例级 HMAC secret 是凭证数据库的必要恢复材料:启动时缺失未知 `hmac_key_id` 必须停止认证并告警,不能临时生成新 secret 后让所有旧 Key 悄然失效。轮换使用带 ID 的 keyring;新 Credential 用 active secret,旧摘要继续用其原 key ID 校验,直到对应 Credential 全部轮换/撤销。备份与灾难恢复文档必须把 secret keyring 和 SQLite 分开保护、成对验证。
## 4. 核心接口
Core 依赖面向行为的接口,避免一个巨型 `Store` 暴露全部内部状态:
```go
type CredentialService interface {
Authenticate(ctx context.Context, presentedKey string) (Principal, error)
Create(ctx context.Context, command CreateCredential) (CreatedCredential, error)
Revoke(ctx context.Context, command RevokeCredential) error
}
type AccessService interface {
Admit(ctx context.Context, command AdmitRequest) (Admission, error)
SelectAuth(ctx context.Context, command SelectAuth) (RouteDecision, error)
Release(ctx context.Context, requestID string) error
}
type CollectionService interface {
Begin(ctx context.Context, observation RequestObservation) error
ObserveExecution(ctx context.Context, observation ExecutionObservation) error
ObserveUsage(ctx context.Context, observation UsageObservation) error
Complete(ctx context.Context, completion RequestCompletion) (CollectedRequest, error)
}
type BillingService interface {
Price(ctx context.Context, input PriceInput) (BillingRecord, error)
Settle(ctx context.Context, command SettlementCommand) (SettlementResult, error)
ApplyLateUsage(ctx context.Context, command LateUsageCommand) (SettlementResult, error)
}
type UnitOfWork interface {
WithinTransaction(ctx context.Context, fn func(Tx) error) error
}
```
具体接口可以随实现收敛,但必须保持:Core 负责用例,模块负责自己的确定性规则,Repository 负责持久化。
## 5. 管理业务用例
### 5.1 创建账户并签发 Key
```text
管理员鉴权
→ 校验账户/套餐/额度/路由命令
→ 事务内创建 DownstreamAccount
→ 创建 BillingAccount / Cycle policy
→ 创建 RouteBinding
→ 创建逻辑 Credential 与首个 CredentialSecretVersion 摘要
→ 写 AuditEvent
→ 提交
→ 只在响应中返回一次完整 Key
```
随机 Key 可以在事务前生成,但只有事务成功后才能返回。事务失败时不得返回一个数据库中不存在的凭证。
### 5.2 禁用、撤销和删除
- `disable`:暂时禁止认证,历史和账本保留;
- `revoke`Credential 永久不可再次启用;
- `delete account`:默认只做软删除,先撤销所有 Key;
- 任何操作都写审计事件;
- 已在执行的请求按准入快照完成结算,禁用只阻止新请求;
- 账本、BillingRecord 和历史 Request 不随 Key 删除。
### 5.3 套餐、余额和人工调整
- 套餐修改只影响指定生效时间后的准入;
- 当前执行请求继续使用 admission snapshot
- 充值、扣减、退款、重置必须追加 LedgerEntry
- Core 调用 Billing 校验金额和周期,再由持久化事务同时写审计;
- 禁止管理 handler 直接更新 `spent``remaining` 等投影字段。
### 5.4 账户绑定
绑定命令同时校验:
- downstream account/credential 是否有效;
- upstream identity 是否存在、provider 是否为 Codex、是否已删除;
- route mode 是否为 default/strict/preferred/pool
- strict 绑定是否至少存在一个目标;
- 修改是否影响已有执行;
- 操作者权限和审计原因。
## 6. 请求业务状态机
### 6.1 状态
```text
received
├─ rejected
└─ admitted
├─ routing
├─ executing (1..N attempts)
└─ terminal: succeeded / failed / canceled
├─ awaiting_usage
├─ settled
└─ unmeasured_final
```
请求终态与结算状态分开保存。`canceled` 可以是 `settled``succeeded` 也可能暂时是 `awaiting_usage`
### 6.2 认证
CPA 调用 `frontend_auth.authenticate` 时:
1. 先检查 method + path allowlist;只有经过完整 interceptor/scheduler/usage/lifecycle 验证的入口才继续认证;
2. 从支持的 Header 提取 Key,禁止从日志或错误回显;
3. 解析 public key ID,执行有界数据库 lookup
4. 常量时间校验对应 secret version 的摘要;
5. 检查 Credential 与 secret version 的 active/disabled/revoked/expired;非 active 状态与未知 Key 对外统一失败;
6. 返回 `Authenticated=true``Principal=credential_id` 和最少安全 metadata
7. 不在 metadata 中放账户余额、上游 AuthID 或秘密。
MVP allowlist 固定为 `POST /v1/responses``POST /v1/responses/compact``POST /backend-api/codex/responses``POST /backend-api/codex/responses/compact``GET /v1/models` 只有在宿主集成测试确认不发生可计费执行后才作为零金额只读入口开启。Alpha Search、Live、Realtime/client secret、WebSocket Responses、图片、视频、其他 provider 路由和未知入口默认认证失败。每增加一个入口,必须先证明它完整进入准入、路由、Usage 和终态链路。
认证只证明“是谁”,金额准入仍在 request interceptor/Core 中执行。这样错误码、周期结转和并发限制可以统一处理。
### 6.3 准入与开始
`request.intercept_before` 调用 Core
1. 使用预存/按目标 CPA 算法确定的 `credential_id → caller_scope` 映射查找本地 Credentialrequest interceptor 本身看不到原始 Principal
2. 拒绝 nested plugin callback 的重复计费路径;
3. 校验 Key、账户、模型范围、周期金额、并发和价格可用性;
4. 在短事务中保存 Request pending、admission snapshot,并占用并发槽;
5. 返回放行或稳定错误。业务拒绝、数据库/账本/价格故障都必须作为成功 RPC envelope 内的 `Terminate=true + StatusCode + safe ResponseBody` 返回,不能返回 RPC error,因为当前宿主会忽略 interceptor error 并继续请求。
推荐生产失败策略:
- 已完成认证后,本地数据库/账本不可用:`503`fail closed
- 金额耗尽:`429 billing_quota_exhausted`
- 缺少、未知、畸形、禁用、撤销或过期 Key:当前 CPA 认证阶段由宿主统一返回 `401 no_credentials`
- 认证后状态并发变化:interceptor 二次检查并返回 `403 access_credential_inactive`
- 无价格且策略要求严格计费:`503 pricing_unavailable`
这是当前宿主契约的限制,而不是 Core 自定义的业务错误:frontend-auth adapter 会把插件错误和 `Authenticated=false` 都映射为 `NotHandled`。所以认证 lookup 自身遇到数据库故障时也会 fail closed 为 `401 no_credentials`;插件必须另记内部诊断/告警。若产品必须向调用方返回认证依赖故障的 `503`,需要先增强 CPA frontend-auth response 和 adapter,不能只改 Core。
插件 dispatcher 必须在每个 RPC 方法边界 recover。interceptor 内部 panic 转为正常 RPC success + `Terminate=true, 503`;不能让 panic 越过 native 边界触发 CPA fuse,因为当前宿主在 interceptor error/panic 时会 fail open。
### 6.4 路由与执行尝试
CPA `scheduler.pick` 调用 Core 的 Route use case
1. 读取 request metadata 中的 caller scope
2. 解析 Credential/Account 的 RouteBinding
3. 只在 CPA 已过滤后的 candidates 中选择;
4. strict 目标不在候选集时返回 scheduler RPC error,阻止选择;绝不能用空/unknown AuthID 或 `Handled=false` 表达拒绝,因为宿主会回退内置调度;
5. preferred/pool 按文档规则回退;
6. 保存 `route_decision`,但不把数据库锁带入 CPA callback。
`request.intercept_after` 每次可观察的上游选择后建立一个 Execution attempt,并在实际执行前再次验证 `selected_auth_id` 属于该 Credential 允许集合;不匹配时用 `Terminate=true` 拒绝。重试不能覆盖上一尝试;最后结果与每次实际 Usage 分开保存。
MVP 的 Execution 精确定义为“一次 `request.intercept_after` 可观察到的 auth/model 执行段”,不保证等于一个物理 HTTP dispatch;例如 auth refresh 后的内部重发不会再次触发 after-auth,只能记录 `subattempt_count/observability=partial`。当前 CPA 会在同一个 RequestID 下多次调用 after-auth,但 `usage.handle` 没有 RequestID/AttemptID。Core 不得把无关联 Usage 猜配给某个并发请求:带 response/RequestID 的 Usage可以结算,无法关联的失败 attempt 只记录 `unmeasured`。完整的 dispatch/attempt 成本需要宿主契约增强。
### 6.5 Usage 观察与终态结算
Response hooks 和 `usage.handle` 只提交观察事实。Core 的关联服务将它们合并到 Request/Execution,但不能在每个 chunk 上直接扣款。response hook 一旦取得新的可靠 canonical Usage,必须在该 hook 返回 CPA 前把 observation/inbox 与 revision 原子持久化;至少最终 Usage 不能只留在内存等待 completion。
终态用例:
1. 幂等接收 `request.complete`
2. 立即释放并发占用;
3. 封存 Request/Execution 当前事实;已经耐久化的 Usage 不依赖 completion 才存在;
4. rejected 且没有上游执行:不生成消费账本;
5. 有可靠 Usage:Billing 计算金额,事务内写 BillingRecord、LedgerEntry、余额投影和 projection event
6. 无 Usage 但可能已触达上游:进入 `awaiting_usage`
7. 提交后用 outbox/非阻塞通知唤醒统计;
8. 重复终态得到相同结果,不产生第二笔账。
### 6.6 取消、失败和迟到 Usage
统一决断:**按可靠的实际上游 Usage 收费,不按 outcome 收费。**
- 取消前已经产生 Usage:正常扣费并显示 `outcome=canceled`
- 失败前已经产生 Usage:正常扣费并显示 `outcome=failed`
- 无 Usage:不猜费,暂记 0 并标记 `unmeasured`
- 迟到 Usage:按新累计应收与已收金额的差额追加 `late_settlement`
- 重复/旧 revision:不扣费;
- 矛盾或减少的累计值:不自动退款,进入人工复核。
Core 必须把“释放并发”和“完成金额核算”拆开,否则为了等待迟到 Usage 会长期占用 Key 并发槽。
## 7. 事务与一致性
必须处于同一 SQLite 事务的典型操作:
- 创建账户、Key 摘要、套餐绑定、路由绑定和审计;
- admission snapshot 与并发占用;
- BillingRecord、LedgerEntry、余额投影和结算幂等键;
- 迟到补记账本与原请求结算投影;
- 管理员余额调整与审计。
不能放进事务:
- CPA host callback
- provider HTTP 请求;
- 长时间统计查询;
- 静态资源处理;
- 等待维护 runner/sidecar。
跨事务通知使用本地 outbox 或由已提交事实的 checkpoint 追赶。不能先发内存事件再提交数据库。
## 8. 并发和幂等
Core 的每个命令携带:
- command/event ID
- actor
- request/trace/execution ID(适用时);
- expected version(管理更新适用时)。
关键约束:
- RequestID 唯一;
- ExecutionID 或 `(request_id, attempt_no)` 唯一;
- Usage revision 幂等;
- 每种结算类型拥有唯一 idempotency key
- Credential public ID 唯一;
- 更新绑定和套餐使用乐观版本或事务锁定,防止管理页面覆盖并发变更。
进程内 map 只能做短期关联和缓存,数据库唯一约束才是最终防线。
Usage revision 由插件生成而非来自 CPA:每个 Execution 保存 canonical cumulative usage vector、source rank、response ID 和 canonical hash。相同 hash/vector 的重复回调不提升 revision;只有事务内 CAS 确认 canonical vector 实际变化时才创建下一 revision。多来源用于择优与交叉校验,绝不能相加;结算差额只在单个 Execution 内计算。
## 9. 管理与查询 facade
管理 API handler 不直接调用 Repository,而调用 Core facade
```go
type AdminFacade interface {
CreateAccount(context.Context, Actor, CreateAccountCommand) (AccountView, error)
IssueCredential(context.Context, Actor, IssueCredentialCommand) (IssuedCredential, error)
ChangePlan(context.Context, Actor, ChangePlanCommand) error
AdjustBalance(context.Context, Actor, AdjustBalanceCommand) error
ChangeRoute(context.Context, Actor, ChangeRouteCommand) error
RevokeCredential(context.Context, Actor, RevokeCredentialCommand) error
}
type QueryFacade interface {
UserDashboard(context.Context, Principal, DashboardFilter) (UserMoneyView, error)
AdminOverview(context.Context, Actor, OverviewFilter) (AdminOverview, error)
}
```
Core 构造权限 scopeStatistics/Repository 执行查询。用户请求不能通过传 account ID 越权;管理员操作必须包含 Actor 和 Audit reason。
## 10. 启动、恢复与降级
Core 初始化顺序:
1. 解析并验证配置;
2. 打开 SQLite,执行 migration 和 integrity 基础检查;
3. 加载实例 secret、价格版本和必要目录;
4. 构造 Repository 与领域服务;
5. 恢复 pending/abandoned Request、已保存 Usage 但未结算的 Execution、awaiting usage、未发布 projection/outbox
6. 装配 callback-driven maintenance;只有 dev runtime 双 Go runtime soak gate 通过后才启动可选 worker,否则长任务交给 sidecar
7. 形成 immutable Runtime,开始接受 CPA 方法。
核心计费依赖失败时必须 fail closed。可选能力可以降级:
- 统计聚合失败:请求和计费继续,UI 显示 lag;
- 上游配额刷新失败:保留旧快照并标过期;
- 最近事件缓存失败:回退 SQLite;
- UI 静态资源失败:不影响请求计费;
- migration、账本写入、Key 校验库失败:停止新请求准入。
当前 CPA 对插件缺失、host 级 fuse、interceptor host-boundary panic 没有纯插件可实现的绝对 fail-closed 保证。生产发布必须把 dev 文档中的 required-plugin/startup gate 和 sentinel provider 作为外部安全前提;缺一项时该部署只能标为开发模式。
## 11. 建议代码结构
```text
internal/
core/
app.go # 依赖装配后的应用 facade
request_flow.go # 请求状态机与编排
credential_usecases.go
billing_usecases.go
routing_usecases.go
query_facade.go
errors.go
domain/
identity/
access/
billing/
statistics/
repository/
cpaadapter/
management/
cmd/cpa-ext/ # 薄 C ABI 入口
```
Core 包不能 import `C`、不能解析 CPA wire JSON,也不能渲染 HTTP。
## 12. 如何参考现有项目
`cpa-plugin-key-billing` 参考其纵向闭环:
- `internal/plugin/app.go`RPC 到应用入口的分发;
- `internal/plugin/intercept.go`:准入、终态和取消;
- `internal/plugin/usage_tracker.go`Request/response 关联;
- `internal/billing/pending.go`:准入快照和单一提交点;
- `internal/billing/account.go`Usage 到账户结算;
- `internal/billing/admin.go``keys.go`:管理用例。
吸收业务顺序和边界,不复制巨型状态 Store、浮点账本或把管理 handler 直接当领域服务的结构。
`cpa-usage-keeper` 参考其应用装配、算法和维护任务边界:
- `internal/app/app.go`Repository、服务、runner 和 router 的装配/关闭;
- `internal/service/`API 与 Repository 之间的服务层;
- `internal/poller/usage_aggregation_runner.go`:已提交事实驱动聚合;只能借鉴 batch/checkpoint 算法,不能默认复制 goroutine;
- `internal/app/maintenance.go``backup_runner.go`:维护任务生命周期。
它是独立服务,不能直接复制其 HTTP server、goroutine、timer 或 backup runner 到 CPA 进程内 c-shared 插件。
CPA 契约依据:
- `CLIProxyAPI/sdk/pluginapi/types.go` 的 frontend auth、interceptor、lifecycle、scheduler、management 类型;
- `internal/pluginhost/adapters_auth.go` 的 exclusive provider 选择;
- `sdk/api/handlers/handlers.go` 的 Principal → caller scope
- `examples/plugin/frontend-auth-exclusive/``request-lifecycle/``scheduler/`
## 13. 验收标准
- 能完成“创建用户和金额账户 → 签发 Key → 绑定 Codex 账户 → 请求 → 扣费 → 耗尽拒绝”的真实闭环;
- 在受支持部署(Home 关闭、sentinel + readiness gateway 生效)中,插件 active/异常/缺失状态都不存在用户可用的 CPA 原生 Key 绕过;
- 完整 Key 只在签发时显示一次,数据库和日志不保存明文;
- 请求重试、重复回调、取消、失败和迟到 Usage 不会重复扣费;
- 禁用 Key 立即阻止新请求,不影响已发生费用的结算;
- 每个跨模块事务都有清晰原子边界和崩溃恢复结果;
- 管理 handler 与 UI 不能直接修改存储或余额投影;
- 统计故障不影响计费,计费/数据库故障时新请求安全拒绝;
- Core 可用普通 Go 单元测试覆盖,不依赖 CGO 或真实 CPA 动态库。