diff --git a/.dockerignore b/.dockerignore index cb7273d..a29ad2b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,7 @@ frontend/node_modules .DS_Store coverage *.tsbuildinfo +.env +.env.* +!.env.example +*.zip diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9b8e90c --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# CLIProxyAPI Management API 地址。默认 Compose 配置使用宿主机 8317 端口。 +CLIPROXY_API_BASE_URL=http://host.docker.internal:8317 + +# CLIProxyAPI remote-management.secret-key 的明文管理密钥。 +# 只保存在部署环境中,不要提交真实值。 +CLIPROXY_API_MANAGEMENT_KEY=replace-with-management-key diff --git a/AGENTS.md b/AGENTS.md index ca9394f..035a796 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,18 +8,18 @@ ## 项目概览 -Codex Helper 是单容器部署的 Codex 账户用量仪表盘,通过 Codex app-server 读取 ChatGPT/Codex 账户、限额和 Token 历史,并通过 Telegram 与 SMTP 发送重置提醒。 +Codex Helper 是单容器部署的 Codex 账户用量仪表盘,通过外部 CLIProxyAPI Management API 读取已托管 Codex 凭据对应的 ChatGPT/Codex 账户、限额和 Token 历史,并通过 Telegram 与 SMTP 发送重置提醒。 - `backend/`:Go 1.26、`net/http` 与 SQLite 后端;[`backend/CONTRACT.md`](backend/CONTRACT.md) 是 API 路径、状态码、响应结构和兼容文案的契约。 - `frontend/`:React 19、TypeScript、Vite、Recharts 前端;生产构建嵌入 Go 二进制。 -- `Dockerfile`:依次构建前端、Go 后端和固定版本 Codex CLI,最终以 UID `10001` 非 root 用户运行。 -- `docker-compose.yml`:对外映射端口并将全部运行数据保存到 `codex-helper-data` 卷的 `/data`。 +- `Dockerfile`:依次构建前端和 Go 后端,最终以 UID `10001` 非 root 用户运行;运行镜像不包含 Codex CLI。 +- `docker-compose.yml`:对外映射端口并将 Codex Helper 自身的数据库与密钥保存到 `codex-helper-data` 卷的 `/data`;CPA 凭据由外部服务保存。 ## 开始工作前 1. 先阅读改动相关的代码、调用方和测试,不要仅凭文档推断行为。 2. 从 [`docs/README.md`](docs/README.md) 选择对应专题;修改 API 时同时核对 [`backend/CONTRACT.md`](backend/CONTRACT.md)。 -3. 修改鉴权、SQLite、账号删除、Codex 进程、凭据、提醒、备份或部署时,先读 [`docs/reference/engineering-invariants.md`](docs/reference/engineering-invariants.md)。 +3. 修改鉴权、SQLite、账号删除、CLIProxyAPI 集成、凭据、提醒、备份或部署时,先读 [`docs/reference/engineering-invariants.md`](docs/reference/engineering-invariants.md)。 4. 执行代码审查时,必须遵循 [`docs/reference/code-review-rules.md`](docs/reference/code-review-rules.md)。 ## 环境与执行政策 @@ -31,7 +31,7 @@ Codex Helper 是单容器部署的 Codex 账户用量仪表盘,通过 Codex ap ## 验证与审查 - 行为修改必须验证成功路径、关键边界和失败路径;修复缺陷时优先添加能复现原问题的回归测试。 -- 后端至少运行相关 Go 测试以及 `gofmt` 检查、`go build ./...` 和 `go vet ./...`;SQLite 迁移、并发或备份改动必须运行相关集成测试。 +- 后端至少运行相关 Go 测试以及 `gofmt` 检查、`go build ./...` 和 `go vet ./...`;SQLite 迁移、并发、CLIProxyAPI 客户端或备份改动必须运行相关集成测试。 - 前端至少运行相关 Vitest 和 `npm run build`;布局、路由、账号设置或响应式交互改动运行相关 Playwright 测试。 - Dockerfile、Compose、持久化或运行用户改动必须验证镜像或 Compose 配置,并核对升级数据路径。 - 代码审查只报告可复现、由当前改动引入或暴露的问题,按 P0–P3 排序;完整流程见 [`docs/reference/code-review-rules.md`](docs/reference/code-review-rules.md)。 @@ -40,12 +40,13 @@ Codex Helper 是单容器部署的 Codex 账户用量仪表盘,通过 Codex ap - HTTP 业务接口保持在 `/api/v1/`;未初始化状态只开放 status、setup 和 login,其他接口必须经过 session 与非只读请求来源校验。 - `backend/internal/store/store.go` 中的 schema 和兼容迁移是 SQLite 结构的事实来源;启动迁移必须保留旧库数据并保持幂等。 -- 账号 ID `1` 的 Codex 凭据固定保留在 `/data/codex`;其他账号使用 `/data/accounts//codex`,升级时不得搬迁旧路径。 -- 每个账号拥有独立 app-server 进程和 `CODEX_HOME`;启动、初始化、同步和停止必须维持现有串行化与并发保护。 -- 删除账号会删除数据库历史和对应凭据目录,是不可恢复操作;前后端必须保持明确确认与精确目标。 +- 旧账号及其历史数据升级时必须保留,并以空 `auth_index` 等待管理员绑定 CPA;新数据库不再创建无绑定的默认账号。 +- 每个非空 CPA `authIndex` 最多绑定一个本地账号;添加或修改前必须验证其唯一指向未禁用的 Codex auth,失败不得留下半完成记录。 +- 删除账号会删除本地数据库历史且不可恢复,但不得删除 CLIProxyAPI 中的凭据;前后端必须明确确认精确目标。 - session cookie 只保存随机 token,数据库只存 SHA-256 摘要;密码继续使用 argon2id,SMTP 密码和 Telegram Token 继续由 `/data/secret.key` 加密。 -- 设置接口不得返回 SMTP 密码或 Telegram Token 明文;秘密不得进入 Git、日志、前端状态快照或 Docker 构建上下文。 -- 限额百分比、窗口和 Token 摘要以 app-server 返回值为准;optional、`null`、多 bucket 和未知套餐必须安全降级。 +- 设置接口不得返回 SMTP 密码或 Telegram Token 明文;CPA management key、OAuth token 和其他秘密不得进入 Git、日志、前端状态快照或 Docker 构建上下文。 +- CPA 地址和 management key 只从服务端环境读取,公开响应必须隐藏 `authIndex`;所有 Management API 请求必须有超时、大小限制和不泄密错误。 +- 限额百分比、窗口和 Token 摘要以 CPA 代请求的 `/wham/usage` 与 `/wham/profiles/me` 返回值为准;optional、`null`、多 bucket 和未知套餐必须安全降级。 - 提醒以稳定 dedupe key 去重,失败只在计划时间后六小时窗口内重试;提前、计划后和异常提前重置语义不得混淆。 - SQLite 下载仅是包含已提交 WAL 数据的一致性数据库快照,不包含 `secret.key` 或 Codex 凭据;完整恢复必须备份整个 `/data`。 - 前端路由和隐藏控件只负责交互,安全边界必须由后端强制执行;账号邮箱在界面中继续掩码显示。 diff --git a/Dockerfile b/Dockerfile index 6938e35..17e2904 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,17 +17,10 @@ COPY backend/internal ./internal COPY --from=frontend /src/backend/internal/web/dist ./internal/web/dist RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X codex-helper/internal/app.Version=${APP_VERSION}" -o /out/codex-helper ./cmd/server -FROM node:24.19.0-bookworm-slim AS codex -ARG CODEX_VERSION=0.147.0 -RUN npm install -g @openai/codex@${CODEX_VERSION} && npm cache clean --force - FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/* \ && useradd --system --uid 10001 --create-home helper && mkdir -p /data && chown helper:helper /data COPY --from=backend /out/codex-helper /usr/local/bin/codex-helper -COPY --from=codex /usr/local/bin/node /usr/local/bin/node -COPY --from=codex /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex -RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex USER helper ENV DATA_DIR=/data LISTEN_ADDR=:8080 VOLUME ["/data"] diff --git a/README.md b/README.md index e601455..efd9eb6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Codex Helper -一个单容器运行的 Codex 账户用量仪表盘。通过 Codex app-server 读取当前 ChatGPT/Codex 账户、套餐、限额窗口、重置时间和每日 Token 历史,并支持 Telegram 与 SMTP 重置提醒。 +一个单容器运行的 Codex 账户用量仪表盘。通过 CLIProxyAPI(CPA)Management API 读取已托管 Codex 凭据对应的 ChatGPT 账户、套餐、限额窗口、重置时间和每日 Token 历史,并支持 Telegram 与 SMTP 重置提醒。 ## 功能 @@ -13,25 +13,37 @@ - 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息 - 初始化后无需登录即可查看公开只读账号总览;只有登录管理员后才能添加账号、同步用量和修改运行期配置 - 前端适配 320px 起的手机浏览器,支持移动底部导航、iOS 安全区与深浅主题 -- React 前端、Go 后端、Codex CLI 和 SQLite 运行在同一个容器中 +- React 前端、Go 后端和 SQLite 运行在同一个容器中;Codex OAuth 凭据统一由外部 CLIProxyAPI 管理 ## 运行要求 - Docker Engine - Docker Compose v2(使用 `docker compose` 命令) -- 一台能够访问 GitHub、OpenAI 登录页面及 Codex 服务的主机 -- 一个可使用 Codex 的 ChatGPT 账户 +- 已运行并启用 Management API 的 CLIProxyAPI 实例 +- CLIProxyAPI 中至少一个可用的 Codex OAuth 凭据及其 `auth_index` +- Codex Helper 容器能够访问 CLIProxyAPI;CLIProxyAPI 能够访问 ChatGPT Codex 服务 -项目不需要单独准备 OpenAI API Key。当前界面使用 ChatGPT 设备码登录,并由容器内的 Codex app-server 保存和刷新登录凭据。OpenAI 官方的设备码流程说明见 [Codex App Server 文档](https://learn.chatgpt.com/docs/app-server#3b-log-in-with-chatgpt-device-code-flow)。 +项目不需要单独准备 OpenAI API Key,也不在本地执行 Codex 登录。Codex OAuth 登录、Token 保存和刷新全部由 CLIProxyAPI 负责;Codex Helper 只保存 CPA `authIndex`,并通过受 management key 保护的服务端请求读取数据。 ## 安装 ```bash git clone git@github.com:zhoujun0601/codex-helper.git cd codex-helper +cp .env.example .env +# 编辑 .env,填写 CLIProxyAPI Management API 地址和 management key docker compose up -d --build ``` +`.env` 至少包含: + +```dotenv +CLIPROXY_API_BASE_URL=http://host.docker.internal:8317 +CLIPROXY_API_MANAGEMENT_KEY=replace-with-management-key +``` + +如果 CLIProxyAPI 位于另一个容器网络,请把两个服务加入同一网络,并将 `CLIPROXY_API_BASE_URL` 改为对应服务名。Codex Helper 从另一个容器访问 CPA Management API 时,CPA 的 `remote-management.allow-remote` 必须允许该来源,且 `remote-management.secret-key` 必须已配置。 + 查看运行状态: ```bash @@ -53,20 +65,16 @@ http://服务器地址:8180 ## 连接 Codex 账户 -1. 使用管理员账号登录 Codex Helper。 -2. 打开“设置中心” → “Codex”。 -3. 点击“生成设备码”。 -4. 点击页面显示的 OpenAI 验证地址,或在另一台设备的浏览器中打开该地址。 -5. 登录需要监控的 ChatGPT/Codex 账户。 -6. 输入页面显示的一次性设备码并确认授权。 -7. 返回 Codex Helper,服务器会在后台自动同步账号用量;首次同步通常会在数秒内完成,之后固定每 5 分钟同步一次。 -8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。 +1. 先在 CLIProxyAPI 中完成 Codex OAuth 登录,确认凭据状态可用。 +2. 从 CPA `GET /v0/management/auth-files` 或其管理面板取得该凭据的 `auth_index`。 +3. 使用管理员账号登录 Codex Helper。 +4. 打开“设置中心” → “Codex”。 +5. 输入 CPA `authIndex`,选择预期的个人订阅或 Team / Business 类型,并点击“添加账号”。 +6. Codex Helper 会先验证该索引唯一指向未禁用的 Codex 凭据,再读取账号信息和用量;成功后立即显示,之后固定每 5 分钟同步一次。 -设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。 +每条本地账号记录只保存一个 CPA `authIndex` 及显示配置,不保存 access token、refresh token 或 CPA management key。删除账号只删除 Codex Helper 中的绑定、历史快照和提醒记录,**不会删除 CLIProxyAPI 中的凭据**。 -如需添加其他账号,或同一邮箱下的个人订阅与 Team 工作区,请在“设置中心” → “Codex”中分别创建连接并完成设备码登录。每个连接使用隔离的登录凭据,可自定义名称;总览页会同时展示所有连接,点击账号卡片即可查看完整详情。删除连接会同时删除对应凭据和历史数据。 - -创建连接时可选择预期的“个人订阅”或“Team / Business 工作区”。授权完成后,Codex Helper 会使用 app-server 返回的真实套餐进行校验;`team` 和当前 Business 系列套餐均识别为团队工作区。设备码接口本身不能指定工作区 ID,因此同一邮箱包含多个空间时,需要在授权页面进入目标空间;如果页面提示类型不匹配,请退出该连接后重新授权。 +同一邮箱下的个人订阅与 Team 工作区应在 CPA 中保留为不同凭据,并分别使用各自的 `auth_index` 添加。Codex Helper 使用 CPA 返回的 ID token claims 和 `/wham/usage` 的真实套餐进行校验;未知新套餐会安全显示为 unknown,不会自动猜测类型。 ## 通用设置与提醒时间 @@ -78,7 +86,7 @@ http://服务器地址:8180 - 提前提醒时间:重置前 1–1440 分钟 - 是否发送重置前提醒和重置后确认;重置后确认也会通过额度百分比回落识别并提醒官方活动、临时补发等提前重置 -提醒只会针对 Codex app-server 返回的限额窗口发送。发送失败的提醒会在计划时间后的六小时内自动重试。 +提醒只会针对通过 CLIProxyAPI 读取到的 Codex 限额窗口发送。发送失败的提醒会在计划时间后的六小时内自动重试。 ## 配置 Telegram @@ -154,7 +162,8 @@ curl http://localhost:8180/health/ready - `codex-helper.db`:管理员、设置、历史用量和通知记录 - `secret.key`:用于解密 SMTP 密码和 Telegram Token -- `codex/`:Codex 登录凭据及配置 + +Codex OAuth 凭据不在该数据卷中,而是保存在 CLIProxyAPI 自己的凭据存储中。 登录管理页面后,可直接在浏览器访问以下地址下载一致性 SQLite 快照: @@ -162,40 +171,40 @@ curl http://localhost:8180/health/ready http://服务器地址:8180/api/v1/maintenance/backup ``` -SQLite 快照不包含 `secret.key` 和 `codex/`。完整灾难恢复必须同时备份整个 `/data` 数据卷;恢复时先停止容器,再恢复全部内容,并确保文件所有者仍可被容器中的 UID `10001` 读取。 +SQLite 快照不包含 `secret.key`。恢复 Codex Helper 本身必须同时备份整个 `/data` 数据卷;CLIProxyAPI 的配置与 OAuth 凭据需要按照 CPA 自身的备份方式单独保护。恢复时先停止容器,再恢复全部内容,并确保文件所有者仍可被容器中的 UID `10001` 读取。 不要使用 `docker compose down -v`,该命令会删除持久数据卷。 ## 数据边界 -Codex Helper 展示的是 Codex app-server 实际返回的数据。当前接口提供: +Codex Helper 通过 CLIProxyAPI 代请求 ChatGPT 的内部 Codex 接口。当前使用的数据源包括 `/wham/usage`、`/wham/profiles/me` 和可选的 `/wham/rate-limit-reset-credits`,提供: - ChatGPT/Codex 账户和套餐类型 - 限额窗口使用百分比、窗口长度和重置时间 - 当前重置周期 Token 合计、本周期单日峰值等摘要 - 每日总 Token 桶 -当前总览的 Token 合计按 app-server 返回的当前有效最长限额窗口汇总每日 Token bucket,通常对应 secondary 周窗口;周期边界所在日期按整日统计。系统不根据 Token 推算 Credits、美元价值或订阅价格。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。 +当前总览的 Token 合计按最长有效限额窗口汇总 `/wham/profiles/me` 返回的每日 Token bucket,通常对应周窗口;周期边界所在日期按整日统计。系统不根据 Token 推算 Credits、美元价值或订阅价格。额度与 Profile Token 活动来自不同的上游聚合链路,刷新时间可能不一致;缺失数据会显示为“暂无”。这些 `/wham/*` 路径属于 ChatGPT/Codex 内部接口,上游可能调整字段或访问规则。 ## 常见问题 -### 页面显示“app-server 未连接” +### 页面显示“CLIProxyAPI 未配置或不可用” -先查看日志并重启服务: +先核对 `.env` 中的 `CLIPROXY_API_BASE_URL` 和 `CLIPROXY_API_MANAGEMENT_KEY`,然后查看日志: ```bash docker compose logs --tail=200 codex-helper docker compose restart codex-helper ``` -确认主机时间准确、DNS 和 HTTPS 出站访问正常。应用会自动重启并重新初始化异常的 app-server 进程。 +确认 CPA Management API 已启用、management key 匹配、远程访问策略允许 Codex Helper 容器,并检查两个容器之间的网络连通性。日志和 API 错误不会输出 management key 或 OAuth Token。 -### 完成设备码授权后仍显示“尚未连接” +### 添加 authIndex 后仍显示“尚未连接” -- 等待首次后台同步完成;之后服务器每 5 分钟自动同步一次。若持续未连接,请检查 app-server 和网络日志。 -- 确认授权的是需要监控的 ChatGPT 账户。 -- 重新进入 Codex 设置,退出账户后再次生成设备码。 -- 检查 `docker compose logs -f codex-helper` 中是否存在网络或认证错误。 +- 确认该索引来自当前配置的 CLIProxyAPI 实例。 +- 确认对应凭据的 provider/type 是 `codex` 且未 disabled;额度耗尽时 CPA 可能暂时标记 unavailable,这不会阻止 Codex Helper 尝试读取重置时间。 +- 在 CPA 中检查 OAuth Token 刷新是否成功。 +- 点击账号的“立即同步”,并检查 `docker compose logs -f codex-helper` 中的非敏感错误信息。 ### Telegram 无法绑定 @@ -232,7 +241,9 @@ npm run dev ```bash cd backend export DATA_DIR=/tmp/codex-helper-data +export CLIPROXY_API_BASE_URL=http://127.0.0.1:8317 +export CLIPROXY_API_MANAGEMENT_KEY=replace-with-management-key go run ./cmd/server ``` -本地开发需要 Go、Node.js、npm,并确保 `codex` 命令在 `PATH` 中。Vite 默认将 `/api` 和 `/health` 代理到 `http://localhost:8080`。 +本地开发需要 Go、Node.js 和 npm,并需要一个可访问的 CLIProxyAPI 实例。Vite 默认将 `/api` 和 `/health` 代理到 `http://localhost:8080`。 diff --git a/backend/CONTRACT.md b/backend/CONTRACT.md index 9768ef0..b64ced2 100644 --- a/backend/CONTRACT.md +++ b/backend/CONTRACT.md @@ -5,7 +5,7 @@ ## 1. 全局行为 - 默认监听 `${LISTEN_ADDR:-:8080}`,API 前缀为 `/api/v1/`。 -- `GET /health/live` 始终返回 `200 {"status":"ok"}`;`GET /health/ready` 在 SQLite 可用时返回 `200 {"status":"ok","appServer":bool}`,数据库不可用时返回 `503 {"error":string}`。`appServer` 表示至少一个账号的 app-server 已完成初始化。 +- `GET /health/live` 始终返回 `200 {"status":"ok"}`;`GET /health/ready` 在 SQLite 可用时返回 `200 {"status":"ok","cpa":bool}`,数据库不可用时返回 `503 {"error":string}`。`cpa` 表示 `CLIPROXY_API_BASE_URL` 与 `CLIPROXY_API_MANAGEMENT_KEY` 均已配置,不代表每个 authIndex 都可用。 - JSON 请求体最多读取 1 MiB,拒绝未知字段;业务错误统一为 `{"error":string}`。未匹配 API 返回 `404 {"error":"接口不存在"}`。 - 所有响应带 `X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy: same-origin` 和同源 CSP。 - `GET /api/v1/system/status`、`POST /api/v1/setup`、`POST /api/v1/auth/login` 匿名可用;初始化完成后,`GET`/`HEAD /api/v1/accounts` 和 `GET`/`HEAD /api/v1/dashboard` 也提供已标记为公开账号的匿名只读总览。匿名总览会隐藏邮箱、认证方式、账号配置校验和内部错误字段;未公开账号对匿名请求按不存在处理。其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。当前 dispatcher 只对部分路由显式限制 HTTP method;下文使用“任意方法”或“非 `GET`”的地方是对实际兼容行为的记录。 @@ -17,7 +17,7 @@ ### Account ```text -{id, displayName, email:string|null, planType:string|null, +{id, displayName, authIndex?:string, email:string|null, planType:string|null, expectedKind:"any"|"personal"|"team", publicVisible:bool, actualKind:"unknown"|"personal"|"team", validationStatus:"pending"|"matched"|"mismatch"|"unknown", @@ -40,14 +40,14 @@ fetchedAt, stale, lastError?} ``` -时间字段为 Unix 秒。`currentCycle` 是按当前仍有效的最长限额窗口计算的当前重置周期;`totalTokens` 由 `account/usage/read` 的每日 Token bucket 汇总,受每日粒度影响,无法精确切分周期起止日期内的单日数据。`summary.peakDailyTokens` 是同一当前重置周期内每日 Token bucket 的最大值,不再直接采用 app-server 未限定口径的峰值摘要;没有有效窗口或周期内没有每日数据时为 `null`。app-server 未提供的其他摘要字段可以是 `null`;列表应返回数组而非 `null`。 -`resetCredits` 仅在 app-server 返回可用重置卡且 `availableCount > 0` 时出现;`expiresAt` 是已返回卡片的去重到期时间列表,服务端未提供卡片详情时可以为空。公开账号的匿名 Dashboard 也会返回该字段;邮箱、认证方式和内部错误字段仍会隐藏。 +登录态 Dashboard 的 `account.authMode` 在 CPA 同步成功时为 `cliproxyapi`;匿名响应将其隐藏。`/wham/profiles/me` 读取失败时限额同步仍可成功:同一进程内保留上次成功的 summary、usage 和据此重算的 currentCycle;没有旧值时 summary 字段为 `null`、usage 为空数组且 currentCycle 缺省。时间字段为 Unix 秒。`currentCycle` 是按当前仍有效的最长限额窗口计算的当前重置周期;`totalTokens` 由 CPA 代请求 `/wham/profiles/me` 得到的每日 Token bucket 汇总,受每日粒度影响,无法精确切分周期起止日期内的单日数据。`summary.peakDailyTokens` 是同一当前重置周期内每日 Token bucket 的最大值;没有有效窗口或周期内没有每日数据时为 `null`。上游未提供的其他摘要字段可以是 `null`;列表应返回数组而非 `null`。 +`resetCredits` 仅在 `/wham/usage` 或可选详情接口返回可用重置卡且 `availableCount > 0` 时出现;`expiresAt` 是可用卡片的去重到期时间列表,详情接口失败或服务端未提供详情时可以为空。公开账号的匿名 Dashboard 也会返回该字段;邮箱、认证方式和内部错误字段仍会隐藏。 ## 3. 系统、初始化与会话 | 方法与路径 | 鉴权 | 行为 | | --- | --- | --- | -| `GET /api/v1/system/status` | 匿名 | `200 {initialized,version,appServer}`。`version` 为镜像构建时注入的应用版本,未注入时默认为 `0.3.0`。 | +| `GET /api/v1/system/status` | 匿名 | `200 {initialized,version,cpa}`。`cpa` 表示 CPA 环境变量已配置;`version` 为镜像构建时注入的应用版本,未注入时默认为 `0.3.0`。 | | `POST /api/v1/setup` | 匿名、仅未初始化 | body `{username,password,timezone}`;用户名至少 3 位、密码至少 10 位,否则 400;时区有效时写入,否则使用默认 UTC。事务创建唯一管理员和通用设置,设置 session,返回 `201 {ok:true}`;已初始化返回 409。 | | `POST /api/v1/auth/login` | 匿名 | body `{username,password}`;未初始化返回 409,错误凭据返回 401,成功设置 session 并返回 `200 {ok:true}`,限流返回 429。 | | `任意方法 /api/v1/auth/me` | session;非 `GET`/`HEAD` 还需来源头 | `200 {username}`。前端使用 `GET`。 | @@ -59,12 +59,10 @@ | 方法与路径 | 请求与响应 | | --- | --- | -| `GET /api/v1/accounts` | 初始化后匿名可读;匿名只返回 `publicVisible=true` 的账号,按 ID 升序;匿名响应隐藏 `email`、`expectedKind`、`actualKind`、`validationStatus`、`possibleDuplicate` 和创建/更新时间。登录后返回全部账号及完整字段。 | -| `POST /api/v1/accounts` | body `{displayName,expectedKind,publicVisible}`;空名称默认为 `新账号`,空类型默认为 `any`,`publicVisible` 省略时默认为 `false`;成功返回 `201 Account`。 | -| `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?,publicVisible?}`;名称不能为空,省略类型或 `publicVisible` 时分别保留旧值;成功返回 `200 {ok:true}`。 | -| `DELETE /api/v1/accounts/{id}` | 停止该账号进程,删除账号及级联历史,再删除对应凭据目录;成功返回 `200 {ok:true}`。 | -| `POST /api/v1/accounts/{id}/login/device` | 启动并初始化 app-server,调用 `account/login/start` 的 `chatgptDeviceCode` 流程;返回含 `verificationUrl`、`userCode` 和 `loginId` 的结果。 | -| `POST /api/v1/accounts/{id}/logout` | 调用 `account/logout` 并将连接状态置为 false;返回 `200 {ok:true}`。 | +| `GET /api/v1/accounts` | 初始化后匿名可读;匿名只返回 `publicVisible=true` 的账号,按 ID 升序;匿名响应清空 `authIndex`,并隐藏 `email`、`expectedKind`、`actualKind`、`validationStatus`、`possibleDuplicate` 和创建/更新时间。登录后返回全部账号及完整字段。 | +| `POST /api/v1/accounts` | body `{displayName?,authIndex,expectedKind?,publicVisible?}`;`authIndex` 必填、去除首尾空白、在本地唯一,并且必须在 CPA 中唯一匹配未禁用的 Codex auth;CPA transient unavailable/error 不阻止额度读取。空名称优先采用 CPA label/name,否则为 `新账号`,空类型默认为 `any`,`publicVisible` 省略时默认为 `false`。后端在持久化前完成上游验证和快照读取,成功返回 `201 Account`,失败不留下账号。 | +| `PUT /api/v1/accounts/{id}` | body `{displayName,authIndex?,expectedKind?,publicVisible?}`;名称不能为空,省略字段保留旧值。修改 `authIndex` 时先验证 CPA auth 和完整快照,失败保留原绑定;成功时原账号的本地 Token 历史、限额快照和提醒记录被原子清除,避免不同 CPA 身份串数据,并返回 `200 {ok:true}`。 | +| `DELETE /api/v1/accounts/{id}` | 删除 Codex Helper 中的账号绑定及级联历史,不删除 CLIProxyAPI 凭据;成功返回 `200 {ok:true}`。 | | `POST /api/v1/accounts/{id}/sync` | 同步指定账号;成功 `200 {ok:true}`,上游失败 502。 | | `GET`/`HEAD /api/v1/dashboard?accountId={id}` | 初始化后匿名可读公开账号,返回内存中的 `Dashboard`;匿名访问未公开账号返回 404,匿名响应隐藏邮箱、认证方式和内部错误字段,但保留重置卡信息。登录后可读取全部账号。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 | | `任意非读方法 /api/v1/dashboard?accountId={id}` | 要求 session;非 `GET`/`HEAD` 还需来源头。保持兼容的读取行为,省略或无效的零值 ID 使用账号 1。 | @@ -104,8 +102,13 @@ | 方法与路径 | 行为 | | --- | --- | | `POST /api/v1/maintenance/cleanup` | 按当前保留天数删除旧限额快照、通知和每日用量,返回 `200 {deleted}`。 | -| `任意方法 /api/v1/maintenance/backup` | 使用 SQLite `VACUUM INTO` 生成包含已提交 WAL 数据的一致性快照,并以 `codex-helper.db` 下载;非 `GET`/`HEAD` 还需来源头,前端使用 `GET`。快照不含 `/data/secret.key` 或 Codex 凭据目录。 | +| `任意方法 /api/v1/maintenance/backup` | 使用 SQLite `VACUUM INTO` 生成包含已提交 WAL 数据的一致性快照,并以 `codex-helper.db` 下载;非 `GET`/`HEAD` 还需来源头,前端使用 `GET`。快照不含 `/data/secret.key`,也不包含外部 CLIProxyAPI 的 Codex 凭据。 | ## 7. 外部协议边界 -每个账号通过 JSONL stdio 与 `codex app-server` 通信。当前使用的方法为 `initialize`、`account/read`、`account/login/start`、`account/logout`、`account/rateLimits/read` 和 `account/usage/read`,并响应 `account/login/completed`、`account/updated`、`account/rateLimits/updated` 通知。官方协议说明见 [Codex App Server](https://learn.chatgpt.com/docs/app-server);本项目以 Dockerfile 固定的 Codex CLI 版本、当前解析代码和测试作为兼容基线。 +Codex Helper 只通过 CLIProxyAPI Management API 访问 Codex 数据: + +- `GET /v0/management/auth-files?auth_index=...`:验证 authIndex,读取非秘密账号元数据和 ID token claims。 +- `POST /v0/management/api-call`:由 CPA 按 authIndex 注入 OAuth token,代请求 `GET https://chatgpt.com/backend-api/wham/usage`、`GET https://chatgpt.com/backend-api/wham/profiles/me` 和可选的 `GET https://chatgpt.com/backend-api/wham/rate-limit-reset-credits`。 + +CPA base URL 和 management key 只来自服务端环境变量;management key、OAuth token、认证文件和原始上游错误正文不得进入 API 响应或日志。`/wham/*` 是内部接口,本项目以 CPA 协议、当前解析代码和测试作为兼容基线。 diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index 4d895c2..73db748 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "codex-helper/internal/cliproxy" "codex-helper/internal/security" "codex-helper/internal/store" ) @@ -24,23 +25,14 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { jsonOut(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) return } - connected := false - a.mu.RLock() - for _, rt := range a.runtimes { - if rt.Ready() { - connected = true - break - } - } - a.mu.RUnlock() - jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": Version, "appServer": connected}) + jsonOut(w, http.StatusOK, map[string]any{"initialized": a.store.Initialized(), "version": Version, "cpa": a.cpaConfigured()}) return } - if p == "setup" && r.Method == "POST" { + if p == "setup" && r.Method == http.MethodPost { a.setup(w, r) return } - if p == "auth/login" && r.Method == "POST" { + if p == "auth/login" && r.Method == http.MethodPost { a.login(w, r) return } @@ -59,91 +51,64 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { case p == "auth/me": var username string _ = a.store.DB.QueryRow("SELECT username FROM admin WHERE id=1").Scan(&username) - jsonOut(w, 200, map[string]string{"username": username}) - case p == "auth/logout" && r.Method == "POST": + jsonOut(w, http.StatusOK, map[string]string{"username": username}) + case p == "auth/logout" && r.Method == http.MethodPost: a.logout(w, r) - case p == "accounts" && r.Method == "POST": - var in struct { - DisplayName string `json:"displayName"` - ExpectedKind string `json:"expectedKind"` - PublicVisible bool `json:"publicVisible"` - } - if decode(r, &in) != nil { - jsonOut(w, 400, map[string]string{"error": "请求格式错误"}) - break - } - in.DisplayName = strings.TrimSpace(in.DisplayName) - if in.DisplayName == "" { - in.DisplayName = "新账号" - } - if in.ExpectedKind == "" { - in.ExpectedKind = "any" - } - if !store.ValidExpectedKind(in.ExpectedKind) { - jsonOut(w, 400, map[string]string{"error": "连接类型无效"}) - break - } - x, e := a.store.CreateAccountWithVisibility(in.DisplayName, in.ExpectedKind, in.PublicVisible) - if e == nil { - a.addRuntime(x.ID) - jsonOut(w, 201, x) - } else { - jsonOut(w, 500, map[string]string{"error": e.Error()}) - } + case p == "accounts" && r.Method == http.MethodPost: + a.createAccount(w, r) case strings.HasPrefix(p, "accounts/"): a.accountAPI(w, r, p) case p == "dashboard": a.dashboardAPI(w, r) - case p == "sync" && r.Method == "POST": + case p == "sync" && r.Method == http.MethodPost: id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64) if id == 0 { id = 1 } - e := a.syncAccount(r.Context(), id) - if e != nil { - jsonOut(w, 502, map[string]string{"error": e.Error()}) + if err := a.syncAccount(r.Context(), id); err != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) } else { - jsonOut(w, 200, map[string]bool{"ok": true}) + jsonOut(w, http.StatusOK, map[string]bool{"ok": true}) } case p == "settings/general": a.generalAPI(w, r) case p == "settings/smtp": a.smtpAPI(w, r) - case p == "settings/smtp/test" && r.Method == "POST": + case p == "settings/smtp/test" && r.Method == http.MethodPost: a.smtpTest(w, r) case p == "settings/telegram": a.telegramAPI(w, r) - case p == "settings/telegram/test" && r.Method == "POST": + case p == "settings/telegram/test" && r.Method == http.MethodPost: a.telegramTest(w, r) - case p == "settings/telegram/bind" && r.Method == "POST": + case p == "settings/telegram/bind" && r.Method == http.MethodPost: a.telegramMu.Lock() code := fmt.Sprintf("%06d", time.Now().UnixNano()%1000000) _ = a.store.SetJSON("telegram_bind", map[string]any{"code": code, "expires": time.Now().Add(10 * time.Minute).Unix()}) a.telegramMu.Unlock() - jsonOut(w, 200, map[string]string{"code": code}) - case p == "maintenance/cleanup" && r.Method == "POST": - n, e := a.store.Cleanup(a.general().RetentionDays) - if e != nil { - jsonOut(w, 500, map[string]string{"error": e.Error()}) + jsonOut(w, http.StatusOK, map[string]string{"code": code}) + case p == "maintenance/cleanup" && r.Method == http.MethodPost: + n, err := a.store.Cleanup(a.general().RetentionDays) + if err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) } else { - jsonOut(w, 200, map[string]int64{"deleted": n}) + jsonOut(w, http.StatusOK, map[string]int64{"deleted": n}) } case p == "maintenance/backup": - dir, e := os.MkdirTemp(a.dataDir, "backup-") - if e != nil { - jsonOut(w, 500, map[string]string{"error": e.Error()}) + dir, err := os.MkdirTemp(a.dataDir, "backup-") + if err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } defer os.RemoveAll(dir) path := filepath.Join(dir, "codex-helper.db") - if e = a.store.Backup(r.Context(), path); e != nil { - jsonOut(w, 500, map[string]string{"error": e.Error()}) + if err = a.store.Backup(r.Context(), path); err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.Header().Set("Content-Disposition", `attachment; filename="codex-helper.db"`) http.ServeFile(w, r, path) default: - jsonOut(w, 404, map[string]string{"error": "接口不存在"}) + jsonOut(w, http.StatusNotFound, map[string]string{"error": "接口不存在"}) } } @@ -156,26 +121,26 @@ func (a *App) accountsAPI(w http.ResponseWriter, r *http.Request) { jsonOut(w, http.StatusConflict, map[string]string{"error": "请先初始化"}) return } - x, e := a.store.Accounts() - if e != nil { - jsonOut(w, http.StatusInternalServerError, map[string]string{"error": e.Error()}) + accounts, err := a.store.Accounts() + if err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !a.authed(r) { - visible := make([]store.Account, 0, len(x)) - for _, account := range x { - if !account.PublicVisible { - continue + visible := make([]store.Account, 0, len(accounts)) + for _, account := range accounts { + if account.PublicVisible { + visible = append(visible, publicAccount(account)) } - visible = append(visible, publicAccount(account)) } - x = visible + accounts = visible } - jsonOut(w, http.StatusOK, x) + jsonOut(w, http.StatusOK, accounts) } func publicAccount(account store.Account) store.Account { account.Email = nil + account.AuthIndex = "" account.ExpectedKind = "any" account.ActualKind = "unknown" account.ValidationStatus = "unknown" @@ -194,12 +159,12 @@ func (a *App) dashboardAPI(w http.ResponseWriter, r *http.Request) { if id == 0 { id = 1 } - account, accountErr := a.store.Account(id) - if accountErr != nil { - if accountErr == sql.ErrNoRows { + account, err := a.store.Account(id) + if err != nil { + if err == sql.ErrNoRows { jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"}) } else { - jsonOut(w, http.StatusInternalServerError, map[string]string{"error": accountErr.Error()}) + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return } @@ -213,18 +178,18 @@ func (a *App) dashboardAPI(w http.ResponseWriter, r *http.Request) { return } rt.syncing.Lock() - d := rt.dash + dashboard := rt.dash rt.syncing.Unlock() - if d.Limits == nil { - d.Limits = []LimitBucket{} + if dashboard.Limits == nil { + dashboard.Limits = []LimitBucket{} } - if d.Usage == nil { - d.Usage = []UsagePoint{} + if dashboard.Usage == nil { + dashboard.Usage = []UsagePoint{} } if !a.authed(r) { - d = publicDashboard(d) + dashboard = publicDashboard(dashboard) } - jsonOut(w, http.StatusOK, d) + jsonOut(w, http.StatusOK, dashboard) } func publicDashboard(d Dashboard) Dashboard { @@ -234,20 +199,109 @@ func publicDashboard(d Dashboard) Dashboard { return d } +func (a *App) createAccount(w http.ResponseWriter, r *http.Request) { + var in struct { + DisplayName string `json:"displayName"` + AuthIndex string `json:"authIndex"` + ExpectedKind string `json:"expectedKind"` + PublicVisible bool `json:"publicVisible"` + } + if decode(r, &in) != nil { + jsonOut(w, http.StatusBadRequest, map[string]string{"error": "请求格式错误"}) + return + } + in.AuthIndex = strings.TrimSpace(in.AuthIndex) + if in.AuthIndex == "" { + jsonOut(w, http.StatusBadRequest, map[string]string{"error": "authIndex 不能为空"}) + return + } + if in.ExpectedKind == "" { + in.ExpectedKind = "any" + } + if !store.ValidExpectedKind(in.ExpectedKind) { + jsonOut(w, http.StatusBadRequest, map[string]string{"error": "连接类型无效"}) + return + } + if used, err := a.store.AuthIndexUsed(in.AuthIndex, 0); err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } else if used { + jsonOut(w, http.StatusConflict, map[string]string{"error": "authIndex 已绑定"}) + return + } + snapshot, err := a.fetchSnapshot(r.Context(), in.AuthIndex) + if err != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + name := strings.TrimSpace(in.DisplayName) + if name == "" { + name = strings.TrimSpace(snapshot.Auth.Label) + } + if name == "" { + name = strings.TrimSpace(snapshot.Auth.Name) + } + if name == "" { + name = "新账号" + } + account, err := a.store.CreateAccountWithVisibility(name, in.AuthIndex, in.ExpectedKind, in.PublicVisible) + if err != nil { + if authIndexConflict(err) { + jsonOut(w, http.StatusConflict, map[string]string{"error": "authIndex 已绑定"}) + } else { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return + } + a.addRuntime(account.ID) + rt := a.runtime(account.ID) + rt.syncing.Lock() + dashboard := dashboardFromSnapshot(account, snapshot) + _, err = a.persistDashboard(dashboard) + if err == nil { + rt.dash = dashboard + } + rt.syncing.Unlock() + if err != nil { + a.mu.Lock() + delete(a.runtimes, account.ID) + a.mu.Unlock() + if rollbackErr := a.store.DeleteAccount(account.ID); rollbackErr != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": "账号创建失败且本地回滚失败"}) + return + } + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + created, err := a.store.Account(account.ID) + if err != nil { + a.mu.Lock() + delete(a.runtimes, account.ID) + a.mu.Unlock() + if rollbackErr := a.store.DeleteAccount(account.ID); rollbackErr != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": "账号创建失败且本地回滚失败"}) + return + } + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, http.StatusCreated, created) +} + func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) { parts := strings.Split(p, "/") if len(parts) < 2 { - jsonOut(w, 404, map[string]string{"error": "接口不存在"}) + jsonOut(w, http.StatusNotFound, map[string]string{"error": "接口不存在"}) return } - id, e := strconv.ParseInt(parts[1], 10, 64) - if e != nil { - jsonOut(w, 400, map[string]string{"error": "账号 ID 无效"}) + id, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + jsonOut(w, http.StatusBadRequest, map[string]string{"error": "账号 ID 无效"}) return } rt := a.runtime(id) if rt == nil { - jsonOut(w, 404, map[string]string{"error": "账号不存在"}) + jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"}) return } action := "" @@ -255,302 +309,450 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) { action = parts[2] } switch { - case action == "" && r.Method == "PUT": - var in struct { - DisplayName string `json:"displayName"` - ExpectedKind string `json:"expectedKind"` - PublicVisible *bool `json:"publicVisible"` - } - if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" { - jsonOut(w, 400, map[string]string{"error": "名称不能为空"}) - return - } - if in.ExpectedKind == "" { - accounts, _ := a.store.Accounts() - for _, account := range accounts { - if account.ID == id { - in.ExpectedKind = account.ExpectedKind - break - } - } - } - if !store.ValidExpectedKind(in.ExpectedKind) { - jsonOut(w, 400, map[string]string{"error": "连接类型无效"}) - return - } - e = a.store.UpdateAccountSettingsWithVisibility(id, strings.TrimSpace(in.DisplayName), in.ExpectedKind, in.PublicVisible) - if e == nil { - jsonOut(w, 200, map[string]bool{"ok": true}) - } - case action == "" && r.Method == "DELETE": + case action == "" && r.Method == http.MethodPut: + a.updateAccount(w, r, id, rt) + case action == "" && r.Method == http.MethodDelete: + // Match the reminder -> global-runtime -> account-runtime lock order + // so no queued reminder for the deleted binding can be sent later. + a.reminderMu.Lock() + defer a.reminderMu.Unlock() a.mu.Lock() - delete(a.runtimes, id) + rt.syncing.Lock() + err := a.store.DeleteAccount(id) + if err == nil { + delete(a.runtimes, id) + } + rt.syncing.Unlock() a.mu.Unlock() - rt.stop() - e = a.store.DeleteAccount(id) - if e == nil { - dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10)) - if id == 1 { - dir = filepath.Join(a.dataDir, "codex") - } - e = os.RemoveAll(dir) + if err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return } - if e == nil { - jsonOut(w, 200, map[string]bool{"ok": true}) - } - case action == "login" && len(parts) > 3 && parts[3] == "device" && r.Method == "POST": - a.deviceLogin(w, r, id) - case action == "logout" && r.Method == "POST": - var out any - e = rt.ensureReady(r.Context()) - if e == nil { - e = rt.client.Call(r.Context(), "account/logout", map[string]any{}, &out) - } - if e == nil { - _ = a.store.UpdateAccount(id, nil, nil, false) - jsonOut(w, 200, map[string]bool{"ok": true}) - } - case action == "sync" && r.Method == "POST": - e = a.syncAccount(r.Context(), id) - if e == nil { - jsonOut(w, 200, map[string]bool{"ok": true}) + jsonOut(w, http.StatusOK, map[string]bool{"ok": true}) + case action == "sync" && r.Method == http.MethodPost: + if err := a.syncAccount(r.Context(), id); err != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return } + jsonOut(w, http.StatusOK, map[string]bool{"ok": true}) default: - jsonOut(w, 404, map[string]string{"error": "接口不存在"}) + jsonOut(w, http.StatusNotFound, map[string]string{"error": "接口不存在"}) + } +} + +func (a *App) updateAccount(w http.ResponseWriter, r *http.Request, id int64, rt *accountRuntime) { + var in struct { + DisplayName string `json:"displayName"` + AuthIndex *string `json:"authIndex"` + ExpectedKind string `json:"expectedKind"` + PublicVisible *bool `json:"publicVisible"` + } + if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" { + jsonOut(w, http.StatusBadRequest, map[string]string{"error": "名称不能为空"}) return } - if e != nil { - jsonOut(w, 502, map[string]string{"error": e.Error()}) + account, err := a.store.Account(id) + if err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return } + if in.ExpectedKind == "" { + in.ExpectedKind = account.ExpectedKind + } + if !store.ValidExpectedKind(in.ExpectedKind) { + jsonOut(w, http.StatusBadRequest, map[string]string{"error": "连接类型无效"}) + return + } + name := strings.TrimSpace(in.DisplayName) + if in.AuthIndex == nil || strings.TrimSpace(*in.AuthIndex) == account.AuthIndex { + if err = a.store.UpdateAccountSettingsWithVisibility(id, name, in.ExpectedKind, in.PublicVisible); err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + rt.syncing.Lock() + rt.dash.DisplayName = name + rt.syncing.Unlock() + jsonOut(w, http.StatusOK, map[string]bool{"ok": true}) + return + } + newAuthIndex := strings.TrimSpace(*in.AuthIndex) + if newAuthIndex == "" { + jsonOut(w, http.StatusBadRequest, map[string]string{"error": "authIndex 不能为空"}) + return + } + if used, checkErr := a.store.AuthIndexUsed(newAuthIndex, id); checkErr != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": checkErr.Error()}) + return + } else if used { + jsonOut(w, http.StatusConflict, map[string]string{"error": "authIndex 已绑定"}) + return + } + snapshot, err := a.fetchSnapshot(r.Context(), newAuthIndex) + if err != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + updatedAccount := account + updatedAccount.DisplayName = name + updatedAccount.AuthIndex = newAuthIndex + updatedAccount.ExpectedKind = in.ExpectedKind + if in.PublicVisible != nil { + updatedAccount.PublicVisible = *in.PublicVisible + } + dashboard := dashboardFromSnapshot(updatedAccount, snapshot) + // Finish any old-identity reminder work before replacing the binding and + // deleting its dedupe records. + a.reminderMu.Lock() + defer a.reminderMu.Unlock() + rt.syncing.Lock() + defer rt.syncing.Unlock() + tx, err := a.store.DB.BeginTx(r.Context(), nil) + if err == nil { + var result sql.Result + if in.PublicVisible == nil { + result, err = tx.Exec("UPDATE accounts SET display_name=?,auth_index=?,expected_kind=?,updated_at=? WHERE id=?", name, newAuthIndex, in.ExpectedKind, time.Now().Unix(), id) + } else { + result, err = tx.Exec("UPDATE accounts SET display_name=?,auth_index=?,expected_kind=?,public_visible=?,updated_at=? WHERE id=?", name, newAuthIndex, in.ExpectedKind, *in.PublicVisible, time.Now().Unix(), id) + } + if err == nil { + var affected int64 + affected, err = result.RowsAffected() + if err == nil && affected == 0 { + err = sql.ErrNoRows + } + } + } + resetDetected := false + if err == nil { + _, err = tx.Exec("DELETE FROM daily_usage WHERE account_id=?", id) + } + if err == nil { + _, err = tx.Exec("DELETE FROM limit_snapshots WHERE account_id=?", id) + } + if err == nil { + _, err = tx.Exec("DELETE FROM notifications WHERE dedupe_key GLOB ?", strconv.FormatInt(id, 10)+":*") + } + if err == nil { + resetDetected, err = a.persistDashboardTx(tx, dashboard) + } + if err == nil { + err = tx.Commit() + } else if tx != nil { + _ = tx.Rollback() + } + if err != nil { + if authIndexConflict(err) { + jsonOut(w, http.StatusConflict, map[string]string{"error": "authIndex 已绑定"}) + } else { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return + } + rt.dash = dashboard + if resetDetected { + go a.processReminders() + } + jsonOut(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func authIndexConflict(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "unique") && strings.Contains(message, "accounts.auth_index") } func (a *App) setup(w http.ResponseWriter, r *http.Request) { if a.store.Initialized() { - jsonOut(w, 409, map[string]string{"error": "系统已初始化"}) + jsonOut(w, http.StatusConflict, map[string]string{"error": "系统已初始化"}) return } var in struct{ Username, Password, Timezone string } if decode(r, &in) != nil || len(in.Username) < 3 || len(in.Password) < 10 { - jsonOut(w, 400, map[string]string{"error": "用户名至少3位,密码至少10位"}) + jsonOut(w, http.StatusBadRequest, map[string]string{"error": "用户名至少3位,密码至少10位"}) return } - tx, e := a.store.DB.Begin() - if e != nil { - jsonOut(w, 500, map[string]string{"error": e.Error()}) + tx, err := a.store.DB.Begin() + if err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } defer tx.Rollback() - _, e = tx.Exec("INSERT INTO admin(id,username,password_hash,created_at) VALUES(1,?,?,?)", in.Username, security.Password(in.Password), time.Now().Unix()) - if e == nil { + _, err = tx.Exec("INSERT INTO admin(id,username,password_hash,created_at) VALUES(1,?,?,?)", in.Username, security.Password(in.Password), time.Now().Unix()) + if err == nil { g := defaults() if in.Timezone != "" { - if _, z := time.LoadLocation(in.Timezone); z == nil { + if _, zoneErr := time.LoadLocation(in.Timezone); zoneErr == nil { g.Timezone = in.Timezone } } b, _ := json.Marshal(g) - _, e = tx.Exec("INSERT INTO settings(key,value,updated_at) VALUES('general',?,?),('initialized','true',?)", string(b), time.Now().Unix(), time.Now().Unix()) + _, err = tx.Exec("INSERT INTO settings(key,value,updated_at) VALUES('general',?,?),('initialized','true',?)", string(b), time.Now().Unix(), time.Now().Unix()) } - if e == nil { - e = tx.Commit() + if err == nil { + err = tx.Commit() } - if e != nil { - jsonOut(w, 500, map[string]string{"error": e.Error()}) + if err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } a.newSession(w, in.Username) - jsonOut(w, 201, map[string]bool{"ok": true}) + jsonOut(w, http.StatusCreated, map[string]bool{"ok": true}) } + func (a *App) login(w http.ResponseWriter, r *http.Request) { if !a.store.Initialized() { - jsonOut(w, 409, map[string]string{"error": "请先初始化"}) + jsonOut(w, http.StatusConflict, map[string]string{"error": "请先初始化"}) return } ip := r.RemoteAddr - v, _ := a.loginAttempts.LoadOrStore(ip, []time.Time{}) - xs := v.([]time.Time) + value, _ := a.loginAttempts.LoadOrStore(ip, []time.Time{}) + attempts := value.([]time.Time) now := time.Now() - fresh := xs[:0] - for _, x := range xs { - if now.Sub(x) < 15*time.Minute { - fresh = append(fresh, x) + fresh := attempts[:0] + for _, attempt := range attempts { + if now.Sub(attempt) < 15*time.Minute { + fresh = append(fresh, attempt) } } if len(fresh) >= 10 { - jsonOut(w, 429, map[string]string{"error": "尝试次数过多,请稍后再试"}) + jsonOut(w, http.StatusTooManyRequests, map[string]string{"error": "尝试次数过多,请稍后再试"}) return } var in struct{ Username, Password string } _ = decode(r, &in) var user, hash string - e := a.store.DB.QueryRow("SELECT username,password_hash FROM admin WHERE id=1").Scan(&user, &hash) - if e != nil || user != in.Username || !security.VerifyPassword(hash, in.Password) { + err := a.store.DB.QueryRow("SELECT username,password_hash FROM admin WHERE id=1").Scan(&user, &hash) + if err != nil || user != in.Username || !security.VerifyPassword(hash, in.Password) { a.loginAttempts.Store(ip, append(fresh, now)) - jsonOut(w, 401, map[string]string{"error": "用户名或密码错误"}) + jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "用户名或密码错误"}) return } a.loginAttempts.Delete(ip) a.newSession(w, user) - jsonOut(w, 200, map[string]bool{"ok": true}) + jsonOut(w, http.StatusOK, map[string]bool{"ok": true}) } + func (a *App) newSession(w http.ResponseWriter, _ string) { - tok := security.Random(32) + token := security.Random(32) _, _ = a.store.DB.Exec("DELETE FROM sessions WHERE expires_at 60 || g.RetentionDays < 30 || g.RetentionDays > 365 || g.BeforeMinutes < 1 || g.BeforeMinutes > 1440 { - jsonOut(w, 400, map[string]string{"error": "设置值不合法"}) + jsonOut(w, http.StatusBadRequest, map[string]string{"error": "设置值不合法"}) return } - if _, e := time.LoadLocation(g.Timezone); e != nil { - jsonOut(w, 400, map[string]string{"error": "无效时区"}) + if _, err := time.LoadLocation(g.Timezone); err != nil { + jsonOut(w, http.StatusBadRequest, map[string]string{"error": "无效时区"}) return } g.SyncMinutes = automaticSyncMinutes _ = a.store.SetJSON("general", g) - jsonOut(w, 200, g) + jsonOut(w, http.StatusOK, g) } -func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request, id int64) { - var out map[string]any - rt := a.runtime(id) - if rt == nil { - jsonOut(w, 404, map[string]string{"error": "账号不存在"}) - return + +func (a *App) fetchSnapshot(ctx context.Context, authIndex string) (cliproxy.Snapshot, error) { + if !a.cpaConfigured() { + return cliproxy.Snapshot{}, cliproxy.ErrNotConfigured } - e := rt.ensureReady(r.Context()) - if e == nil { - e = rt.client.Call(r.Context(), "account/login/start", map[string]any{"type": "chatgptDeviceCode"}, &out) - } - if e != nil { - jsonOut(w, 502, map[string]string{"error": e.Error()}) - return - } - jsonOut(w, 200, out) + return a.cpa.Snapshot(ctx, authIndex) } func (a *App) syncAccount(ctx context.Context, id int64) error { rt := a.runtime(id) if rt == nil { - return fmt.Errorf("账号不存在") + return errorsForSync("账号不存在") } rt.syncing.Lock() defer rt.syncing.Unlock() - if err := rt.ensureReady(ctx); err != nil { - return err + account, err := a.store.Account(id) + if err != nil { + return a.markSyncFailure(rt, id, "账号不存在") } - ctx, c := context.WithTimeout(ctx, 20*time.Second) - defer c() - var ar struct { - Account *struct { - Type string `json:"type"` - Email *string `json:"email"` - PlanType *string `json:"planType"` - } `json:"account"` + if strings.TrimSpace(account.AuthIndex) == "" { + return a.markSyncFailure(rt, id, "账号尚未绑定 authIndex") } - if e := rt.client.Call(ctx, "account/read", map[string]any{"refreshToken": false}, &ar); e != nil { - return e + snapshot, err := a.fetchSnapshot(ctx, account.AuthIndex) + if err != nil { + return a.markSyncFailure(rt, id, err.Error()) } - name := "账号" - accounts, _ := a.store.Accounts() - for _, x := range accounts { - if x.ID == id { - name = x.DisplayName - break - } + dashboard := dashboardFromSnapshot(account, snapshot) + mergeProfileData(&dashboard, rt.dash, snapshot.ProfileAvailable, snapshot.UsageAvailable) + resetDetected, err := a.persistDashboard(dashboard) + if err != nil { + return a.markSyncFailure(rt, id, err.Error()) } - d := Dashboard{AccountID: id, DisplayName: name, FetchedAt: time.Now().Unix(), Account: AccountView{Connected: ar.Account != nil}, Limits: []LimitBucket{}, Usage: []UsagePoint{}} - if ar.Account != nil { - d.Account.Email = ar.Account.Email - d.Account.PlanType = ar.Account.PlanType - d.Account.AuthMode = &ar.Account.Type - } - var lr struct { - RateLimits *rawLimit `json:"rateLimits"` - By map[string]rawLimit `json:"rateLimitsByLimitId"` - ResetCredits *rawResetCredits `json:"rateLimitResetCredits"` - } - if e := rt.client.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil { - d.ResetCredits = normalizeResetCredits(lr.ResetCredits) - if len(lr.By) > 0 { - for _, x := range lr.By { - d.Limits = append(d.Limits, flattenLimit(x)...) - } - } else if lr.RateLimits != nil { - d.Limits = flattenLimit(*lr.RateLimits) - } - } - // Some app-server versions expose the workspace plan only on limit buckets. - if ar.Account != nil && store.AccountKind(d.Account.PlanType) == "unknown" { - var fallback *string - consistent := true - for _, x := range d.Limits { - if store.AccountKind(x.PlanType) == "unknown" { - continue - } - if fallback == nil { - fallback = x.PlanType - } else if store.AccountKind(fallback) != store.AccountKind(x.PlanType) { - consistent = false - } - } - if consistent && fallback != nil { - d.Account.PlanType = fallback - } - } - var ur struct { - Summary UsageSummary `json:"summary"` - Daily []struct { - StartDate string `json:"startDate"` - Tokens int64 `json:"tokens"` - } `json:"dailyUsageBuckets"` - } - if e := rt.client.Call(ctx, "account/usage/read", map[string]any{}, &ur); e == nil { - d.Summary = ur.Summary - for _, x := range ur.Daily { - p := UsagePoint{Date: x.StartDate, TotalTokens: x.Tokens} - d.Usage = append(d.Usage, p) - _, _ = a.store.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?) ON CONFLICT(account_id,date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", id, x.StartDate, x.Tokens, d.FetchedAt) - } - d.CurrentCycle = currentTokenCycle(d.Limits, d.Usage, d.FetchedAt) - d.Summary.PeakDailyTokens = peakDailyTokensForCycle(d.CurrentCycle, d.Usage, d.FetchedAt) - } - resetDetected, e := a.storeLimitSnapshots(d) - if e != nil { - return e - } - rt.dash = d - _ = a.store.UpdateAccount(id, d.Account.Email, d.Account.PlanType, d.Account.Connected) + rt.dash = dashboard if resetDetected { go a.processReminders() } return nil } +func errorsForSync(message string) error { + return fmt.Errorf("%s", message) +} + +func (a *App) markSyncFailure(rt *accountRuntime, id int64, message string) error { + if rt.dash.AccountID == 0 { + rt.dash.AccountID = id + } + if rt.dash.Limits == nil { + rt.dash.Limits = []LimitBucket{} + } + if rt.dash.Usage == nil { + rt.dash.Usage = []UsagePoint{} + } + rt.dash.Stale = true + rt.dash.LastError = message + return errorsForSync(message) +} + +func mergeProfileData(current *Dashboard, previous Dashboard, profileAvailable, usageAvailable bool) { + if !profileAvailable { + current.Summary = previous.Summary + } else { + if current.Summary.LifetimeTokens == nil { + current.Summary.LifetimeTokens = previous.Summary.LifetimeTokens + } + if current.Summary.LongestRunningTurnSec == nil { + current.Summary.LongestRunningTurnSec = previous.Summary.LongestRunningTurnSec + } + if current.Summary.CurrentStreakDays == nil { + current.Summary.CurrentStreakDays = previous.Summary.CurrentStreakDays + } + if current.Summary.LongestStreakDays == nil { + current.Summary.LongestStreakDays = previous.Summary.LongestStreakDays + } + } + if !usageAvailable { + current.Usage = append([]UsagePoint(nil), previous.Usage...) + if current.Usage == nil { + current.Usage = []UsagePoint{} + } + } + current.Summary.PeakDailyTokens = nil + if usageAvailable || len(current.Usage) > 0 { + current.CurrentCycle = currentTokenCycle(current.Limits, current.Usage, current.FetchedAt) + current.Summary.PeakDailyTokens = peakDailyTokensForCycle(current.CurrentCycle, current.Usage, current.FetchedAt) + } +} + +func dashboardFromSnapshot(account store.Account, snapshot cliproxy.Snapshot) Dashboard { + authMode := "cliproxyapi" + dashboard := Dashboard{ + AccountID: account.ID, + DisplayName: account.DisplayName, + Account: AccountView{ + Email: snapshot.Auth.Email, + AuthMode: &authMode, + PlanType: snapshot.Auth.PlanType, + Connected: true, + }, + Limits: make([]LimitBucket, 0, len(snapshot.Limits)), + Usage: make([]UsagePoint, 0, len(snapshot.Usage)), + FetchedAt: snapshot.FetchedAt.Unix(), + Stale: false, + } + for _, limit := range snapshot.Limits { + dashboard.Limits = append(dashboard.Limits, LimitBucket{ + LimitID: limit.LimitID, + LimitName: limit.LimitName, + WindowType: limit.WindowType, + UsedPercent: limit.UsedPercent, + WindowDurationMinutes: limit.WindowDurationMinutes, + ResetsAt: limit.ResetsAt, + PlanType: limit.PlanType, + }) + } + dashboard.Summary = UsageSummary{ + LifetimeTokens: snapshot.Summary.LifetimeTokens, + PeakDailyTokens: snapshot.Summary.PeakDailyTokens, + LongestRunningTurnSec: snapshot.Summary.LongestRunningTurnSec, + CurrentStreakDays: snapshot.Summary.CurrentStreakDays, + LongestStreakDays: snapshot.Summary.LongestStreakDays, + } + for _, point := range snapshot.Usage { + dashboard.Usage = append(dashboard.Usage, UsagePoint{Date: point.Date, TotalTokens: point.TotalTokens}) + } + dashboard.Summary.PeakDailyTokens = nil + if snapshot.UsageAvailable { + dashboard.CurrentCycle = currentTokenCycle(dashboard.Limits, dashboard.Usage, dashboard.FetchedAt) + dashboard.Summary.PeakDailyTokens = peakDailyTokensForCycle(dashboard.CurrentCycle, dashboard.Usage, dashboard.FetchedAt) + } + if snapshot.ResetCredits != nil && snapshot.ResetCredits.AvailableCount > 0 { + expiresAt := append([]int64(nil), snapshot.ResetCredits.ExpiresAt...) + sort.Slice(expiresAt, func(i, j int) bool { return expiresAt[i] < expiresAt[j] }) + dashboard.ResetCredits = &ResetCreditsSummary{AvailableCount: snapshot.ResetCredits.AvailableCount, ExpiresAt: expiresAt} + } + return dashboard +} + +func (a *App) persistDashboard(d Dashboard) (bool, error) { + tx, err := a.store.DB.Begin() + if err != nil { + return false, err + } + resetDetected, err := a.persistDashboardTx(tx, d) + if err != nil { + _ = tx.Rollback() + return false, err + } + if err = tx.Commit(); err != nil { + return false, err + } + return resetDetected, nil +} + +func (a *App) persistDashboardTx(tx *sql.Tx, d Dashboard) (bool, error) { + for _, point := range d.Usage { + if _, err := tx.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?) ON CONFLICT(account_id,date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", d.AccountID, point.Date, point.TotalTokens, d.FetchedAt); err != nil { + return false, err + } + } + resetDetected, err := a.storeLimitSnapshotsTx(tx, d) + if err != nil { + return false, err + } + result, err := tx.Exec("UPDATE accounts SET email=?,plan_type=?,connected=1,updated_at=? WHERE id=?", d.Account.Email, d.Account.PlanType, time.Now().Unix(), d.AccountID) + if err != nil { + return false, err + } + if affected, affectedErr := result.RowsAffected(); affectedErr != nil { + return false, affectedErr + } else if affected == 0 { + return false, sql.ErrNoRows + } + return resetDetected, nil +} + const resetDropTolerance = 0.01 func (a *App) storeLimitSnapshots(d Dashboard) (bool, error) { @@ -558,41 +760,10 @@ func (a *App) storeLimitSnapshots(d Dashboard) (bool, error) { if err != nil { return false, err } - defer tx.Rollback() - g := a.general() - resetDetected := false - for _, x := range d.Limits { - var previousID, previousFetchedAt, previousResetsAt int64 - var previousUsed float64 - err = tx.QueryRow(`SELECT id,used_percent,resets_at,fetched_at FROM limit_snapshots - WHERE account_id=? AND limit_id=? AND window_type=? ORDER BY fetched_at DESC,id DESC LIMIT 1`, - d.AccountID, x.LimitID, x.WindowType).Scan(&previousID, &previousUsed, &previousResetsAt, &previousFetchedAt) - if err != nil && err != sql.ErrNoRows { - return false, err - } - age := d.FetchedAt - previousFetchedAt - if err == nil && g.NotifyAfter && age >= 0 && age <= int64((6*time.Hour).Seconds()) && previousUsed-x.UsedPercent > resetDropTolerance { - kind := "detected_after" - key := fmt.Sprintf("%d:%s:%s:detected:%d", d.AccountID, x.LimitID, x.WindowType, previousID) - now := time.Unix(d.FetchedAt, 0) - if previousResetsAt <= d.FetchedAt && now.Sub(time.Unix(previousResetsAt, 0)) <= 6*time.Hour { - kind = "after" - key = fmt.Sprintf("%d:%s:%s:%d:after", d.AccountID, x.LimitID, x.WindowType, previousResetsAt) - } - event := notificationEvent{Version: 1, Kind: kind, Account: d.DisplayName, DurationMins: x.WindowDurationMinutes, - Remaining: 100 - x.UsedPercent, PreviousUsed: previousUsed, Used: x.UsedPercent, ResetsAt: x.ResetsAt} - body, _ := json.Marshal(event) - _, err = tx.Exec(`INSERT OR IGNORE INTO notifications - (dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at,body) - VALUES(?,?,?,'pending',0,'',?,NULL,?)`, key, "configured", kind, d.FetchedAt, string(body)) - if err != nil { - return false, err - } - resetDetected = true - } - if _, err = tx.Exec("INSERT INTO limit_snapshots(limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at,account_id) VALUES(?,?,?,?,?,?,?)", x.LimitID, x.WindowType, x.UsedPercent, x.WindowDurationMinutes, x.ResetsAt, d.FetchedAt, d.AccountID); err != nil { - return false, err - } + resetDetected, err := a.storeLimitSnapshotsTx(tx, d) + if err != nil { + _ = tx.Rollback() + return false, err } if err = tx.Commit(); err != nil { return false, err @@ -600,41 +771,42 @@ func (a *App) storeLimitSnapshots(d Dashboard) (bool, error) { return resetDetected, nil } -type rawLimit struct { - LimitID string `json:"limitId"` - LimitName *string `json:"limitName"` - PlanType *string `json:"planType"` - Primary *LimitWindow `json:"primary"` - Secondary *LimitWindow `json:"secondary"` -} - -type rawResetCredits struct { - AvailableCount int `json:"availableCount"` - Credits []rawResetCredit `json:"credits"` -} - -type rawResetCredit struct { - ExpiresAt *int64 `json:"expiresAt"` -} - -func normalizeResetCredits(raw *rawResetCredits) *ResetCreditsSummary { - if raw == nil || raw.AvailableCount <= 0 { - return nil - } - expiresAt := make([]int64, 0, len(raw.Credits)) - for _, credit := range raw.Credits { - if credit.ExpiresAt != nil && *credit.ExpiresAt > 0 { - expiresAt = append(expiresAt, *credit.ExpiresAt) +func (a *App) storeLimitSnapshotsTx(tx *sql.Tx, d Dashboard) (bool, error) { + g := a.general() + resetDetected := false + for _, limit := range d.Limits { + var previousID, previousFetchedAt, previousResetsAt int64 + var previousUsed float64 + err := tx.QueryRow(`SELECT id,used_percent,resets_at,fetched_at FROM limit_snapshots + WHERE account_id=? AND limit_id=? AND window_type=? ORDER BY fetched_at DESC,id DESC LIMIT 1`, + d.AccountID, limit.LimitID, limit.WindowType).Scan(&previousID, &previousUsed, &previousResetsAt, &previousFetchedAt) + if err != nil && err != sql.ErrNoRows { + return false, err + } + age := d.FetchedAt - previousFetchedAt + if err == nil && g.NotifyAfter && age >= 0 && age <= int64((6*time.Hour).Seconds()) && previousUsed-limit.UsedPercent > resetDropTolerance { + kind := "detected_after" + key := fmt.Sprintf("%d:%s:%s:detected:%d", d.AccountID, limit.LimitID, limit.WindowType, previousID) + now := time.Unix(d.FetchedAt, 0) + if previousResetsAt <= d.FetchedAt && now.Sub(time.Unix(previousResetsAt, 0)) <= 6*time.Hour { + kind = "after" + key = fmt.Sprintf("%d:%s:%s:%d:after", d.AccountID, limit.LimitID, limit.WindowType, previousResetsAt) + } + event := notificationEvent{Version: 1, Kind: kind, Account: d.DisplayName, DurationMins: limit.WindowDurationMinutes, + Remaining: 100 - limit.UsedPercent, PreviousUsed: previousUsed, Used: limit.UsedPercent, ResetsAt: limit.ResetsAt} + body, _ := json.Marshal(event) + if _, err = tx.Exec(`INSERT OR IGNORE INTO notifications + (dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at,body) + VALUES(?,?,?,'pending',0,'',?,NULL,?)`, key, "configured", kind, d.FetchedAt, string(body)); err != nil { + return false, err + } + resetDetected = true + } + if _, err = tx.Exec("INSERT INTO limit_snapshots(limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at,account_id) VALUES(?,?,?,?,?,?,?)", limit.LimitID, limit.WindowType, limit.UsedPercent, limit.WindowDurationMinutes, limit.ResetsAt, d.FetchedAt, d.AccountID); err != nil { + return false, err } } - sort.Slice(expiresAt, func(i, j int) bool { return expiresAt[i] < expiresAt[j] }) - uniqueExpiresAt := expiresAt[:0] - for _, value := range expiresAt { - if len(uniqueExpiresAt) == 0 || uniqueExpiresAt[len(uniqueExpiresAt)-1] != value { - uniqueExpiresAt = append(uniqueExpiresAt, value) - } - } - return &ResetCreditsSummary{AvailableCount: raw.AvailableCount, ExpiresAt: uniqueExpiresAt} + return resetDetected, nil } func currentTokenCycle(limits []LimitBucket, usage []UsagePoint, fetchedAt int64) *TokenCycle { @@ -652,7 +824,6 @@ func currentTokenCycle(limits []LimitBucket, usage []UsagePoint, fetchedAt int64 if !found { return nil } - startedAt := current.ResetsAt - int64(current.WindowDurationMinutes)*60 startDate := time.Unix(startedAt, 0).UTC().Format("2006-01-02") endDate := time.Unix(fetchedAt, 0).UTC().Format("2006-01-02") @@ -666,14 +837,7 @@ func currentTokenCycle(limits []LimitBucket, usage []UsagePoint, fetchedAt int64 } total += point.TotalTokens } - return &TokenCycle{ - LimitID: current.LimitID, - WindowType: current.WindowType, - WindowDurationMinutes: current.WindowDurationMinutes, - StartedAt: startedAt, - ResetsAt: current.ResetsAt, - TotalTokens: total, - } + return &TokenCycle{LimitID: current.LimitID, WindowType: current.WindowType, WindowDurationMinutes: current.WindowDurationMinutes, StartedAt: startedAt, ResetsAt: current.ResetsAt, TotalTokens: total} } func peakDailyTokensForCycle(cycle *TokenCycle, usage []UsagePoint, fetchedAt int64) *int64 { @@ -711,18 +875,3 @@ func betterTokenCycleLimit(candidate, current LimitBucket) bool { } return candidate.ResetsAt > current.ResetsAt } - -func flattenLimit(x rawLimit) []LimitBucket { - out := []LimitBucket{} - if x.Primary != nil { - out = append(out, LimitBucket{x.LimitID, x.LimitName, "primary", x.Primary.UsedPercent, x.Primary.WindowDurationMins, x.Primary.ResetsAt, x.PlanType}) - } - if x.Secondary != nil { - out = append(out, LimitBucket{x.LimitID, x.LimitName, "secondary", x.Secondary.UsedPercent, x.Secondary.WindowDurationMins, x.Secondary.ResetsAt, x.PlanType}) - } - return out -} - -var _ = context.Canceled -var _ = sql.ErrNoRows -var _ = strconv.Itoa diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index ea1b91e..f4753ef 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -2,8 +2,6 @@ package app import ( "context" - "crypto/tls" - "database/sql" "embed" "encoding/json" "errors" @@ -11,15 +9,12 @@ import ( "io/fs" "log" "net/http" - "net/smtp" "os" - "path/filepath" - "strconv" "strings" "sync" "time" - "codex-helper/internal/codex" + "codex-helper/internal/cliproxy" "codex-helper/internal/security" "codex-helper/internal/store" webassets "codex-helper/internal/web" @@ -32,48 +27,51 @@ type App struct { dataDir string store *store.Store vault *security.Vault + cpa cpaClient server *http.Server ctx context.Context cancel context.CancelFunc mu sync.RWMutex + syncAllMu sync.Mutex runtimes map[int64]*accountRuntime loginAttempts sync.Map reminderMu sync.Mutex telegramMu sync.Mutex } + type accountRuntime struct { - client codexClient - processCtx context.Context - dash Dashboard - syncing sync.Mutex - lifecycle sync.Mutex - stateMu sync.RWMutex - ready bool - stopped bool + dash Dashboard + syncing sync.Mutex +} + +type cpaClient interface { + Configured() bool + Snapshot(context.Context, string) (cliproxy.Snapshot, error) } const automaticSyncInterval = time.Duration(automaticSyncMinutes) * time.Minute -type codexClient interface { - Start(context.Context) error - Initialize(context.Context) error - Call(context.Context, string, any, any) error - Close() error - Connected() bool -} - func New() (*App, error) { dir := env("DATA_DIR", "/data") - s, e := store.Open(dir) - if e != nil { - return nil, e + s, err := store.Open(dir) + if err != nil { + return nil, err } - v, e := security.OpenVault(dir) - if e != nil { - return nil, e + v, err := security.OpenVault(dir) + if err != nil { + _ = s.DB.Close() + return nil, err } ctx, cancel := context.WithCancel(context.Background()) - a := &App{dataDir: dir, store: s, vault: v, ctx: ctx, cancel: cancel, runtimes: map[int64]*accountRuntime{}} + a := &App{ + dataDir: dir, + store: s, + vault: v, + cpa: cliproxy.New(os.Getenv("CLIPROXY_API_BASE_URL"), os.Getenv("CLIPROXY_API_MANAGEMENT_KEY")), + ctx: ctx, + cancel: cancel, + runtimes: map[int64]*accountRuntime{}, + } accounts, _ := s.Accounts() for _, account := range accounts { a.addRuntime(account.ID) @@ -81,205 +79,111 @@ func New() (*App, error) { a.server = &http.Server{Addr: env("LISTEN_ADDR", ":8080"), Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second} return a, nil } + func env(k, d string) string { if v := os.Getenv(k); v != "" { return v } return d } + func (a *App) Run() error { - if e := os.MkdirAll(filepath.Join(a.dataDir, "codex"), 0700); e != nil { - return e - } - go a.keepCodex() - go a.scheduler() + go a.syncScheduler() + go a.maintenanceScheduler() go a.telegramLoop() log.Printf("codex-helper listening on %s", a.server.Addr) - e := a.server.ListenAndServe() - if errors.Is(e, http.ErrServerClosed) { + err := a.server.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { return nil } - return e + return err } + func (a *App) Close() { a.cancel() - ctx, c := context.WithTimeout(context.Background(), 5*time.Second) - defer c() - _ = a.server.Shutdown(ctx) - a.mu.RLock() - for _, rt := range a.runtimes { - rt.stop() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if a.server != nil { + _ = a.server.Shutdown(ctx) } - a.mu.RUnlock() + a.syncAllMu.Lock() + a.syncAllMu.Unlock() _ = a.store.DB.Close() } -func (a *App) keepCodex() { - for { - select { - case <-a.ctx.Done(): - return - default: - } - a.mu.RLock() - ids := make([]int64, 0, len(a.runtimes)) - for id := range a.runtimes { - ids = append(ids, id) - } - a.mu.RUnlock() - for _, id := range ids { - rt := a.runtime(id) - if rt == nil || rt.Ready() { - continue - } - if e := rt.ensureReady(a.ctx); e == nil { - _ = a.syncAccount(context.Background(), id) - } else if !errors.Is(e, errRuntimeStopped) { - log.Printf("app-server initialize: %v", e) - } - } - time.Sleep(time.Second) - } -} -func (a *App) onCodexNotification(id int64) func(string, json.RawMessage) { - return func(method string, _ json.RawMessage) { - if method == "account/login/completed" || method == "account/updated" || method == "account/rateLimits/updated" { - go a.syncAccountWithRetry(id, method == "account/login/completed") - } - } -} -func (a *App) syncAccountWithRetry(id int64, requireClassifiedAccount bool) { - delays := []time.Duration{0, time.Second, 3 * time.Second} - var err error - for _, delay := range delays { - if delay > 0 { - select { - case <-a.ctx.Done(): - return - case <-time.After(delay): - } - } - err = a.syncAccount(context.Background(), id) - if err == nil { - rt := a.runtime(id) - if !requireClassifiedAccount || rt == nil || accountClassificationReady(rt) { - return - } - err = errors.New("登录已完成,但工作区套餐尚未就绪") - } - } - if rt := a.runtime(id); rt != nil { - rt.syncing.Lock() - rt.dash.Stale = true - rt.dash.LastError = err.Error() - rt.syncing.Unlock() - } -} - -func accountClassificationReady(rt *accountRuntime) bool { - rt.syncing.Lock() - defer rt.syncing.Unlock() - return rt.dash.Account.Connected && store.AccountKind(rt.dash.Account.PlanType) != "unknown" -} func (a *App) addRuntime(id int64) { - dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10), "codex") - if id == 1 { - // Account 1 deliberately keeps the legacy path so upgrades retain the - // existing login, and fresh installs use the same deterministic path. - dir = filepath.Join(a.dataDir, "codex") + dashboard := Dashboard{AccountID: id, Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true} + if account, err := a.store.Account(id); err == nil { + dashboard.DisplayName = account.DisplayName + dashboard.Account = AccountView{Email: account.Email, PlanType: account.PlanType, Connected: account.Connected} } - rt := &accountRuntime{client: codex.New(dir, a.onCodexNotification(id)), processCtx: a.ctx, dash: Dashboard{Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true}} a.mu.Lock() - a.runtimes[id] = rt + a.runtimes[id] = &accountRuntime{dash: dashboard} a.mu.Unlock() } -var errRuntimeStopped = errors.New("账号服务已停止") - -func (rt *accountRuntime) ensureReady(ctx context.Context) error { - rt.lifecycle.Lock() - defer rt.lifecycle.Unlock() - rt.stateMu.RLock() - stopped, ready := rt.stopped, rt.ready - rt.stateMu.RUnlock() - if stopped { - return errRuntimeStopped - } - if ready && rt.client.Connected() { - return nil - } - // Connected only means the child process is alive. If a previous attempt - // did not finish the protocol handshake, discard it before trying again. - rt.stateMu.Lock() - rt.ready = false - rt.stateMu.Unlock() - if rt.client.Connected() { - _ = rt.client.Close() - } - processCtx := rt.processCtx - if processCtx == nil { - processCtx = ctx - } - if err := rt.client.Start(processCtx); err != nil { - return err - } - initCtx, cancel := context.WithTimeout(ctx, 20*time.Second) - err := rt.client.Initialize(initCtx) - cancel() - if err != nil { - _ = rt.client.Close() - return err - } - rt.stateMu.Lock() - rt.ready = true - rt.stateMu.Unlock() - return nil -} - -func (rt *accountRuntime) Ready() bool { - rt.stateMu.RLock() - ready := !rt.stopped && rt.ready - rt.stateMu.RUnlock() - return ready && rt.client.Connected() -} - -func (rt *accountRuntime) stop() { - rt.lifecycle.Lock() - defer rt.lifecycle.Unlock() - rt.stateMu.Lock() - rt.stopped = true - rt.ready = false - rt.stateMu.Unlock() - _ = rt.client.Close() -} func (a *App) runtime(id int64) *accountRuntime { a.mu.RLock() defer a.mu.RUnlock() return a.runtimes[id] } + func (a *App) syncAll(ctx context.Context) { + if !a.syncAllMu.TryLock() { + return + } + defer a.syncAllMu.Unlock() + if ctx.Err() != nil { + return + } a.mu.RLock() ids := make([]int64, 0, len(a.runtimes)) for id := range a.runtimes { ids = append(ids, id) } a.mu.RUnlock() + + const maxConcurrentAccountSyncs = 4 + sem := make(chan struct{}, maxConcurrentAccountSyncs) + var wg sync.WaitGroup for _, id := range ids { - _ = a.syncAccount(ctx, id) + wg.Add(1) + go func(accountID int64) { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + _ = a.syncAccount(ctx, accountID) + }(id) } + wg.Wait() } -func (a *App) scheduler() { - maintenanceTicker := time.NewTicker(time.Minute) - syncTicker := time.NewTicker(automaticSyncInterval) - defer maintenanceTicker.Stop() - defer syncTicker.Stop() + +func (a *App) syncScheduler() { + a.syncAll(a.ctx) + ticker := time.NewTicker(automaticSyncInterval) + defer ticker.Stop() for { select { case <-a.ctx.Done(): return - case <-syncTicker.C: - a.syncAll(context.Background()) - case <-maintenanceTicker.C: + case <-ticker.C: + a.syncAll(a.ctx) + } + } +} + +func (a *App) maintenanceScheduler() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-a.ctx.Done(): + return + case <-ticker.C: g := a.general() _, _ = a.store.Cleanup(g.RetentionDays) go a.processReminders() @@ -287,31 +191,28 @@ func (a *App) scheduler() { } } +func (a *App) cpaConfigured() bool { + return a.cpa != nil && a.cpa.Configured() +} + func (a *App) routes() http.Handler { m := http.NewServeMux() - m.HandleFunc("/health/live", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]any{"status": "ok"}) }) + m.HandleFunc("/health/live", func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, http.StatusOK, map[string]any{"status": "ok"}) + }) m.HandleFunc("/health/ready", func(w http.ResponseWriter, r *http.Request) { - if e := a.store.Health(r.Context()); e != nil { - jsonOut(w, 503, map[string]string{"error": e.Error()}) + if err := a.store.Health(r.Context()); err != nil { + jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return } - connected := false - a.mu.RLock() - for _, rt := range a.runtimes { - if rt.Ready() { - connected = true - break - } - } - a.mu.RUnlock() - jsonOut(w, 200, map[string]any{"status": "ok", "appServer": connected}) + jsonOut(w, http.StatusOK, map[string]any{"status": "ok", "cpa": a.cpaConfigured()}) }) m.HandleFunc("/api/v1/", a.api) sub, _ := fs.Sub(webassets.Assets, "dist") files := http.FileServer(http.FS(sub)) m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { - if _, e := fs.Stat(sub, strings.TrimPrefix(r.URL.Path, "/")); e == nil { + if _, err := fs.Stat(sub, strings.TrimPrefix(r.URL.Path, "/")); err == nil { files.ServeHTTP(w, r) return } @@ -322,6 +223,7 @@ func (a *App) routes() http.Handler { }) return securityHeaders(m) } + func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") @@ -331,42 +233,40 @@ func securityHeaders(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + func jsonOut(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } + func decode(r *http.Request, v any) error { defer r.Body.Close() d := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) d.DisallowUnknownFields() return d.Decode(v) } + func (a *App) authed(r *http.Request) bool { - c, e := r.Cookie("session") - if e != nil { + c, err := r.Cookie("session") + if err != nil { return false } var x int return a.store.DB.QueryRow("SELECT 1 FROM sessions WHERE token_hash=? AND expires_at>?", security.HashToken(c.Value), time.Now().Unix()).Scan(&x) == nil } + func (a *App) require(w http.ResponseWriter, r *http.Request) bool { if !a.authed(r) { - jsonOut(w, 401, map[string]string{"error": "未登录"}) + jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "未登录"}) return false } - if r.Method != "GET" && r.Method != "HEAD" { - if r.Header.Get("X-Requested-With") != "codex-helper" { - jsonOut(w, 403, map[string]string{"error": "请求来源校验失败"}) - return false - } + if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Header.Get("X-Requested-With") != "codex-helper" { + jsonOut(w, http.StatusForbidden, map[string]string{"error": "请求来源校验失败"}) + return false } return true } // Keep an explicit embed reference visible to tooling. var _ embed.FS -var _ = sql.ErrNoRows -var _ = strconv.Itoa -var _ = tls.VersionTLS13 -var _ = smtp.SendMail diff --git a/backend/internal/app/notify.go b/backend/internal/app/notify.go index 9c3967b..5162b56 100644 --- a/backend/internal/app/notify.go +++ b/backend/internal/app/notify.go @@ -415,7 +415,9 @@ func (a *App) handleTG(t TelegramSettings, chat int64, text string) { return } if text == "立即刷新" || text == "/refresh" { - a.syncAll(context.Background()) + go a.syncAll(a.ctx) + _ = tgSend(t, "🔄 刷新请求已提交\n\n如果已有刷新正在执行,将复用该轮结果;稍后可再次查询当前用量。") + return } msg := "" a.mu.RLock() diff --git a/backend/internal/app/reminder_test.go b/backend/internal/app/reminder_test.go index 76c7138..9748754 100644 --- a/backend/internal/app/reminder_test.go +++ b/backend/internal/app/reminder_test.go @@ -367,6 +367,13 @@ func reminderDashboard(fetchedAt int64, used float64, resetsAt int64) Dashboard } } +func ensureReminderAccount(t *testing.T, a *App) { + t.Helper() + if _, err := a.store.CreateAccount("测试账号", "test-auth"); err != nil { + t.Fatal(err) + } +} + func notificationCount(t *testing.T, a *App) int { t.Helper() var count int @@ -378,6 +385,7 @@ func notificationCount(t *testing.T, a *App) int { func TestStoreLimitSnapshotsDetectsEarlyReset(t *testing.T) { a := newReminderTestApp(t) + ensureReminderAccount(t, a) now := time.Now().Unix() if detected, err := a.storeLimitSnapshots(reminderDashboard(now, 42, now+3600)); err != nil || detected { t.Fatalf("initial snapshot: detected=%v err=%v", detected, err) @@ -433,6 +441,7 @@ func TestLegacyNotificationFormatting(t *testing.T) { func TestStoreLimitSnapshotsUsesScheduledAfterDedupeKey(t *testing.T) { a := newReminderTestApp(t) + ensureReminderAccount(t, a) now := time.Now().Unix() resetAt := now + 30 _, _ = a.storeLimitSnapshots(reminderDashboard(now, 70, resetAt)) @@ -466,6 +475,7 @@ func TestStoreLimitSnapshotsIgnoresNonResetChanges(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { a := newReminderTestApp(t) + ensureReminderAccount(t, a) g := defaults() g.NotifyAfter = tt.notifyAfter if err := a.store.SetJSON("general", g); err != nil { diff --git a/backend/internal/app/runtime_test.go b/backend/internal/app/runtime_test.go index 632450f..3a6947e 100644 --- a/backend/internal/app/runtime_test.go +++ b/backend/internal/app/runtime_test.go @@ -4,26 +4,125 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" - "reflect" - "strconv" "strings" "sync" + "sync/atomic" "testing" "time" + "codex-helper/internal/cliproxy" "codex-helper/internal/security" "codex-helper/internal/store" ) -func TestSystemStatusRejectsNonGETMethods(t *testing.T) { +type fakeCPAClient struct { + mu sync.Mutex + configured bool + snapshots map[string]cliproxy.Snapshot + errors map[string]error + calls []string +} + +func (f *fakeCPAClient) Configured() bool { return f.configured } + +func (f *fakeCPAClient) Snapshot(_ context.Context, authIndex string) (cliproxy.Snapshot, error) { + return f.snapshot(authIndex) +} + +func (f *fakeCPAClient) snapshot(authIndex string) (cliproxy.Snapshot, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, authIndex) + if err := f.errors[authIndex]; err != nil { + return cliproxy.Snapshot{}, err + } + snapshot, ok := f.snapshots[authIndex] + if !ok { + return cliproxy.Snapshot{}, errors.New("auth 不存在") + } + return snapshot, nil +} + +func testSnapshot(authIndex string) cliproxy.Snapshot { + email := authIndex + "@example.com" + plan := "plus" + lifetime := int64(1234) + peak := int64(321) + return cliproxy.Snapshot{ + Auth: cliproxy.AuthFile{AuthIndex: authIndex, Label: "CPA 标签", Email: &email, AccountID: "acct-1", PlanType: &plan}, + Limits: []cliproxy.Limit{{LimitID: "codex", WindowType: "primary", UsedPercent: 12.5, WindowDurationMinutes: 300, ResetsAt: time.Now().Add(time.Hour).Unix(), PlanType: &plan}}, + Summary: cliproxy.UsageSummary{LifetimeTokens: &lifetime, PeakDailyTokens: &peak}, + Usage: []cliproxy.UsagePoint{{Date: time.Now().UTC().Format("2006-01-02"), TotalTokens: 100}}, + ProfileAvailable: true, + UsageAvailable: true, + FetchedAt: time.Now().UTC(), + } +} + +type blockingCPAClient struct { + started chan struct{} + release chan struct{} + active atomic.Int32 + maximum atomic.Int32 +} + +func (f *blockingCPAClient) Configured() bool { return true } + +func (f *blockingCPAClient) Snapshot(_ context.Context, authIndex string) (cliproxy.Snapshot, error) { + active := f.active.Add(1) + defer f.active.Add(-1) + for { + maximum := f.maximum.Load() + if active <= maximum || f.maximum.CompareAndSwap(maximum, active) { + break + } + } + f.started <- struct{}{} + <-f.release + return testSnapshot(authIndex), nil +} + +func configureTestCPA(a *App, authIndexes ...string) *fakeCPAClient { + client := &fakeCPAClient{configured: true, snapshots: map[string]cliproxy.Snapshot{}, errors: map[string]error{}} + for _, authIndex := range authIndexes { + client.snapshots[authIndex] = testSnapshot(authIndex) + } + a.cpa = client + return client +} + +func authenticatedRequest(t *testing.T, a *App, method, path, body string) *http.Request { + t.Helper() + if err := a.store.Set("initialized", "true"); err != nil { + t.Fatal(err) + } + session := "test-session" + if _, err := a.store.DB.Exec("INSERT OR IGNORE INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(method, path, strings.NewReader(body)) + request.AddCookie(&http.Cookie{Name: "session", Value: session}) + if method != http.MethodGet && method != http.MethodHead { + request.Header.Set("X-Requested-With", "codex-helper") + } + return request +} + +func TestSystemStatusUsesCPAFlagAndRejectsNonGET(t *testing.T) { a := newReminderTestApp(t) + a.cpa = &fakeCPAClient{configured: true} recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodPost, "/api/v1/system/status", nil) - a.api(recorder, request) + a.api(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"cpa":true`) || strings.Contains(recorder.Body.String(), "appServer") { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } + recorder = httptest.NewRecorder() + a.api(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/system/status", nil)) if recorder.Code != http.StatusMethodNotAllowed { - t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) } } @@ -33,10 +132,9 @@ func TestSystemStatusReturnsBuildVersion(t *testing.T) { Version = "1.2.3-test" t.Cleanup(func() { Version = originalVersion }) recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil) - a.api(recorder, request) + a.api(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)) if recorder.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) } var body struct { Version string `json:"version"` @@ -45,26 +143,124 @@ func TestSystemStatusReturnsBuildVersion(t *testing.T) { t.Fatal(err) } if body.Version != "1.2.3-test" { - t.Fatalf("version = %q; want %q", body.Version, "1.2.3-test") + t.Fatalf("version = %q", body.Version) + } +} + +func TestAnonymousOverviewRedactsCPAAndIdentityData(t *testing.T) { + a := newReminderTestApp(t) + if err := a.store.Set("initialized", "true"); err != nil { + t.Fatal(err) + } + account, err := a.store.CreateAccountWithVisibility("公开账号", "private-cpa-index", "personal", true) + if err != nil { + t.Fatal(err) + } + email, plan, authMode := "owner@example.com", "plus", "cliproxyapi" + if err := a.store.UpdateAccount(account.ID, &email, &plan, true); err != nil { + t.Fatal(err) + } + a.addRuntime(account.ID) + a.runtime(account.ID).dash = Dashboard{ + AccountID: account.ID, + DisplayName: account.DisplayName, + Account: AccountView{Email: &email, PlanType: &plan, AuthMode: &authMode, Connected: true}, + Limits: []LimitBucket{}, + Usage: []UsagePoint{}, + ResetCredits: &ResetCreditsSummary{AvailableCount: 2, ExpiresAt: []int64{1784246400}}, + FetchedAt: time.Now().Unix(), + LastError: "private upstream detail", + } + + accountsRecorder := httptest.NewRecorder() + a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil)) + if accountsRecorder.Code != http.StatusOK || strings.Contains(accountsRecorder.Body.String(), "private-cpa-index") || strings.Contains(accountsRecorder.Body.String(), "owner@example.com") { + t.Fatalf("anonymous accounts status=%d body=%s", accountsRecorder.Code, accountsRecorder.Body.String()) + } + + dashboardRecorder := httptest.NewRecorder() + a.api(dashboardRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId="+strconvID(account.ID), nil)) + if dashboardRecorder.Code != http.StatusOK { + t.Fatalf("dashboard status=%d body=%s", dashboardRecorder.Code, dashboardRecorder.Body.String()) + } + var dashboard Dashboard + if err := json.Unmarshal(dashboardRecorder.Body.Bytes(), &dashboard); err != nil { + t.Fatal(err) + } + if dashboard.Account.Email != nil || dashboard.Account.AuthMode != nil || dashboard.LastError != "" || dashboard.ResetCredits == nil || dashboard.ResetCredits.AvailableCount != 2 { + t.Fatalf("anonymous dashboard = %#v", dashboard) + } + + for _, target := range []struct { + method string + path string + }{ + {http.MethodGet, "/api/v1/settings/general"}, + {http.MethodPost, "/api/v1/accounts"}, + {http.MethodPost, "/api/v1/accounts/" + strconvID(account.ID) + "/sync"}, + } { + recorder := httptest.NewRecorder() + a.api(recorder, httptest.NewRequest(target.method, target.path, nil)) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("anonymous %s %s status=%d", target.method, target.path, recorder.Code) + } + } +} + +func TestAccountVisibilityFiltersAnonymousOverviewAndCanBeUpdated(t *testing.T) { + a := newReminderTestApp(t) + if err := a.store.Set("initialized", "true"); err != nil { + t.Fatal(err) + } + publicAccount, err := a.store.CreateAccountWithVisibility("公开账号", "public-auth", "team", true) + if err != nil { + t.Fatal(err) + } + privateAccount, err := a.store.CreateAccount("私有账号", "private-auth", "personal") + if err != nil { + t.Fatal(err) + } + a.addRuntime(publicAccount.ID) + a.addRuntime(privateAccount.ID) + + recorder := httptest.NewRecorder() + a.api(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil)) + var accounts []store.Account + if recorder.Code != http.StatusOK || json.Unmarshal(recorder.Body.Bytes(), &accounts) != nil || len(accounts) != 1 || accounts[0].ID != publicAccount.ID { + t.Fatalf("anonymous accounts status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + privateDashboard := httptest.NewRecorder() + a.api(privateDashboard, httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId="+strconvID(privateAccount.ID), nil)) + if privateDashboard.Code != http.StatusNotFound { + t.Fatalf("private dashboard status=%d", privateDashboard.Code) + } + + update := httptest.NewRecorder() + a.api(update, authenticatedRequest(t, a, http.MethodPut, "/api/v1/accounts/"+strconvID(privateAccount.ID), `{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`)) + if update.Code != http.StatusOK { + t.Fatalf("update status=%d body=%s", update.Code, update.Body.String()) + } + updated, err := a.store.Account(privateAccount.ID) + if err != nil || !updated.PublicVisible { + t.Fatalf("updated=%#v err=%v", updated, err) } } func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) { a := newReminderTestApp(t) - if err := a.store.Set("initialized", "true"); err != nil { - t.Fatal(err) - } - a.runtimes[1] = &accountRuntime{} - _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken("test-session"), time.Now().Add(time.Hour).Unix(), time.Now().Unix()) + account, err := a.store.CreateAccount("账号", "auth-1") if err != nil { t.Fatal(err) } + a.addRuntime(account.ID) + rt := a.runtime(account.ID) + rt.dash.Limits = nil + rt.dash.Usage = nil recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil) - request.AddCookie(&http.Cookie{Name: "session", Value: "test-session"}) - a.api(recorder, request) + a.api(recorder, authenticatedRequest(t, a, http.MethodGet, "/api/v1/dashboard?accountId="+strconvID(account.ID), "")) if recorder.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) } var body struct { Limits []LimitBucket `json:"limits"` @@ -74,216 +270,230 @@ func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) { t.Fatal(err) } if body.Limits == nil || body.Usage == nil { - t.Fatalf("nil lists in response: %s", recorder.Body.String()) + t.Fatalf("nil lists: %s", recorder.Body.String()) } } -func TestAnonymousOverviewIsReadOnly(t *testing.T) { +func TestCreateAccountRequiresAuthIndexAndImmediatelySyncs(t *testing.T) { a := newReminderTestApp(t) - if err := a.store.Set("initialized", "true"); err != nil { - t.Fatal(err) - } - email := "owner@example.com" - plan := "plus" - if err := a.store.UpdateAccount(1, &email, &plan, true); err != nil { - t.Fatal(err) - } - publicVisible := true - if err := a.store.UpdateAccountSettingsWithVisibility(1, "默认账号", "any", &publicVisible); err != nil { - t.Fatal(err) - } - a.runtimes[1] = &accountRuntime{ - dash: Dashboard{ - AccountID: 1, - DisplayName: "默认账号", - Account: AccountView{Email: &email, PlanType: &plan, Connected: true}, - Limits: []LimitBucket{}, - Usage: []UsagePoint{}, - ResetCredits: &ResetCreditsSummary{AvailableCount: 2, ExpiresAt: []int64{1784246400}}, - FetchedAt: time.Now().Unix(), - }, + configureTestCPA(a, "auth-1") + + missing := httptest.NewRecorder() + a.api(missing, authenticatedRequest(t, a, http.MethodPost, "/api/v1/accounts", `{"displayName":"A"}`)) + if missing.Code != http.StatusBadRequest { + t.Fatalf("missing authIndex status = %d body = %s", missing.Code, missing.Body.String()) } - accountsRecorder := httptest.NewRecorder() - a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil)) - if accountsRecorder.Code != http.StatusOK { - t.Fatalf("anonymous accounts status = %d, body = %s", accountsRecorder.Code, accountsRecorder.Body.String()) - } - var accounts []struct { - Email *string `json:"email"` - ExpectedKind string `json:"expectedKind"` - PublicVisible bool `json:"publicVisible"` - ValidationState string `json:"validationStatus"` - } - if err := json.Unmarshal(accountsRecorder.Body.Bytes(), &accounts); err != nil { - t.Fatal(err) - } - if len(accounts) != 1 || accounts[0].Email != nil || !accounts[0].PublicVisible || accounts[0].ExpectedKind != "any" || accounts[0].ValidationState != "unknown" { - t.Fatalf("anonymous account data = %#v; sensitive account fields were not redacted", accounts) - } - - dashboardRecorder := httptest.NewRecorder() - a.api(dashboardRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil)) - if dashboardRecorder.Code != http.StatusOK { - t.Fatalf("anonymous dashboard status = %d, body = %s", dashboardRecorder.Code, dashboardRecorder.Body.String()) - } - var publicDashboardBody Dashboard - if err := json.Unmarshal(dashboardRecorder.Body.Bytes(), &publicDashboardBody); err != nil { - t.Fatal(err) - } - if publicDashboardBody.Account.Email != nil || publicDashboardBody.Account.AuthMode != nil { - t.Fatalf("anonymous dashboard account = %#v; identity fields were not redacted", publicDashboardBody.Account) - } - if publicDashboardBody.ResetCredits == nil || publicDashboardBody.ResetCredits.AvailableCount != 2 { - t.Fatalf("anonymous dashboard reset credits = %#v; want two credits", publicDashboardBody.ResetCredits) - } - - for _, path := range []string{"/api/v1/settings/general", "/api/v1/accounts", "/api/v1/accounts/1/sync"} { - recorder := httptest.NewRecorder() - method := http.MethodGet - if path == "/api/v1/accounts" || strings.HasSuffix(path, "/sync") { - method = http.MethodPost - } - a.api(recorder, httptest.NewRequest(method, path, nil)) - if recorder.Code != http.StatusUnauthorized { - t.Fatalf("anonymous %s status = %d, body = %s; configuration must require login", path, recorder.Code, recorder.Body.String()) - } - } - - session := "test-session" - if _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil { - t.Fatal(err) - } - privateRecorder := httptest.NewRecorder() - privateRequest := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil) - privateRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) - a.api(privateRecorder, privateRequest) - if privateRecorder.Code != http.StatusOK { - t.Fatalf("authenticated dashboard status = %d, body = %s", privateRecorder.Code, privateRecorder.Body.String()) - } - var privateDashboardBody Dashboard - if err := json.Unmarshal(privateRecorder.Body.Bytes(), &privateDashboardBody); err != nil { - t.Fatal(err) - } - if privateDashboardBody.Account.Email == nil || *privateDashboardBody.Account.Email != email { - t.Fatalf("authenticated dashboard email = %v; want %q", privateDashboardBody.Account.Email, email) - } - if privateDashboardBody.ResetCredits == nil || privateDashboardBody.ResetCredits.AvailableCount != 2 { - t.Fatalf("authenticated dashboard reset credits = %#v; want two credits", privateDashboardBody.ResetCredits) - } - configRecorder := httptest.NewRecorder() - configRequest := httptest.NewRequest(http.MethodPut, "/api/v1/settings/general", strings.NewReader(`{"timezone":"UTC","theme":"system","syncMinutes":5,"retentionDays":90,"beforeMinutes":30,"notifyBefore":true,"notifyAfter":true}`)) - configRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) - configRequest.Header.Set("X-Requested-With", "codex-helper") - a.api(configRecorder, configRequest) - if configRecorder.Code != http.StatusOK { - t.Fatalf("authenticated settings status = %d, body = %s", configRecorder.Code, configRecorder.Body.String()) - } -} - -func TestAccountVisibilityFiltersAnonymousOverviewAndCanBeUpdated(t *testing.T) { - a := newReminderTestApp(t) - if err := a.store.Set("initialized", "true"); err != nil { - t.Fatal(err) - } - publicAccount, err := a.store.CreateAccountWithVisibility("公开账号", "team", true) - if err != nil { - t.Fatal(err) - } - privateAccount, err := a.store.CreateAccount("私有账号", "personal") - if err != nil { - t.Fatal(err) - } - a.runtimes[publicAccount.ID] = &accountRuntime{} - a.runtimes[privateAccount.ID] = &accountRuntime{} - - accountsRecorder := httptest.NewRecorder() - a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil)) - if accountsRecorder.Code != http.StatusOK { - t.Fatalf("anonymous accounts status = %d, body = %s", accountsRecorder.Code, accountsRecorder.Body.String()) - } - var visible []struct { - ID int64 `json:"id"` - PublicVisible bool `json:"publicVisible"` - } - if err := json.Unmarshal(accountsRecorder.Body.Bytes(), &visible); err != nil { - t.Fatal(err) - } - if len(visible) != 1 || visible[0].ID != publicAccount.ID || !visible[0].PublicVisible { - t.Fatalf("anonymous accounts = %#v; want only public account %d", visible, publicAccount.ID) - } - - privateDashboard := httptest.NewRecorder() - privatePath := "/api/v1/dashboard?accountId=" + strconv.FormatInt(privateAccount.ID, 10) - a.api(privateDashboard, httptest.NewRequest(http.MethodGet, privatePath, nil)) - if privateDashboard.Code != http.StatusNotFound { - t.Fatalf("anonymous private dashboard status = %d, body = %s", privateDashboard.Code, privateDashboard.Body.String()) - } - - publicDashboard := httptest.NewRecorder() - publicPath := "/api/v1/dashboard?accountId=" + strconv.FormatInt(publicAccount.ID, 10) - a.api(publicDashboard, httptest.NewRequest(http.MethodGet, publicPath, nil)) - if publicDashboard.Code != http.StatusOK { - t.Fatalf("anonymous public dashboard status = %d, body = %s", publicDashboard.Code, publicDashboard.Body.String()) - } - - session := "visibility-session" - if _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil { - t.Fatal(err) - } - createRecorder := httptest.NewRecorder() - createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/accounts", strings.NewReader(`{"displayName":"接口公开账号","expectedKind":"team","publicVisible":true}`)) - createRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) - createRequest.Header.Set("X-Requested-With", "codex-helper") - a.api(createRecorder, createRequest) - if createRecorder.Code != http.StatusCreated { - t.Fatalf("authenticated account creation status = %d, body = %s", createRecorder.Code, createRecorder.Body.String()) + recorder := httptest.NewRecorder() + a.api(recorder, authenticatedRequest(t, a, http.MethodPost, "/api/v1/accounts", `{"authIndex":"auth-1","expectedKind":"personal","publicVisible":true}`)) + if recorder.Code != http.StatusCreated { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) } var created store.Account - if err := json.Unmarshal(createRecorder.Body.Bytes(), &created); err != nil { + if err := json.Unmarshal(recorder.Body.Bytes(), &created); err != nil { t.Fatal(err) } - if !created.PublicVisible { - t.Fatalf("created account = %#v; want publicVisible=true", created) + if created.AuthIndex != "auth-1" || created.DisplayName != "CPA 标签" || !created.Connected || created.Email == nil { + t.Fatalf("created = %#v", created) } - authenticatedAccounts := httptest.NewRecorder() - authenticatedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil) - authenticatedRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) - a.api(authenticatedAccounts, authenticatedRequest) - if authenticatedAccounts.Code != http.StatusOK { - t.Fatalf("authenticated accounts status = %d, body = %s", authenticatedAccounts.Code, authenticatedAccounts.Body.String()) + rt := a.runtime(created.ID) + if rt == nil || rt.dash.Stale || rt.dash.Account.AuthMode == nil || *rt.dash.Account.AuthMode != "cliproxyapi" || len(rt.dash.Limits) != 1 || rt.dash.Summary.PeakDailyTokens == nil || *rt.dash.Summary.PeakDailyTokens != 100 { + t.Fatalf("dashboard = %#v", rt) } - var all []struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(authenticatedAccounts.Body.Bytes(), &all); err != nil { - t.Fatal(err) - } - if len(all) != 4 { - t.Fatalf("authenticated accounts = %#v; want default, public, private, and newly created accounts", all) + var usageRows int + if err := a.store.DB.QueryRow("SELECT COUNT(*) FROM daily_usage WHERE account_id=?", created.ID).Scan(&usageRows); err != nil || usageRows != 1 { + t.Fatalf("usage rows = %d err = %v", usageRows, err) } +} - anonymousUpdate := httptest.NewRecorder() - anonymousUpdateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatInt(privateAccount.ID, 10), strings.NewReader(`{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`)) - a.api(anonymousUpdate, anonymousUpdateRequest) - if anonymousUpdate.Code != http.StatusUnauthorized { - t.Fatalf("anonymous visibility update status = %d, body = %s", anonymousUpdate.Code, anonymousUpdate.Body.String()) +func TestCreateAccountRejectsDuplicateAuthIndex(t *testing.T) { + a := newReminderTestApp(t) + configureTestCPA(a, "auth-1") + first := httptest.NewRecorder() + a.api(first, authenticatedRequest(t, a, http.MethodPost, "/api/v1/accounts", `{"displayName":"A","authIndex":"auth-1"}`)) + if first.Code != http.StatusCreated { + t.Fatalf("first status = %d body = %s", first.Code, first.Body.String()) } + second := httptest.NewRecorder() + a.api(second, authenticatedRequest(t, a, http.MethodPost, "/api/v1/accounts", `{"displayName":"B","authIndex":"auth-1"}`)) + if second.Code != http.StatusConflict { + t.Fatalf("second status = %d body = %s", second.Code, second.Body.String()) + } + accounts, _ := a.store.Accounts() + if len(accounts) != 1 { + t.Fatalf("accounts = %#v", accounts) + } +} - authenticatedUpdate := httptest.NewRecorder() - authenticatedUpdateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatInt(privateAccount.ID, 10), strings.NewReader(`{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`)) - authenticatedUpdateRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) - authenticatedUpdateRequest.Header.Set("X-Requested-With", "codex-helper") - a.api(authenticatedUpdate, authenticatedUpdateRequest) - if authenticatedUpdate.Code != http.StatusOK { - t.Fatalf("authenticated visibility update status = %d, body = %s", authenticatedUpdate.Code, authenticatedUpdate.Body.String()) +func TestFailedCreateDoesNotPersistAccount(t *testing.T) { + a := newReminderTestApp(t) + client := configureTestCPA(a) + client.errors["bad-auth"] = errors.New("CPA upstream unavailable") + recorder := httptest.NewRecorder() + a.api(recorder, authenticatedRequest(t, a, http.MethodPost, "/api/v1/accounts", `{"displayName":"A","authIndex":"bad-auth"}`)) + if recorder.Code != http.StatusBadGateway { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) } - updated, err := a.store.Account(privateAccount.ID) + accounts, _ := a.store.Accounts() + if len(accounts) != 0 || len(a.runtimes) != 0 { + t.Fatalf("partial account persisted: accounts=%#v runtimes=%d", accounts, len(a.runtimes)) + } +} + +func TestSyncSuccessAndFailureUpdatesStaleState(t *testing.T) { + a := newReminderTestApp(t) + client := configureTestCPA(a, "auth-1") + account, err := a.store.CreateAccount("账号", "auth-1") if err != nil { t.Fatal(err) } - if !updated.PublicVisible { - t.Fatalf("updated account = %#v; want publicVisible=true", updated) + a.addRuntime(account.ID) + if err := a.syncAccount(context.Background(), account.ID); err != nil { + t.Fatal(err) + } + rt := a.runtime(account.ID) + if rt.dash.Stale || rt.dash.LastError != "" { + t.Fatalf("successful dashboard = %#v", rt.dash) + } + client.errors["auth-1"] = errors.New("CPA upstream unavailable") + if err := a.syncAccount(context.Background(), account.ID); err == nil { + t.Fatal("failed sync unexpectedly succeeded") + } + if !rt.dash.Stale || rt.dash.LastError != "CPA upstream unavailable" || len(rt.dash.Limits) != 1 { + t.Fatalf("stale dashboard = %#v", rt.dash) + } +} + +func TestSyncPreservesUsageWhenProfileBucketsAreUnavailable(t *testing.T) { + a := newReminderTestApp(t) + client := configureTestCPA(a, "auth-1") + account, err := a.store.CreateAccount("账号", "auth-1") + if err != nil { + t.Fatal(err) + } + a.addRuntime(account.ID) + if err := a.syncAccount(context.Background(), account.ID); err != nil { + t.Fatal(err) + } + withoutProfile := client.snapshots["auth-1"] + updatedLifetime := int64(9999) + withoutProfile.ProfileAvailable = true + withoutProfile.UsageAvailable = false + withoutProfile.Summary = cliproxy.UsageSummary{LifetimeTokens: &updatedLifetime} + withoutProfile.Usage = nil + withoutProfile.Limits[0].UsedPercent = 55 + client.snapshots["auth-1"] = withoutProfile + if err := a.syncAccount(context.Background(), account.ID); err != nil { + t.Fatal(err) + } + dashboard := a.runtime(account.ID).dash + if len(dashboard.Limits) != 1 || dashboard.Limits[0].UsedPercent != 55 || dashboard.Summary.LifetimeTokens == nil || *dashboard.Summary.LifetimeTokens != 9999 || len(dashboard.Usage) != 1 || dashboard.CurrentCycle == nil { + t.Fatalf("dashboard = %#v", dashboard) + } +} + +func TestUpdateAuthIndexFailureKeepsOldBinding(t *testing.T) { + a := newReminderTestApp(t) + client := configureTestCPA(a, "old-auth") + client.errors["new-auth"] = errors.New("new auth unavailable") + account, err := a.store.CreateAccount("账号", "old-auth") + if err != nil { + t.Fatal(err) + } + a.addRuntime(account.ID) + if err := a.syncAccount(context.Background(), account.ID); err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + path := "/api/v1/accounts/" + strconvID(account.ID) + a.api(recorder, authenticatedRequest(t, a, http.MethodPut, path, `{"displayName":"新名称","authIndex":"new-auth"}`)) + if recorder.Code != http.StatusBadGateway { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } + stored, err := a.store.Account(account.ID) + if err != nil { + t.Fatal(err) + } + if stored.AuthIndex != "old-auth" || stored.DisplayName != "账号" || a.runtime(account.ID).dash.DisplayName != "账号" { + t.Fatalf("half-updated account=%#v dashboard=%#v", stored, a.runtime(account.ID).dash) + } +} + +func TestUpdateAuthIndexClearsPreviousIdentityHistory(t *testing.T) { + a := newReminderTestApp(t) + client := configureTestCPA(a, "old-auth", "new-auth") + newSnapshot := client.snapshots["new-auth"] + newSnapshot.Usage = []cliproxy.UsagePoint{{Date: "2026-08-15", TotalTokens: 999}} + client.snapshots["new-auth"] = newSnapshot + account, err := a.store.CreateAccount("账号", "old-auth") + if err != nil { + t.Fatal(err) + } + a.addRuntime(account.ID) + if err := a.syncAccount(context.Background(), account.ID); err != nil { + t.Fatal(err) + } + if _, err := a.store.DB.Exec(`INSERT INTO notifications + (dedupe_key,channel,kind,status,scheduled_at,body) VALUES(?, 'configured', 'after', 'sent', 1, '{}')`, strconvID(account.ID)+":codex:primary:1:after"); err != nil { + t.Fatal(err) + } + + recorder := httptest.NewRecorder() + path := "/api/v1/accounts/" + strconvID(account.ID) + a.api(recorder, authenticatedRequest(t, a, http.MethodPut, path, `{"displayName":"新身份","authIndex":"new-auth"}`)) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var usageRows, limitRows, notificationRows int + if err := a.store.DB.QueryRow("SELECT COUNT(*) FROM daily_usage WHERE account_id=?", account.ID).Scan(&usageRows); err != nil { + t.Fatal(err) + } + if err := a.store.DB.QueryRow("SELECT COUNT(*) FROM limit_snapshots WHERE account_id=?", account.ID).Scan(&limitRows); err != nil { + t.Fatal(err) + } + if err := a.store.DB.QueryRow("SELECT COUNT(*) FROM notifications WHERE dedupe_key GLOB ?", strconvID(account.ID)+":*").Scan(¬ificationRows); err != nil { + t.Fatal(err) + } + var tokens int64 + if err := a.store.DB.QueryRow("SELECT total_tokens FROM daily_usage WHERE account_id=? AND date='2026-08-15'", account.ID).Scan(&tokens); err != nil { + t.Fatal(err) + } + if usageRows != 1 || limitRows != 1 || notificationRows != 0 || tokens != 999 { + t.Fatalf("rows usage=%d limits=%d notifications=%d tokens=%d", usageRows, limitRows, notificationRows, tokens) + } +} + +func TestPublicAccountsDoNotExposeAuthIndex(t *testing.T) { + a := newReminderTestApp(t) + if err := a.store.Set("initialized", "true"); err != nil { + t.Fatal(err) + } + account, err := a.store.CreateAccountWithVisibility("公开", "secret-auth-index", "any", true) + if err != nil { + t.Fatal(err) + } + a.addRuntime(account.ID) + recorder := httptest.NewRecorder() + a.api(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil)) + if recorder.Code != http.StatusOK || strings.Contains(recorder.Body.String(), "secret-auth-index") || strings.Contains(recorder.Body.String(), "authIndex") { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } +} + +func TestDeviceLoginAndLogoutRoutesAreRemoved(t *testing.T) { + a := newReminderTestApp(t) + account, err := a.store.CreateAccount("账号", "auth-1") + if err != nil { + t.Fatal(err) + } + a.addRuntime(account.ID) + for _, suffix := range []string{"/login/device", "/logout"} { + recorder := httptest.NewRecorder() + path := "/api/v1/accounts/" + strconvID(account.ID) + suffix + a.api(recorder, authenticatedRequest(t, a, http.MethodPost, path, "")) + if recorder.Code != http.StatusNotFound { + t.Fatalf("%s status = %d body = %s", suffix, recorder.Code, recorder.Body.String()) + } } } @@ -291,63 +501,12 @@ func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) { now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC) reset := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC) cycle := currentTokenCycle( - []LimitBucket{ - { - LimitID: "codex", - WindowType: "primary", - WindowDurationMinutes: 300, - ResetsAt: now.Add(5 * time.Hour).Unix(), - }, - { - LimitID: "codex", - WindowType: "secondary", - WindowDurationMinutes: 7 * 24 * 60, - ResetsAt: reset.Unix(), - }, - }, - []UsagePoint{ - {Date: "2026-08-07", TotalTokens: 50}, - {Date: "2026-08-08", TotalTokens: 100}, - {Date: "2026-08-10", TotalTokens: 200}, - {Date: "2026-08-14", TotalTokens: 300}, - {Date: "2026-08-15", TotalTokens: 400}, - }, + []LimitBucket{{LimitID: "codex", WindowType: "primary", WindowDurationMinutes: 300, ResetsAt: now.Add(5 * time.Hour).Unix()}, {LimitID: "codex", WindowType: "secondary", WindowDurationMinutes: 7 * 24 * 60, ResetsAt: reset.Unix()}}, + []UsagePoint{{Date: "2026-08-07", TotalTokens: 50}, {Date: "2026-08-08", TotalTokens: 100}, {Date: "2026-08-10", TotalTokens: 200}, {Date: "2026-08-14", TotalTokens: 300}, {Date: "2026-08-15", TotalTokens: 400}}, now.Unix(), ) - if cycle == nil { - t.Fatal("currentTokenCycle() returned nil") - } - if cycle.WindowType != "secondary" || cycle.WindowDurationMinutes != 7*24*60 { - t.Fatalf("cycle window = %#v; want the seven-day secondary window", cycle) - } - if cycle.StartedAt != time.Date(2026, time.August, 8, 0, 0, 0, 0, time.UTC).Unix() { - t.Fatalf("cycle start = %d; want 2026-08-08", cycle.StartedAt) - } - if cycle.ResetsAt != reset.Unix() || cycle.TotalTokens != 600 { - t.Fatalf("cycle = %#v; want reset %d and 600 tokens", cycle, reset.Unix()) - } -} - -func TestNormalizeResetCreditsKeepsAvailableCountAndUniqueExpiryTimes(t *testing.T) { - earlier := int64(1781654400) - later := int64(1784246400) - credits := normalizeResetCredits(&rawResetCredits{ - AvailableCount: 3, - Credits: []rawResetCredit{ - {ExpiresAt: &later}, - {ExpiresAt: nil}, - {ExpiresAt: &earlier}, - {ExpiresAt: &later}, - }, - }) - if credits == nil || credits.AvailableCount != 3 { - t.Fatalf("reset credits = %#v; want three available credits", credits) - } - if want := []int64{earlier, later}; !reflect.DeepEqual(credits.ExpiresAt, want) { - t.Fatalf("expiry times = %v; want %v", credits.ExpiresAt, want) - } - if normalizeResetCredits(&rawResetCredits{}) != nil { - t.Fatal("zero available credits should be hidden") + if cycle == nil || cycle.WindowType != "secondary" || cycle.TotalTokens != 600 { + t.Fatalf("cycle = %#v", cycle) } } @@ -358,16 +517,13 @@ func TestCurrentTokenCycleRequiresAValidFutureResetWindow(t *testing.T) { time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC).Unix(), ) if cycle != nil { - t.Fatalf("currentTokenCycle() = %#v; want nil", cycle) + t.Fatalf("cycle = %#v; want nil", cycle) } } func TestPeakDailyTokensUsesOnlyTheCurrentTokenCycle(t *testing.T) { now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC) - cycle := &TokenCycle{ - StartedAt: time.Date(2026, time.August, 8, 0, 0, 0, 0, time.UTC).Unix(), - ResetsAt: time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC).Unix(), - } + cycle := &TokenCycle{StartedAt: time.Date(2026, time.August, 8, 0, 0, 0, 0, time.UTC).Unix(), ResetsAt: time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC).Unix()} peak := peakDailyTokensForCycle(cycle, []UsagePoint{ {Date: "2026-08-07", TotalTokens: 900}, {Date: "2026-08-08", TotalTokens: 100}, @@ -376,7 +532,46 @@ func TestPeakDailyTokensUsesOnlyTheCurrentTokenCycle(t *testing.T) { {Date: "2026-08-15", TotalTokens: 800}, }, now.Unix()) if peak == nil || *peak != 300 { - t.Fatalf("cycle peak = %v; want 300", peak) + t.Fatalf("peak = %v; want 300", peak) + } +} + +func TestSyncAllUsesBoundedConcurrency(t *testing.T) { + a := newReminderTestApp(t) + client := &blockingCPAClient{started: make(chan struct{}, 6), release: make(chan struct{})} + a.cpa = client + for i := 0; i < 6; i++ { + account, err := a.store.CreateAccount(fmt.Sprintf("账号%d", i), fmt.Sprintf("auth-%d", i)) + if err != nil { + t.Fatal(err) + } + a.addRuntime(account.ID) + } + done := make(chan struct{}) + go func() { + a.syncAll(context.Background()) + close(done) + }() + for i := 0; i < 4; i++ { + select { + case <-client.started: + case <-time.After(time.Second): + t.Fatal("four account syncs did not start concurrently") + } + } + select { + case <-client.started: + t.Fatal("more than four account syncs started before a slot was released") + case <-time.After(50 * time.Millisecond): + } + close(client.release) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("syncAll did not finish") + } + if maximum := client.maximum.Load(); maximum != 4 { + t.Fatalf("maximum concurrency = %d; want 4", maximum) } } @@ -385,198 +580,11 @@ func TestAutomaticSyncIntervalIsFixedAtFiveMinutes(t *testing.T) { if err := a.store.SetJSON("general", GeneralSettings{SyncMinutes: 60, RetentionDays: 90, BeforeMinutes: 30}); err != nil { t.Fatal(err) } - if got := a.general().SyncMinutes; got != automaticSyncMinutes { - t.Fatalf("sync minutes = %d; want %d", got, automaticSyncMinutes) - } - if automaticSyncInterval != 5*time.Minute { - t.Fatalf("automatic sync interval = %s; want 5m", automaticSyncInterval) + if got := a.general().SyncMinutes; got != automaticSyncMinutes || automaticSyncInterval != 5*time.Minute { + t.Fatalf("sync minutes = %d interval = %s", got, automaticSyncInterval) } } -func TestFlattenLimitReadsAppServerWindowDuration(t *testing.T) { - var limit rawLimit - if err := json.Unmarshal([]byte(`{ - "limitId":"codex", - "primary":{"usedPercent":20,"windowDurationMins":10080,"resetsAt":1787197043} - }`), &limit); err != nil { - t.Fatal(err) - } - flattened := flattenLimit(limit) - if len(flattened) != 1 || flattened[0].WindowDurationMinutes != 10080 { - t.Fatalf("flattened limit = %#v; want a 10080-minute window", flattened) - } -} - -type fakeCodexClient struct { - mu sync.Mutex - connected bool - starts int - initializes int - closes int - calls int - initErrors []error - initStarted chan struct{} - initRelease chan struct{} -} - -func (f *fakeCodexClient) Start(context.Context) error { - f.mu.Lock() - defer f.mu.Unlock() - f.starts++ - f.connected = true - return nil -} - -func (f *fakeCodexClient) Initialize(context.Context) error { - f.mu.Lock() - f.initializes++ - var err error - if len(f.initErrors) > 0 { - err, f.initErrors = f.initErrors[0], f.initErrors[1:] - } - started, release := f.initStarted, f.initRelease - f.mu.Unlock() - if started != nil { - select { - case started <- struct{}{}: - default: - } - } - if release != nil { - <-release - } - return err -} - -func (f *fakeCodexClient) Call(_ context.Context, method string, _ any, out any) error { - f.mu.Lock() - defer f.mu.Unlock() - f.calls++ - if method == "account/login/start" { - result := out.(*map[string]any) - *result = map[string]any{"verificationUrl": "https://example.test/device", "userCode": "ABCD-EFGH"} - } - return nil -} - -func (f *fakeCodexClient) Close() error { - f.mu.Lock() - defer f.mu.Unlock() - f.closes++ - f.connected = false - return nil -} - -func (f *fakeCodexClient) Connected() bool { - f.mu.Lock() - defer f.mu.Unlock() - return f.connected -} - -func (f *fakeCodexClient) counts() (starts, initializes, closes, calls int) { - f.mu.Lock() - defer f.mu.Unlock() - return f.starts, f.initializes, f.closes, f.calls -} - -func TestEnsureReadySerializesColdStart(t *testing.T) { - client := &fakeCodexClient{} - rt := &accountRuntime{client: client} - var wg sync.WaitGroup - errs := make(chan error, 8) - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - errs <- rt.ensureReady(context.Background()) - }() - } - wg.Wait() - close(errs) - for err := range errs { - if err != nil { - t.Fatal(err) - } - } - starts, initializes, _, _ := client.counts() - if starts != 1 || initializes != 1 { - t.Fatalf("cold starts = %d, initializes = %d; want 1 each", starts, initializes) - } -} - -func TestEnsureReadyRetriesAfterInitializeFailure(t *testing.T) { - client := &fakeCodexClient{initErrors: []error{errors.New("handshake failed")}} - rt := &accountRuntime{client: client} - if err := rt.ensureReady(context.Background()); err == nil { - t.Fatal("first initialization unexpectedly succeeded") - } - if err := rt.ensureReady(context.Background()); err != nil { - t.Fatalf("retry failed: %v", err) - } - starts, initializes, closes, _ := client.counts() - if starts != 2 || initializes != 2 || closes != 1 { - t.Fatalf("starts = %d, initializes = %d, closes = %d; want 2, 2, 1", starts, initializes, closes) - } -} - -func TestStopWaitsForStartupAndPreventsRestart(t *testing.T) { - started := make(chan struct{}, 1) - release := make(chan struct{}) - client := &fakeCodexClient{initStarted: started, initRelease: release} - rt := &accountRuntime{client: client} - readyDone := make(chan error, 1) - go func() { readyDone <- rt.ensureReady(context.Background()) }() - <-started - stopDone := make(chan struct{}) - go func() { rt.stop(); close(stopDone) }() - close(release) - if err := <-readyDone; err != nil { - t.Fatalf("startup failed: %v", err) - } - <-stopDone - if err := rt.ensureReady(context.Background()); !errors.Is(err, errRuntimeStopped) { - t.Fatalf("restart error = %v; want stopped", err) - } - starts, initializes, closes, _ := client.counts() - if starts != 1 || initializes != 1 || closes != 1 { - t.Fatalf("starts = %d, initializes = %d, closes = %d; want 1 each", starts, initializes, closes) - } -} - -func TestDeviceLoginStartsColdRuntime(t *testing.T) { - client := &fakeCodexClient{} - a := &App{runtimes: map[int64]*accountRuntime{2: {client: client}}} - recorder := httptest.NewRecorder() - request := httptest.NewRequest("POST", "/api/v1/accounts/2/login/device", nil) - a.deviceLogin(recorder, request, 2) - if recorder.Code != 200 { - t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) - } - starts, initializes, _, calls := client.counts() - if starts != 1 || initializes != 1 || calls != 1 { - t.Fatalf("starts = %d, initializes = %d, calls = %d; want 1 each", starts, initializes, calls) - } -} - -func TestAccountClassificationReadyRequiresConnectedKnownPlan(t *testing.T) { - team := "team" - unknown := "unknown" - tests := []struct { - name string - account AccountView - want bool - }{ - {name: "disconnected", account: AccountView{PlanType: &team}, want: false}, - {name: "missing plan", account: AccountView{Connected: true}, want: false}, - {name: "unknown plan", account: AccountView{Connected: true, PlanType: &unknown}, want: false}, - {name: "classified", account: AccountView{Connected: true, PlanType: &team}, want: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rt := &accountRuntime{dash: Dashboard{Account: tt.account}} - if got := accountClassificationReady(rt); got != tt.want { - t.Fatalf("accountClassificationReady() = %v; want %v", got, tt.want) - } - }) - } +func strconvID(id int64) string { + return fmt.Sprintf("%d", id) } diff --git a/backend/internal/cliproxy/client.go b/backend/internal/cliproxy/client.go new file mode 100644 index 0000000..7f91e69 --- /dev/null +++ b/backend/internal/cliproxy/client.go @@ -0,0 +1,745 @@ +package cliproxy + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + usageURL = "https://chatgpt.com/backend-api/wham/usage" + profileURL = "https://chatgpt.com/backend-api/wham/profiles/me" + resetCreditsURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits" + codexUserAgent = "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal" + maxResponseBody = 4 << 20 +) + +var ErrNotConfigured = errors.New("CLIProxyAPI Management API 未配置") + +type Client struct { + baseURL string + managementKey string + httpClient *http.Client + now func() time.Time +} + +type AuthFile struct { + AuthIndex string + Label string + Name string + Email *string + Status string + AccountID string + PlanType *string +} + +type Limit struct { + LimitID string + LimitName *string + WindowType string + UsedPercent float64 + WindowDurationMinutes int + ResetsAt int64 + PlanType *string +} + +type UsageSummary struct { + LifetimeTokens *int64 + PeakDailyTokens *int64 + LongestRunningTurnSec *int64 + CurrentStreakDays *int + LongestStreakDays *int +} + +type UsagePoint struct { + Date string + TotalTokens int64 +} + +type ResetCredits struct { + AvailableCount int + ExpiresAt []int64 +} + +type Snapshot struct { + Auth AuthFile + Limits []Limit + Summary UsageSummary + Usage []UsagePoint + ProfileAvailable bool + UsageAvailable bool + ResetCredits *ResetCredits + FetchedAt time.Time +} + +func New(baseURL, managementKey string) *Client { + return NewWithHTTPClient(baseURL, managementKey, &http.Client{Timeout: 15 * time.Second}) +} + +func NewWithHTTPClient(baseURL, managementKey string, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 15 * time.Second} + } + return &Client{ + baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + managementKey: managementKey, + httpClient: httpClient, + now: time.Now, + } +} + +func (c *Client) Configured() bool { + return c.baseURL != "" && c.managementKey != "" +} + +func (c *Client) Auth(ctx context.Context, authIndex string) (AuthFile, error) { + if !c.Configured() { + return AuthFile{}, ErrNotConfigured + } + authIndex = strings.TrimSpace(authIndex) + if authIndex == "" { + return AuthFile{}, errors.New("authIndex 不能为空") + } + u, err := url.Parse(c.baseURL + "/v0/management/auth-files") + if err != nil { + return AuthFile{}, errors.New("CLIProxyAPI 地址无效") + } + q := u.Query() + q.Set("auth_index", authIndex) + u.RawQuery = q.Encode() + body, err := c.managementRequest(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return AuthFile{}, err + } + var raw any + if err := json.Unmarshal(body, &raw); err != nil { + return AuthFile{}, errors.New("CLIProxyAPI auth-files 响应格式错误") + } + items := authItems(raw) + matches := make([]AuthFile, 0, 1) + for _, item := range items { + auth, ok := parseAuthFile(item, authIndex) + if ok { + matches = append(matches, auth) + } + } + if len(matches) == 0 { + return AuthFile{}, errors.New("未找到可用的 Codex auth") + } + if len(matches) != 1 { + return AuthFile{}, errors.New("Codex auth 匹配结果不唯一") + } + return matches[0], nil +} + +func (c *Client) Snapshot(ctx context.Context, authIndex string) (Snapshot, error) { + auth, err := c.Auth(ctx, authIndex) + if err != nil { + return Snapshot{}, err + } + receivedAt := c.now().UTC() + usageBody, err := c.apiCall(ctx, auth, usageURL) + if err != nil { + return Snapshot{}, fmt.Errorf("获取用量限额失败: %w", err) + } + limits, availableFallback, effectivePlan, err := parseUsage(usageBody, auth.PlanType, receivedAt) + if err != nil { + return Snapshot{}, err + } + auth.PlanType = effectivePlan + var summary UsageSummary + usage := []UsagePoint{} + profileAvailable := false + usageAvailable := false + if profileBody, profileErr := c.apiCall(ctx, auth, profileURL); profileErr == nil { + if parsedSummary, parsedUsage, parsedUsageAvailable, parseErr := parseProfile(profileBody); parseErr == nil { + summary = parsedSummary + usage = parsedUsage + usageAvailable = parsedUsageAvailable + profileAvailable = usageAvailable || usageSummaryAvailable(summary) + } + } + var resetCredits *ResetCredits + resetCtx, cancelReset := context.WithTimeout(ctx, 5*time.Second) + resetBody, resetErr := c.apiCall(resetCtx, auth, resetCreditsURL) + cancelReset() + if resetErr == nil { + resetCredits = parseResetCredits(resetBody, availableFallback) + } else if availableFallback > 0 { + resetCredits = &ResetCredits{AvailableCount: availableFallback, ExpiresAt: []int64{}} + } + return Snapshot{ + Auth: auth, + Limits: limits, + Summary: summary, + Usage: usage, + ProfileAvailable: profileAvailable, + UsageAvailable: usageAvailable, + ResetCredits: resetCredits, + FetchedAt: receivedAt, + }, nil +} + +func (c *Client) managementRequest(ctx context.Context, method, endpoint string, body io.Reader) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return nil, errors.New("创建 CLIProxyAPI 请求失败") + } + req.Header.Set("Authorization", "Bearer "+c.managementKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + httpClient := *c.httpClient + httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, errors.New("CLIProxyAPI 请求失败") + } + defer resp.Body.Close() + payload, err := readLimited(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("CLIProxyAPI Management API 返回状态码 %d", resp.StatusCode) + } + return payload, nil +} + +func (c *Client) apiCall(ctx context.Context, auth AuthFile, upstreamURL string) ([]byte, error) { + requestBody := struct { + AuthIndex string `json:"auth_index"` + Method string `json:"method"` + URL string `json:"url"` + Header map[string]string `json:"header"` + }{ + AuthIndex: auth.AuthIndex, + Method: http.MethodGet, + URL: upstreamURL, + Header: map[string]string{ + "Authorization": "Bearer $TOKEN$", + "Chatgpt-Account-Id": auth.AccountID, + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": codexUserAgent, + }, + } + encoded, err := json.Marshal(requestBody) + if err != nil { + return nil, errors.New("创建 CLIProxyAPI api-call 请求失败") + } + payload, err := c.managementRequest(ctx, http.MethodPost, c.baseURL+"/v0/management/api-call", strings.NewReader(string(encoded))) + if err != nil { + return nil, err + } + var envelope struct { + StatusCode int `json:"status_code"` + Body json.RawMessage `json:"body"` + } + if err := json.Unmarshal(payload, &envelope); err != nil || envelope.StatusCode == 0 || len(envelope.Body) == 0 { + return nil, errors.New("CLIProxyAPI api-call 响应格式错误") + } + if envelope.StatusCode < 200 || envelope.StatusCode >= 300 { + if errorType := safeUpstreamErrorType(envelope.Body); errorType != "" { + return nil, fmt.Errorf("CLIProxyAPI 上游请求返回状态码 %d (%s)", envelope.StatusCode, errorType) + } + return nil, fmt.Errorf("CLIProxyAPI 上游请求返回状态码 %d", envelope.StatusCode) + } + var bodyString string + if len(envelope.Body) > 0 && envelope.Body[0] == '"' { + if err := json.Unmarshal(envelope.Body, &bodyString); err != nil { + return nil, errors.New("CLIProxyAPI api-call body 格式错误") + } + if len(bodyString) > maxResponseBody { + return nil, errors.New("CLIProxyAPI 响应体过大") + } + return []byte(bodyString), nil + } + if len(envelope.Body) > maxResponseBody { + return nil, errors.New("CLIProxyAPI 响应体过大") + } + return envelope.Body, nil +} + +func safeUpstreamErrorType(raw json.RawMessage) string { + body := []byte(raw) + if len(body) > 0 && body[0] == '"' { + var text string + if json.Unmarshal(body, &text) != nil { + return "" + } + body = []byte(text) + } + var payload map[string]any + if json.Unmarshal(body, &payload) != nil { + return "" + } + candidates := []string{stringValue(payload, "type", "code")} + if nested, ok := objectValue(payload, "error"); ok { + candidates = append([]string{stringValue(nested, "type", "code")}, candidates...) + } + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" || len(candidate) > 80 { + continue + } + safe := true + for _, r := range candidate { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' || r == '.') { + safe = false + break + } + } + if safe { + return candidate + } + } + return "" +} + +func readLimited(r io.Reader) ([]byte, error) { + payload, err := io.ReadAll(io.LimitReader(r, maxResponseBody+1)) + if err != nil { + return nil, errors.New("读取 CLIProxyAPI 响应失败") + } + if len(payload) > maxResponseBody { + return nil, errors.New("CLIProxyAPI 响应体过大") + } + return payload, nil +} + +func authItems(raw any) []map[string]any { + switch value := raw.(type) { + case []any: + out := make([]map[string]any, 0, len(value)) + for _, item := range value { + if object, ok := item.(map[string]any); ok { + out = append(out, object) + } + } + return out + case map[string]any: + for _, key := range []string{"auth_files", "authFiles", "files", "data", "items"} { + if nested, ok := value[key]; ok { + if out := authItems(nested); len(out) > 0 { + return out + } + } + } + if stringValue(value, "auth_index", "authIndex") != "" { + return []map[string]any{value} + } + } + return []map[string]any{} +} + +func parseAuthFile(item map[string]any, expectedIndex string) (AuthFile, bool) { + index := strings.TrimSpace(stringValue(item, "auth_index", "authIndex")) + if index != expectedIndex { + return AuthFile{}, false + } + provider := strings.ToLower(strings.TrimSpace(stringValue(item, "provider"))) + typ := strings.ToLower(strings.TrimSpace(stringValue(item, "type"))) + if provider != "" && provider != "codex" || typ != "" && typ != "codex" || provider == "" && typ == "" { + return AuthFile{}, false + } + status := strings.TrimSpace(stringValue(item, "status")) + statusLower := strings.ToLower(status) + // Do not reject CPA's transient unavailable/error state: quota exhaustion + // itself can set it, and this dashboard still needs to read the reset time. + if boolValue(item, "disabled") || statusLower == "disabled" { + return AuthFile{}, false + } + claims := claimsFrom(item) + authClaims, _ := objectValue(claims, "https://api.openai.com/auth") + accountID := strings.TrimSpace(stringValue(claims, "chatgpt_account_id", "chatgptAccountId")) + if accountID == "" { + accountID = strings.TrimSpace(stringValue(authClaims, "chatgpt_account_id", "chatgptAccountId")) + } + if accountID == "" { + return AuthFile{}, false + } + var email *string + if value := strings.TrimSpace(stringValue(item, "email")); value != "" { + email = &value + } else if value := strings.TrimSpace(stringValue(claims, "email")); value != "" { + email = &value + } + var planType *string + value := strings.TrimSpace(stringValue(claims, "plan_type", "planType")) + if value == "" { + value = strings.TrimSpace(stringValue(authClaims, "chatgpt_plan_type", "plan_type", "planType")) + } + if value != "" { + planType = &value + } + return AuthFile{ + AuthIndex: index, + Label: strings.TrimSpace(stringValue(item, "label")), + Name: strings.TrimSpace(stringValue(item, "name")), + Email: email, + Status: status, + AccountID: accountID, + PlanType: planType, + }, true +} + +func claimsFrom(item map[string]any) map[string]any { + for _, key := range []string{"id_token_claims", "idTokenClaims", "id_token", "idToken"} { + value, ok := item[key] + if !ok { + continue + } + if object, ok := value.(map[string]any); ok { + if nested, ok := object["claims"].(map[string]any); ok { + return nested + } + return object + } + if encoded, ok := value.(string); ok { + if claims := decodeClaimsString(encoded); claims != nil { + return claims + } + } + } + return map[string]any{} +} + +func decodeClaimsString(value string) map[string]any { + var claims map[string]any + if json.Unmarshal([]byte(value), &claims) == nil { + return claims + } + parts := strings.Split(value, ".") + if len(parts) < 2 { + return nil + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || json.Unmarshal(payload, &claims) != nil { + return nil + } + return claims +} + +func parseUsage(body []byte, fallbackPlan *string, receivedAt time.Time) ([]Limit, int, *string, error) { + var root map[string]any + if err := json.Unmarshal(body, &root); err != nil { + return nil, 0, nil, errors.New("wham usage 响应格式错误") + } + if data, ok := objectValue(root, "data"); ok { + root = data + } + planType := fallbackPlan + if value := strings.TrimSpace(stringValue(root, "plan_type", "planType")); value != "" { + planType = &value + } + limits := make([]Limit, 0) + if raw, ok := objectValue(root, "rate_limit", "rateLimit"); ok { + limits = append(limits, parseLimit("codex", nil, raw, planType, receivedAt)...) + } + if raw, ok := objectValue(root, "code_review_rate_limit", "codeReviewRateLimit"); ok { + limits = append(limits, parseLimit("code_review", nil, raw, planType, receivedAt)...) + } + for _, raw := range arrayValue(root, "additional_rate_limits", "additionalRateLimits") { + object, ok := raw.(map[string]any) + if !ok { + continue + } + limitID := strings.TrimSpace(stringValue(object, "metered_feature", "meteredFeature")) + if limitID == "" { + continue + } + var name *string + if value := strings.TrimSpace(stringValue(object, "limit_name", "limitName")); value != "" { + name = &value + } + rateLimit := object + if nested, ok := objectValue(object, "rate_limit", "rateLimit"); ok { + rateLimit = nested + } + limits = append(limits, parseLimit(limitID, name, rateLimit, planType, receivedAt)...) + } + availableCount := 0 + if credits, ok := objectValue(root, "rate_limit_reset_credits", "rateLimitResetCredits"); ok { + availableCount = int(numberValue(credits, "available_count", "availableCount")) + } + return limits, availableCount, planType, nil +} + +func parseLimit(limitID string, name *string, raw map[string]any, planType *string, receivedAt time.Time) []Limit { + out := make([]Limit, 0, 2) + for _, window := range []struct { + kind string + keys []string + }{{"primary", []string{"primary_window", "primaryWindow"}}, {"secondary", []string{"secondary_window", "secondaryWindow"}}} { + object, ok := objectValue(raw, window.keys...) + if !ok { + continue + } + used, usedOK := numericValue(object, "used_percent", "usedPercent") + if !usedOK { + continue + } + seconds := int64(numberValue(object, "limit_window_seconds", "limitWindowSeconds")) + resetsAt := unixTimeValue(object, "reset_at", "resetAt") + if resetsAt == 0 { + resetAfter := int64(numberValue(object, "reset_after_seconds", "resetAfterSeconds")) + if resetAfter > 0 { + resetsAt = receivedAt.Unix() + resetAfter + } + } + used = math.Max(0, math.Min(100, used)) + out = append(out, Limit{ + LimitID: limitID, + LimitName: name, + WindowType: window.kind, + UsedPercent: used, + WindowDurationMinutes: int((seconds + 59) / 60), + ResetsAt: resetsAt, + PlanType: planType, + }) + } + return out +} + +func parseProfile(body []byte) (UsageSummary, []UsagePoint, bool, error) { + var root map[string]any + if err := json.Unmarshal(body, &root); err != nil { + return UsageSummary{}, nil, false, errors.New("wham profile 响应格式错误") + } + stats := root + nestedStats := false + if nested, ok := objectValue(root, "stats", "usage_stats", "usageStats"); ok { + stats = nested + nestedStats = true + } + summary := UsageSummary{ + LifetimeTokens: nonNegativeInt64Pointer(stats, "lifetime_tokens", "lifetimeTokens"), + PeakDailyTokens: nonNegativeInt64Pointer(stats, "peak_daily_tokens", "peakDailyTokens"), + LongestRunningTurnSec: nonNegativeInt64Pointer(stats, "longest_running_turn_sec", "longestRunningTurnSec"), + CurrentStreakDays: nonNegativeIntPointer(stats, "current_streak_days", "currentStreakDays"), + LongestStreakDays: nonNegativeIntPointer(stats, "longest_streak_days", "longestStreakDays"), + } + buckets, usageAvailable := arrayField(root, "daily_usage_buckets", "dailyUsageBuckets") + if !usageAvailable && nestedStats { + buckets, usageAvailable = arrayField(stats, "daily_usage_buckets", "dailyUsageBuckets") + } + usage := make([]UsagePoint, 0, len(buckets)) + for _, raw := range buckets { + bucket, ok := raw.(map[string]any) + if !ok { + continue + } + date := strings.TrimSpace(stringValue(bucket, "date", "start_date", "startDate")) + if len(date) >= 10 { + date = date[:10] + } + if _, err := time.Parse("2006-01-02", date); err != nil { + continue + } + tokens, ok := numericValue(bucket, "total_tokens", "totalTokens", "tokens") + if !ok || tokens < 0 { + continue + } + usage = append(usage, UsagePoint{Date: date, TotalTokens: int64(tokens)}) + } + return summary, usage, usageAvailable, nil +} + +func usageSummaryAvailable(summary UsageSummary) bool { + return summary.LifetimeTokens != nil || summary.PeakDailyTokens != nil || summary.LongestRunningTurnSec != nil || summary.CurrentStreakDays != nil || summary.LongestStreakDays != nil +} + +func parseResetCredits(body []byte, fallback int) *ResetCredits { + var root map[string]any + if json.Unmarshal(body, &root) != nil { + if fallback > 0 { + return &ResetCredits{AvailableCount: fallback, ExpiresAt: []int64{}} + } + return nil + } + if data, ok := objectValue(root, "data"); ok { + root = data + } + countPresent := hasValue(root, "available_count", "availableCount") + count := int(numberValue(root, "available_count", "availableCount")) + if countPresent && count <= 0 { + return nil + } + if !countPresent { + count = fallback + } + if count <= 0 { + return nil + } + expiresAt := make([]int64, 0) + for _, raw := range arrayValue(root, "credits", "available_credits", "availableCredits") { + credit, ok := raw.(map[string]any) + if !ok { + continue + } + if status := strings.ToLower(strings.TrimSpace(stringValue(credit, "status"))); status != "" && status != "available" { + continue + } + if value := unixTimeValue(credit, "expires_at", "expiresAt"); value > 0 { + expiresAt = append(expiresAt, value) + } + } + return &ResetCredits{AvailableCount: count, ExpiresAt: uniqueSorted(expiresAt)} +} + +func uniqueSorted(values []int64) []int64 { + for i := 0; i < len(values); i++ { + for j := i + 1; j < len(values); j++ { + if values[j] < values[i] { + values[i], values[j] = values[j], values[i] + } + } + } + out := values[:0] + for _, value := range values { + if len(out) == 0 || out[len(out)-1] != value { + out = append(out, value) + } + } + return out +} + +func objectValue(object map[string]any, keys ...string) (map[string]any, bool) { + for _, key := range keys { + if value, ok := object[key].(map[string]any); ok { + return value, true + } + } + return nil, false +} + +func arrayValue(object map[string]any, keys ...string) []any { + value, _ := arrayField(object, keys...) + return value +} + +func arrayField(object map[string]any, keys ...string) ([]any, bool) { + for _, key := range keys { + if value, ok := object[key].([]any); ok { + return value, true + } + } + return []any{}, false +} + +func stringValue(object map[string]any, keys ...string) string { + for _, key := range keys { + value, ok := object[key] + if !ok || value == nil { + continue + } + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + case float64: + if typed == math.Trunc(typed) { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', -1, 64) + } + } + return "" +} + +func hasValue(object map[string]any, keys ...string) bool { + for _, key := range keys { + if value, ok := object[key]; ok && value != nil { + return true + } + } + return false +} + +func boolValue(object map[string]any, keys ...string) bool { + for _, key := range keys { + if value, ok := object[key].(bool); ok { + return value + } + } + return false +} + +func numberValue(object map[string]any, keys ...string) float64 { + value, _ := numericValue(object, keys...) + return value +} + +func numericValue(object map[string]any, keys ...string) (float64, bool) { + for _, key := range keys { + value, ok := object[key] + if !ok || value == nil { + continue + } + var parsed float64 + var err error + switch typed := value.(type) { + case float64: + parsed = typed + case string: + parsed, err = strconv.ParseFloat(strings.TrimSpace(typed), 64) + case json.Number: + parsed, err = typed.Float64() + default: + continue + } + if err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0) { + return parsed, true + } + } + return 0, false +} + +func nonNegativeInt64Pointer(object map[string]any, keys ...string) *int64 { + if value, ok := numericValue(object, keys...); ok && value >= 0 { + parsed := int64(value) + return &parsed + } + return nil +} + +func nonNegativeIntPointer(object map[string]any, keys ...string) *int { + value := nonNegativeInt64Pointer(object, keys...) + if value == nil { + return nil + } + parsed := int(*value) + return &parsed +} + +func unixTimeValue(object map[string]any, keys ...string) int64 { + for _, key := range keys { + value, ok := object[key] + if !ok || value == nil { + continue + } + if text, ok := value.(string); ok { + if parsed, err := time.Parse(time.RFC3339, text); err == nil { + return parsed.Unix() + } + if parsed, err := strconv.ParseInt(text, 10, 64); err == nil { + return parsed + } + } + return int64(numberValue(object, key)) + } + return 0 +} diff --git a/backend/internal/cliproxy/client_test.go b/backend/internal/cliproxy/client_test.go new file mode 100644 index 0000000..3003db5 --- /dev/null +++ b/backend/internal/cliproxy/client_test.go @@ -0,0 +1,319 @@ +package cliproxy + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func authResponse(items string) string { + return `{"auth_files":` + items + `}` +} + +func validAuth(index string) string { + return `{"auth_index":"` + index + `","provider":"codex","type":"codex","label":"Main","email":"user@example.com","status":"ready","id_token":{"chatgpt_account_id":"acct-123","plan_type":"new_unknown_plan"}}` +} + +func TestAuthFiltersProviderStatusAndRequiresUniqueMatch(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "valid", body: authResponse(`[` + validAuth("auth/one") + `]`), want: ""}, + {name: "wrong provider", body: authResponse(`[{"auth_index":"auth/one","provider":"gemini","type":"gemini","id_token":{"chatgpt_account_id":"acct"}}]`), want: "未找到"}, + {name: "disabled", body: authResponse(`[{"auth_index":"auth/one","provider":"codex","disabled":true,"id_token":{"chatgpt_account_id":"acct"}}]`), want: "未找到"}, + {name: "quota unavailable remains readable", body: authResponse(`[{"auth_index":"auth/one","type":"codex","status":"error","unavailable":true,"id_token":{"chatgpt_account_id":"acct-123","plan_type":"new_unknown_plan"}}]`), want: ""}, + {name: "duplicate", body: authResponse(`[` + validAuth("auth/one") + `,` + validAuth("auth/one") + `]`), want: "不唯一"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("auth_index"); got != "auth/one" { + t.Fatalf("auth_index = %q", got) + } + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + client := New(server.URL, "management-secret") + auth, err := client.Auth(context.Background(), "auth/one") + if tt.want == "" { + if err != nil { + t.Fatal(err) + } + if auth.AuthIndex != "auth/one" || auth.AccountID != "acct-123" || auth.PlanType == nil || *auth.PlanType != "new_unknown_plan" { + t.Fatalf("auth = %#v", auth) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v; want %q", err, tt.want) + } + }) + } +} + +func TestSnapshotUsesManagementAuthorizationAndAPICallRequestStructure(t *testing.T) { + var mu sync.Mutex + var upstreamURLs []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer management-secret" { + t.Fatalf("Authorization = %q", got) + } + switch r.URL.Path { + case "/v0/management/auth-files": + _, _ = w.Write([]byte(authResponse(`[` + validAuth("auth 1") + `]`))) + case "/v0/management/api-call": + var request struct { + AuthIndex string `json:"auth_index"` + Method string `json:"method"` + URL string `json:"url"` + Header map[string]string `json:"header"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + if request.AuthIndex != "auth 1" || request.Method != http.MethodGet || request.Header["Authorization"] != "Bearer $TOKEN$" || request.Header["Chatgpt-Account-Id"] != "acct-123" || request.Header["Accept"] != "application/json" || request.Header["Content-Type"] != "application/json" || request.Header["User-Agent"] != codexUserAgent { + t.Fatalf("api-call request = %#v", request) + } + mu.Lock() + upstreamURLs = append(upstreamURLs, request.URL) + mu.Unlock() + var body string + switch request.URL { + case usageURL: + body = `{"rate_limit":{"primary_window":{"used_percent":12.75,"limit_window_seconds":301,"reset_after_seconds":60}}}` + case profileURL: + body = `{"lifetime_tokens":1000,"peak_daily_tokens":250,"longest_running_turn_sec":90,"current_streak_days":3,"longest_streak_days":8,"daily_usage_buckets":[{"start_date":"2026-08-14","tokens":77}]}` + case resetCreditsURL: + body = `{"available_count":1,"credits":[{"expires_at":"2026-08-20T00:00:00Z"}]}` + default: + t.Fatalf("unexpected upstream URL %q", request.URL) + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": body}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := New(server.URL, "management-secret") + fixedNow := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + client.now = func() time.Time { return fixedNow } + snapshot, err := client.Snapshot(context.Background(), "auth 1") + if err != nil { + t.Fatal(err) + } + if len(snapshot.Limits) != 1 || snapshot.Limits[0].UsedPercent != 12.75 || snapshot.Limits[0].WindowDurationMinutes != 6 || snapshot.Limits[0].ResetsAt != fixedNow.Unix()+60 { + t.Fatalf("limits = %#v", snapshot.Limits) + } + if snapshot.Limits[0].PlanType == nil || *snapshot.Limits[0].PlanType != "new_unknown_plan" { + t.Fatalf("plan type was not preserved: %#v", snapshot.Limits[0].PlanType) + } + if snapshot.Summary.LifetimeTokens == nil || *snapshot.Summary.LifetimeTokens != 1000 || snapshot.Summary.PeakDailyTokens == nil || *snapshot.Summary.PeakDailyTokens != 250 || len(snapshot.Usage) != 1 || snapshot.Usage[0].TotalTokens != 77 { + t.Fatalf("profile = %#v usage = %#v", snapshot.Summary, snapshot.Usage) + } + if snapshot.ResetCredits == nil || snapshot.ResetCredits.AvailableCount != 1 || len(snapshot.ResetCredits.ExpiresAt) != 1 || snapshot.ResetCredits.ExpiresAt[0] != time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC).Unix() { + t.Fatalf("reset credits = %#v", snapshot.ResetCredits) + } + if len(upstreamURLs) != 3 { + t.Fatalf("upstream URLs = %#v", upstreamURLs) + } +} + +func TestUsageParsesAllLimitKindsAndClampsPercentages(t *testing.T) { + plan := "enterprise-new" + body := []byte(`{ + "rate_limit":{"primary_window":{"used_percent":-1.25,"limit_window_seconds":300,"reset_at":1800000000}}, + "code_review_rate_limit":{"secondary_window":{"used_percent":101.5,"limit_window_seconds":604801,"reset_at":1800000100}}, + "additional_rate_limits":[{"metered_feature":"spark","limit_name":"Spark usage","primary_window":{"used_percent":45.5,"limit_window_seconds":61,"reset_at":1800000200}}], + "rate_limit_reset_credits":{"available_count":2} + }`) + limits, fallback, effectivePlan, err := parseUsage(body, &plan, time.Now()) + if err != nil { + t.Fatal(err) + } + if len(limits) != 3 || limits[0].LimitID != "codex" || limits[0].UsedPercent != 0 || limits[1].LimitID != "code_review" || limits[1].UsedPercent != 100 || limits[1].WindowDurationMinutes != 10081 || limits[2].LimitID != "spark" || limits[2].LimitName == nil || *limits[2].LimitName != "Spark usage" || limits[2].WindowDurationMinutes != 2 || fallback != 2 || effectivePlan == nil || *effectivePlan != plan { + t.Fatalf("limits = %#v fallback = %d", limits, fallback) + } +} + +func TestUsageSkipsWindowsWithoutUsedPercentage(t *testing.T) { + limits, _, _, err := parseUsage([]byte(`{"rate_limit":{"primary_window":{"limit_window_seconds":18000,"reset_at":1800000000}}}`), nil, time.Now()) + if err != nil { + t.Fatal(err) + } + if len(limits) != 0 { + t.Fatalf("limits = %#v; missing used_percent must not become zero usage", limits) + } +} + +func TestUsageReadsNestedAdditionalLimitsAndPlanFromPayload(t *testing.T) { + body := []byte(`{"data":{"plan_type":"prolite","additional_rate_limits":[{"metered_feature":"codex_bengalfox","limit_name":"GPT-5.3-Codex-Spark","rate_limit":{"primary_window":{"used_percent":9.5,"limit_window_seconds":18000,"reset_at":1800000000}}}]}}`) + limits, _, plan, err := parseUsage(body, nil, time.Now()) + if err != nil { + t.Fatal(err) + } + if len(limits) != 1 || limits[0].LimitID != "codex_bengalfox" || limits[0].UsedPercent != 9.5 || plan == nil || *plan != "prolite" || limits[0].PlanType == nil || *limits[0].PlanType != "prolite" { + t.Fatalf("limits = %#v plan = %v", limits, plan) + } +} + +func TestProfileMissingOrNullBucketsAreNotMarkedAvailable(t *testing.T) { + for _, body := range []string{`{}`, `{"stats":{}}`, `{"stats":{"daily_usage_buckets":null}}`} { + summary, usage, usageAvailable, err := parseProfile([]byte(body)) + if err != nil { + t.Fatal(err) + } + if usageAvailable || usage == nil || len(usage) != 0 || usageSummaryAvailable(summary) { + t.Fatalf("body=%s summary=%#v usage=%#v available=%v", body, summary, usage, usageAvailable) + } + } +} + +func TestProfileInvalidOptionalMetricsRemainUnavailable(t *testing.T) { + summary, usage, usageAvailable, err := parseProfile([]byte(`{"stats":{"lifetime_tokens":"unknown","daily_usage_buckets":[{"start_date":"2026-08-14","tokens":"bad"}]}}`)) + if err != nil { + t.Fatal(err) + } + if summary.LifetimeTokens != nil || !usageAvailable || len(usage) != 0 { + t.Fatalf("summary = %#v usage = %#v", summary, usage) + } +} + +func TestResetCreditsIgnoreNonAvailableDetails(t *testing.T) { + credits := parseResetCredits([]byte(`{"available_count":2,"credits":[{"status":"redeemed","expires_at":"2026-08-19T00:00:00Z"},{"status":"available","expires_at":"2026-08-20T00:00:00Z"}]}`), 0) + if credits == nil || credits.AvailableCount != 2 || len(credits.ExpiresAt) != 1 || credits.ExpiresAt[0] != time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC).Unix() { + t.Fatalf("credits = %#v", credits) + } +} + +func TestProfileFailureDoesNotHideRateLimits(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v0/management/auth-files" { + _, _ = w.Write([]byte(authResponse(`[` + validAuth("auth-1") + `]`))) + return + } + var request struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&request) + switch request.URL { + case usageURL: + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": `{"rate_limit":{"primary_window":{"used_percent":50,"limit_window_seconds":18000,"reset_at":1800000000}}}`}) + case profileURL: + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 503, "body": `{"error":"profile unavailable"}`}) + case resetCreditsURL: + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 404, "body": `{}`}) + } + })) + defer server.Close() + + snapshot, err := New(server.URL, "management-secret").Snapshot(context.Background(), "auth-1") + if err != nil { + t.Fatal(err) + } + if len(snapshot.Limits) != 1 || snapshot.ProfileAvailable || snapshot.Usage == nil || len(snapshot.Usage) != 0 || snapshot.Summary.LifetimeTokens != nil { + t.Fatalf("snapshot = %#v", snapshot) + } +} + +func TestResetCreditsSuccessfulZeroOverridesUsageFallback(t *testing.T) { + if credits := parseResetCredits([]byte(`{"available_count":0,"credits":[]}`), 3); credits != nil { + t.Fatalf("credits = %#v; successful detail response is authoritative", credits) + } +} + +func TestOptionalResetFailureFallsBackToUsageCount(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v0/management/auth-files" { + _, _ = w.Write([]byte(authResponse(`[` + validAuth("auth-1") + `]`))) + return + } + var request struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&request) + switch request.URL { + case usageURL: + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": `{"rate_limit_reset_credits":{"available_count":3}}`}) + case profileURL: + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": `{}`}) + case resetCreditsURL: + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 503, "body": `{"secret":"upstream detail"}`}) + } + })) + defer server.Close() + client := New(server.URL, "management-secret") + snapshot, err := client.Snapshot(context.Background(), "auth-1") + if err != nil { + t.Fatal(err) + } + if snapshot.ResetCredits == nil || snapshot.ResetCredits.AvailableCount != 3 || snapshot.ResetCredits.ExpiresAt == nil { + t.Fatalf("reset credits = %#v", snapshot.ResetCredits) + } +} + +func TestManagementRequestDoesNotFollowRedirectsWithSecret(t *testing.T) { + reachedRedirect := false + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reachedRedirect = true + if r.Header.Get("Authorization") != "" { + t.Fatal("management Authorization header reached redirect target") + } + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer server.Close() + + client := New(server.URL, "management-secret") + _, err := client.Auth(context.Background(), "auth-1") + if err == nil || !strings.Contains(err.Error(), "302") { + t.Fatalf("error = %v", err) + } + if reachedRedirect { + t.Fatal("redirect was unexpectedly followed") + } +} + +func TestSafeUpstreamErrorTypeOnlyReturnsBoundedIdentifiers(t *testing.T) { + if got := safeUpstreamErrorType(json.RawMessage(`"{\"error\":{\"type\":\"token_expired\"}}"`)); got != "token_expired" { + t.Fatalf("type = %q", got) + } + if got := safeUpstreamErrorType(json.RawMessage(`{"error":{"type":"secret bearer value"}}`)); got != "" { + t.Fatalf("unsafe type = %q", got) + } +} + +func TestUpstreamErrorDoesNotLeakBodyOrManagementKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v0/management/auth-files" { + _, _ = w.Write([]byte(authResponse(`[` + validAuth("auth-1") + `]`))) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 401, "body": `{"access_token":"raw-token","detail":"private detail"}`}) + })) + defer server.Close() + client := New(server.URL, "management-secret") + _, err := client.Snapshot(context.Background(), "auth-1") + if err == nil { + t.Fatal("snapshot unexpectedly succeeded") + } + message := err.Error() + for _, secret := range []string{"raw-token", "private detail", "management-secret"} { + if strings.Contains(message, secret) { + t.Fatalf("error leaked %q: %s", secret, message) + } + } + if !strings.Contains(message, "401") { + t.Fatalf("error = %q", message) + } +} diff --git a/backend/internal/codex/client.go b/backend/internal/codex/client.go deleted file mode 100644 index 3406ba5..0000000 --- a/backend/internal/codex/client.go +++ /dev/null @@ -1,175 +0,0 @@ -package codex - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "sync" - "sync/atomic" - "time" -) - -type Client struct { - mu sync.Mutex - cmd *exec.Cmd - in io.WriteCloser - pending map[int64]chan envelope - id atomic.Int64 - connected bool - configDir string - notify func(string, json.RawMessage) -} -type envelope struct { - ID *int64 `json:"id,omitempty"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error any `json:"error,omitempty"` -} - -func New(configDir string, notify func(string, json.RawMessage)) *Client { - return &Client{pending: map[int64]chan envelope{}, configDir: configDir, notify: notify} -} -func (c *Client) Start(ctx context.Context) error { - c.mu.Lock() - defer c.mu.Unlock() - if c.connected { - return nil - } - if e := ensureConfigDir(c.configDir); e != nil { - return e - } - cmd := exec.CommandContext(ctx, "codex", "app-server") - cmd.Env = append(os.Environ(), "CODEX_HOME="+c.configDir) - out, e := cmd.StdoutPipe() - if e != nil { - return e - } - in, e := cmd.StdinPipe() - if e != nil { - return e - } - cmd.Stderr = os.Stderr - if e = cmd.Start(); e != nil { - return e - } - c.cmd, c.in, c.connected = cmd, in, true - go c.read(cmd, out) - go func() { _ = cmd.Wait(); c.failAll(cmd) }() - return nil -} - -func ensureConfigDir(dir string) error { - if e := os.MkdirAll(dir, 0700); e != nil { - return fmt.Errorf("create CODEX_HOME %q: %w", dir, e) - } - return nil -} -func (c *Client) Initialize(ctx context.Context) error { - var out any - if e := c.Call(ctx, "initialize", map[string]any{"clientInfo": map[string]any{"name": "codex-helper", "title": "Codex Helper", "version": "0.1.0"}, "capabilities": map[string]any{}}, &out); e != nil { - return e - } - return c.send(map[string]any{"method": "initialized", "params": map[string]any{}}) -} -func (c *Client) read(cmd *exec.Cmd, r io.Reader) { - s := bufio.NewScanner(r) - s.Buffer(make([]byte, 64*1024), 8*1024*1024) - for s.Scan() { - var e envelope - if json.Unmarshal(s.Bytes(), &e) != nil { - continue - } - if e.ID != nil { - c.mu.Lock() - ch := c.pending[*e.ID] - delete(c.pending, *e.ID) - c.mu.Unlock() - if ch != nil { - ch <- e - } - } else if e.Method != "" && c.notify != nil { - go c.notify(e.Method, e.Params) - } - } - c.failAll(cmd) -} -func (c *Client) failAll(cmd *exec.Cmd) { - c.mu.Lock() - defer c.mu.Unlock() - // A previous process may finish after its replacement has started. It must - // not mark the new connection as disconnected or fail its pending calls. - if c.cmd != cmd { - return - } - c.connected = false - c.cmd = nil - c.in = nil - for id, ch := range c.pending { - ch <- envelope{Error: "app-server disconnected"} - delete(c.pending, id) - } -} -func (c *Client) send(v any) error { - c.mu.Lock() - defer c.mu.Unlock() - if !c.connected { - return errors.New("app-server unavailable") - } - b, _ := json.Marshal(v) - b = append(b, '\n') - _, e := c.in.Write(b) - return e -} -func (c *Client) Call(ctx context.Context, method string, params any, out any) error { - id := c.id.Add(1) - ch := make(chan envelope, 1) - c.mu.Lock() - c.pending[id] = ch - c.mu.Unlock() - if e := c.send(map[string]any{"id": id, "method": method, "params": params}); e != nil { - return e - } - select { - case e := <-ch: - if e.Error != nil { - return fmt.Errorf("app-server %s: %v", method, e.Error) - } - if out != nil { - return json.Unmarshal(e.Result, out) - } - return nil - case <-ctx.Done(): - c.mu.Lock() - delete(c.pending, id) - c.mu.Unlock() - return ctx.Err() - case <-time.After(20 * time.Second): - c.mu.Lock() - delete(c.pending, id) - c.mu.Unlock() - return errors.New("app-server timeout") - } -} -func (c *Client) Close() error { - c.mu.Lock() - cmd := c.cmd - c.cmd = nil - c.in = nil - c.connected = false - for id, ch := range c.pending { - ch <- envelope{Error: "app-server disconnected"} - delete(c.pending, id) - } - c.mu.Unlock() - if cmd != nil && cmd.Process != nil { - return cmd.Process.Kill() - } - return nil -} -func (c *Client) Connected() bool { c.mu.Lock(); defer c.mu.Unlock(); return c.connected } diff --git a/backend/internal/codex/client_test.go b/backend/internal/codex/client_test.go deleted file mode 100644 index 045f904..0000000 --- a/backend/internal/codex/client_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package codex - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestEnsureConfigDirCreatesNestedDirectory(t *testing.T) { - dir := filepath.Join(t.TempDir(), "accounts", "2", "codex") - if err := ensureConfigDir(dir); err != nil { - t.Fatal(err) - } - info, err := os.Stat(dir) - if err != nil { - t.Fatal(err) - } - if !info.IsDir() { - t.Fatalf("%s is not a directory", dir) - } - if got := info.Mode().Perm(); got != 0700 { - t.Fatalf("permissions = %o; want 700", got) - } -} - -func TestEnsureConfigDirReportsCreationFailure(t *testing.T) { - parent := t.TempDir() - file := filepath.Join(parent, "not-a-directory") - if err := os.WriteFile(file, []byte("x"), 0600); err != nil { - t.Fatal(err) - } - dir := filepath.Join(file, "codex") - err := ensureConfigDir(dir) - if err == nil { - t.Fatal("expected directory creation to fail") - } - if !strings.Contains(err.Error(), "create CODEX_HOME") || !strings.Contains(err.Error(), dir) { - t.Fatalf("error = %q; want CODEX_HOME path context", err) - } -} diff --git a/backend/internal/store/auth_index_test.go b/backend/internal/store/auth_index_test.go new file mode 100644 index 0000000..d884575 --- /dev/null +++ b/backend/internal/store/auth_index_test.go @@ -0,0 +1,92 @@ +package store + +import ( + "database/sql" + "path/filepath" + "testing" +) + +func TestAuthIndexCRUDAndUniqueness(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer s.DB.Close() + first, err := s.CreateAccount("First", "auth-1", "personal") + if err != nil { + t.Fatal(err) + } + if first.AuthIndex != "auth-1" { + t.Fatalf("first = %#v", first) + } + if _, err = s.CreateAccount("Duplicate", "auth-1"); err == nil { + t.Fatal("duplicate auth_index unexpectedly succeeded") + } + second, err := s.CreateAccountWithVisibility("Second", "auth-2", "team", true) + if err != nil { + t.Fatal(err) + } + newIndex := "auth-3" + visible := false + if err = s.UpdateAccountBinding(second.ID, "Updated", &newIndex, "any", &visible); err != nil { + t.Fatal(err) + } + updated, err := s.Account(second.ID) + if err != nil { + t.Fatal(err) + } + if updated.AuthIndex != "auth-3" || updated.DisplayName != "Updated" || updated.PublicVisible || updated.ExpectedKind != "any" { + t.Fatalf("updated = %#v", updated) + } + conflict := "auth-1" + if err = s.UpdateAccountBinding(second.ID, "Updated", &conflict, "any", nil); err == nil { + t.Fatal("conflicting update unexpectedly succeeded") + } + stillUpdated, err := s.Account(second.ID) + if err != nil || stillUpdated.AuthIndex != "auth-3" { + t.Fatalf("failed update changed binding: %#v err=%v", stillUpdated, err) + } + used, err := s.AuthIndexUsed("auth-1", first.ID) + if err != nil || used { + t.Fatalf("exclude current account: used=%v err=%v", used, err) + } +} + +func TestAuthIndexMigrationIsIdempotentAndPreservesAccounts(t *testing.T) { + dir := t.TempDir() + db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db")) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(`CREATE TABLE accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, display_name TEXT NOT NULL, email TEXT, + plan_type TEXT, expected_kind TEXT NOT NULL DEFAULT 'any', public_visible INTEGER NOT NULL DEFAULT 0, + connected INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL + ); INSERT INTO accounts VALUES(7,'Legacy','legacy@example.com','plus','personal',1,1,1,2);`) + if err != nil { + t.Fatal(err) + } + _ = db.Close() + + for i := 0; i < 2; i++ { + s, openErr := Open(dir) + if openErr != nil { + t.Fatal(openErr) + } + account, accountErr := s.Account(7) + if accountErr != nil { + _ = s.DB.Close() + t.Fatal(accountErr) + } + if account.AuthIndex != "" || account.DisplayName != "Legacy" || account.Email == nil || *account.Email != "legacy@example.com" || !account.PublicVisible { + _ = s.DB.Close() + t.Fatalf("migrated account = %#v", account) + } + var indexCount int + if queryErr := s.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_accounts_auth_index'").Scan(&indexCount); queryErr != nil || indexCount != 1 { + _ = s.DB.Close() + t.Fatalf("index count = %d err=%v", indexCount, queryErr) + } + _ = s.DB.Close() + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 52a0812..301304f 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -94,6 +94,7 @@ func (s *Store) migrateAccounts() error { display_name TEXT NOT NULL, email TEXT, plan_type TEXT, + auth_index TEXT NOT NULL DEFAULT '', expected_kind TEXT NOT NULL DEFAULT 'any', public_visible INTEGER NOT NULL DEFAULT 0, connected INTEGER NOT NULL DEFAULT 0, @@ -102,7 +103,7 @@ func (s *Store) migrateAccounts() error { )`); err != nil { return err } - var hasExpectedKind, hasPublicVisible bool + var hasAuthIndex, hasExpectedKind, hasPublicVisible bool rows, qerr := tx.Query("PRAGMA table_info(accounts)") if qerr != nil { return qerr @@ -112,10 +113,16 @@ func (s *Store) migrateAccounts() error { var name, typ string var def any _ = rows.Scan(&cid, &name, &typ, ¬null, &def, &pk) + hasAuthIndex = hasAuthIndex || name == "auth_index" hasExpectedKind = hasExpectedKind || name == "expected_kind" hasPublicVisible = hasPublicVisible || name == "public_visible" } rows.Close() + if !hasAuthIndex { + if _, err = tx.Exec("ALTER TABLE accounts ADD COLUMN auth_index TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + } if !hasExpectedKind { if _, err = tx.Exec("ALTER TABLE accounts ADD COLUMN expected_kind TEXT NOT NULL DEFAULT 'any'"); err != nil { return err @@ -126,13 +133,31 @@ func (s *Store) migrateAccounts() error { return err } } - var count int + if _, err = tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_auth_index ON accounts(auth_index) WHERE auth_index <> ''"); err != nil { + return err + } + // Legacy local app-server accounts have no CPA binding. Preserve their + // metadata and history, but do not report them as connected until an + // administrator assigns a valid authIndex. + if _, err = tx.Exec("UPDATE accounts SET connected=0 WHERE auth_index=''"); err != nil { + return err + } + var count, legacyRows int if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil { return err } if count == 0 { - if _, err = tx.Exec("INSERT INTO accounts(id,display_name,created_at,updated_at) VALUES(1,'默认账号',?,?)", time.Now().Unix(), time.Now().Unix()); err != nil { - return err + for _, table := range []string{"daily_usage", "limit_snapshots"} { + var tableRows int + if err = tx.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&tableRows); err != nil { + return err + } + legacyRows += tableRows + } + if legacyRows > 0 { + if _, err = tx.Exec("INSERT INTO accounts(id,display_name,created_at,updated_at) VALUES(1,'默认账号',?,?)", time.Now().Unix(), time.Now().Unix()); err != nil { + return err + } } } for _, table := range []string{"daily_usage", "limit_snapshots"} { @@ -187,6 +212,7 @@ type Account struct { DisplayName string `json:"displayName"` Email *string `json:"email"` PlanType *string `json:"planType"` + AuthIndex string `json:"authIndex,omitempty"` ExpectedKind string `json:"expectedKind"` PublicVisible bool `json:"publicVisible"` ActualKind string `json:"actualKind"` @@ -198,7 +224,7 @@ type Account struct { } func (s *Store) Accounts() ([]Account, error) { - rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,expected_kind,public_visible,connected,created_at,updated_at FROM accounts ORDER BY id") + rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,auth_index,expected_kind,public_visible,connected,created_at,updated_at FROM accounts ORDER BY id") if e != nil { return nil, e } @@ -206,7 +232,7 @@ func (s *Store) Accounts() ([]Account, error) { out := []Account{} for rows.Next() { var a Account - if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil { + if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.AuthIndex, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil { return nil, e } a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType) @@ -228,8 +254,8 @@ func (s *Store) Accounts() ([]Account, error) { func (s *Store) Account(id int64) (Account, error) { var a Account - err := s.DB.QueryRow("SELECT id,display_name,email,plan_type,expected_kind,public_visible,connected,created_at,updated_at FROM accounts WHERE id=?", id). - Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt) + err := s.DB.QueryRow("SELECT id,display_name,email,plan_type,auth_index,expected_kind,public_visible,connected,created_at,updated_at FROM accounts WHERE id=?", id). + Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.AuthIndex, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt) if err != nil { return Account{}, err } @@ -237,38 +263,46 @@ func (s *Store) Account(id int64) (Account, error) { return a, nil } -func (s *Store) CreateAccount(name string, kinds ...string) (Account, error) { - return s.createAccount(name, false, kinds...) +func (s *Store) CreateAccount(name, authIndex string, kinds ...string) (Account, error) { + return s.createAccount(name, authIndex, false, kinds...) } -func (s *Store) CreateAccountWithVisibility(name, expectedKind string, publicVisible bool) (Account, error) { - return s.createAccount(name, publicVisible, expectedKind) +func (s *Store) CreateAccountWithVisibility(name, authIndex, expectedKind string, publicVisible bool) (Account, error) { + return s.createAccount(name, authIndex, publicVisible, expectedKind) } -func (s *Store) createAccount(name string, publicVisible bool, kinds ...string) (Account, error) { +func (s *Store) createAccount(name, authIndex string, publicVisible bool, kinds ...string) (Account, error) { expectedKind := "any" if len(kinds) > 0 { expectedKind = kinds[0] } now := time.Now().Unix() - r, e := s.DB.Exec("INSERT INTO accounts(display_name,expected_kind,public_visible,created_at,updated_at) VALUES(?,?,?,?,?)", name, expectedKind, publicVisible, now, now) + r, e := s.DB.Exec("INSERT INTO accounts(display_name,auth_index,expected_kind,public_visible,created_at,updated_at) VALUES(?,?,?,?,?,?)", name, authIndex, expectedKind, publicVisible, now, now) if e != nil { return Account{}, e } id, _ := r.LastInsertId() - return Account{ID: id, DisplayName: name, ExpectedKind: expectedKind, PublicVisible: publicVisible, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil + return Account{ID: id, DisplayName: name, AuthIndex: authIndex, ExpectedKind: expectedKind, PublicVisible: publicVisible, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil } func (s *Store) UpdateAccountSettings(id int64, name, expectedKind string) error { return s.UpdateAccountSettingsWithVisibility(id, name, expectedKind, nil) } func (s *Store) UpdateAccountSettingsWithVisibility(id int64, name, expectedKind string, publicVisible *bool) error { + return s.UpdateAccountBinding(id, name, nil, expectedKind, publicVisible) +} + +func (s *Store) UpdateAccountBinding(id int64, name string, authIndex *string, expectedKind string, publicVisible *bool) error { var r sql.Result var e error - if publicVisible == nil { + if authIndex == nil && publicVisible == nil { r, e = s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,updated_at=? WHERE id=?", name, expectedKind, time.Now().Unix(), id) - } else { + } else if authIndex == nil { r, e = s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,public_visible=?,updated_at=? WHERE id=?", name, expectedKind, *publicVisible, time.Now().Unix(), id) + } else if publicVisible == nil { + r, e = s.DB.Exec("UPDATE accounts SET display_name=?,auth_index=?,expected_kind=?,updated_at=? WHERE id=?", name, *authIndex, expectedKind, time.Now().Unix(), id) + } else { + r, e = s.DB.Exec("UPDATE accounts SET display_name=?,auth_index=?,expected_kind=?,public_visible=?,updated_at=? WHERE id=?", name, *authIndex, expectedKind, *publicVisible, time.Now().Unix(), id) } if e != nil { return e @@ -279,6 +313,12 @@ func (s *Store) UpdateAccountSettingsWithVisibility(id int64, name, expectedKind } return nil } +func (s *Store) AuthIndexUsed(authIndex string, excludeID int64) (bool, error) { + var count int + err := s.DB.QueryRow("SELECT COUNT(*) FROM accounts WHERE auth_index=? AND id<>?", authIndex, excludeID).Scan(&count) + return count > 0, err +} + func (s *Store) RenameAccount(id int64, name string) error { var kind string if err := s.DB.QueryRow("SELECT expected_kind FROM accounts WHERE id=?", id).Scan(&kind); err != nil { @@ -323,8 +363,18 @@ func (s *Store) UpdateAccount(id int64, email, plan *string, connected bool) err return e } func (s *Store) DeleteAccount(id int64) error { - _, e := s.DB.Exec("DELETE FROM accounts WHERE id=?", id) - return e + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec("DELETE FROM notifications WHERE dedupe_key GLOB ?", fmt.Sprintf("%d:*", id)); err != nil { + return err + } + if _, err = tx.Exec("DELETE FROM accounts WHERE id=?", id); err != nil { + return err + } + return tx.Commit() } func (s *Store) Get(key string) (string, bool) { diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 39ca447..30a0731 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -3,6 +3,7 @@ package store import ( "context" "database/sql" + "fmt" "path/filepath" "testing" @@ -33,18 +34,26 @@ func TestAccountsAndPerAccountUsage(t *testing.T) { } defer s.DB.Close() accounts, err := s.Accounts() - if err != nil || len(accounts) != 1 || accounts[0].ID != 1 || accounts[0].PublicVisible { - t.Fatalf("default accounts = %#v, %v", accounts, err) + if err != nil || len(accounts) != 0 { + t.Fatalf("fresh accounts = %#v, %v; want no automatic default account", accounts, err) } - second, err := s.CreateAccount("Team workspace") + first, err := s.CreateAccount("Personal workspace", "auth-personal") if err != nil { t.Fatal(err) } - for _, id := range []int64{1, second.ID} { + second, err := s.CreateAccount("Team workspace", "auth-team") + if err != nil { + t.Fatal(err) + } + for _, id := range []int64{first.ID, second.ID} { if _, err = s.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?)", id, "2026-08-13", id*100, 1); err != nil { t.Fatal(err) } } + if _, err = s.DB.Exec(`INSERT INTO notifications + (dedupe_key,channel,kind,status,scheduled_at,body) VALUES(?, 'configured', 'after', 'pending', 1, '{}')`, fmt.Sprintf("%d:codex:primary:1:after", second.ID)); err != nil { + t.Fatal(err) + } if err = s.DeleteAccount(second.ID); err != nil { t.Fatal(err) } @@ -52,6 +61,9 @@ func TestAccountsAndPerAccountUsage(t *testing.T) { if err = s.DB.QueryRow("SELECT COUNT(*) FROM daily_usage WHERE account_id=?", second.ID).Scan(&count); err != nil || count != 0 { t.Fatalf("usage was not cascaded: %d, %v", count, err) } + if err = s.DB.QueryRow("SELECT COUNT(*) FROM notifications WHERE dedupe_key GLOB ?", fmt.Sprintf("%d:*", second.ID)).Scan(&count); err != nil || count != 0 { + t.Fatalf("notifications were not deleted: %d, %v", count, err) + } } func TestAccountKindAndValidation(t *testing.T) { @@ -97,7 +109,7 @@ func TestExistingAccountsGainExpectedKind(t *testing.T) { if err != nil || len(accounts) != 1 { t.Fatalf("accounts = %#v, %v", accounts, err) } - if accounts[0].ExpectedKind != "any" || accounts[0].PublicVisible || accounts[0].ActualKind != "team" || accounts[0].ValidationStatus != "matched" { + if accounts[0].AuthIndex != "" || accounts[0].ExpectedKind != "any" || accounts[0].PublicVisible || accounts[0].Connected || accounts[0].ActualKind != "team" || accounts[0].ValidationStatus != "pending" { t.Fatalf("migrated account = %#v", accounts[0]) } } @@ -109,11 +121,11 @@ func TestAccountVisibilitySettings(t *testing.T) { } defer s.DB.Close() - private, err := s.CreateAccount("私有账号") + private, err := s.CreateAccount("私有账号", "private-auth") if err != nil { t.Fatal(err) } - public, err := s.CreateAccountWithVisibility("公开账号", "team", true) + public, err := s.CreateAccountWithVisibility("公开账号", "public-auth", "team", true) if err != nil { t.Fatal(err) } diff --git a/docker-compose.yml b/docker-compose.yml index 48e3e1e..50bbe11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,11 @@ services: - "8180:8080" volumes: - codex-helper-data:/data + extra_hosts: + - "host.docker.internal:host-gateway" environment: LISTEN_ADDR: ":8080" + CLIPROXY_API_BASE_URL: "${CLIPROXY_API_BASE_URL:-http://host.docker.internal:8317}" + CLIPROXY_API_MANAGEMENT_KEY: "${CLIPROXY_API_MANAGEMENT_KEY:-}" volumes: codex-helper-data: diff --git a/docs/README.md b/docs/README.md index c1f0242..213c725 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ | --- | --- | | 修改启动、路由、中间件、健康检查或后台任务 | [`backend/runtime-and-api.md`](backend/runtime-and-api.md) | | 修改初始化、登录、session、请求来源或秘密 | [`backend/authentication-and-security.md`](backend/authentication-and-security.md) | -| 修改 Codex 登录、账号进程、同步、套餐或用量解析 | [`backend/codex-integration-and-usage.md`](backend/codex-integration-and-usage.md) | +| 修改 CLIProxyAPI 绑定、账号同步、套餐或用量解析 | [`backend/codex-integration-and-usage.md`](backend/codex-integration-and-usage.md) | | 修改 SQLite、提醒、Telegram、SMTP、清理或备份 | [`backend/data-notifications-and-backup.md`](backend/data-notifications-and-backup.md) | | 修改 React 路由、总览、设置、状态或 API 调用 | [`frontend/application.md`](frontend/application.md) | | 开发、构建、测试或检查格式 | [`guides/development-and-validation.md`](guides/development-and-validation.md) | diff --git a/docs/backend/authentication-and-security.md b/docs/backend/authentication-and-security.md index 223dc78..31c7105 100644 --- a/docs/backend/authentication-and-security.md +++ b/docs/backend/authentication-and-security.md @@ -2,7 +2,7 @@ ## 初始化与管理员 -新数据库始终创建账号表中的默认 Codex 账号,但只有 `settings.initialized` 存在才视为完成安装。`POST /api/v1/setup` 在事务中创建唯一 `admin(id=1)`、通用设置和安装标记;用户名至少 3 位、密码至少 10 位。首次初始化没有额外安装令牌,因此初始化完成前不得把实例直接暴露到不可信网络。 +新数据库不预建 Codex 账号;管理员初始化后通过 CPA `authIndex` 添加绑定。只有 `settings.initialized` 存在才视为完成安装。`POST /api/v1/setup` 在事务中创建唯一 `admin(id=1)`、通用设置和安装标记;用户名至少 3 位、密码至少 10 位。首次初始化没有额外安装令牌,因此初始化完成前不得把实例直接暴露到不可信网络。 管理员密码使用 argon2id(3 次、64 MiB、2 lanes、32 字节结果和随机 salt)保存。当前没有改密或找回接口;不要通过新增旁路直接写入明文或弱摘要。 @@ -18,10 +18,10 @@ `security.OpenVault` 首次启动创建权限 `0600` 的 `/data/secret.key`。SMTP 密码和 Telegram Bot Token 使用该 32 字节密钥经 AES-GCM 加密,密文写入 SQLite;GET 和保存响应不得返回秘密明文,空密码/Token 表示保留旧值。 -Codex OAuth 凭据由 app-server 写入各账号隔离的 `CODEX_HOME`,不经过浏览器或 SQLite。`secret.key`、数据库、Codex 目录和日志都可能包含敏感运行信息,不得提交 Git、加入镜像层或复制到前端。 +Codex OAuth 凭据由外部 CLIProxyAPI 保存和刷新,不进入 Codex Helper。每条本地账号记录只保存非秘密的 CPA `authIndex`;`CLIPROXY_API_MANAGEMENT_KEY` 只从服务端环境变量读取,不写入 SQLite、日志、前端状态或响应。`secret.key`、数据库、部署 `.env`、CPA management key 和 CPA 凭据目录都属于敏感运行信息,不得提交 Git、加入镜像层或复制到前端。 ## HTTP 边界 -初始化完成后,标记为公开的账号列表和 Dashboard 以匿名只读方式开放,供公开总览加载;未标记账号对匿名请求不可见。匿名响应不返回邮箱、Codex 认证方式、账号配置校验字段或内部错误。新增账号、设备码登录、同步、删除、公开状态修改、提醒和所有设置接口仍必须经过 `require` 的 session 与来源校验。 +初始化完成后,标记为公开的账号列表和 Dashboard 以匿名只读方式开放,供公开总览加载;未标记账号对匿名请求不可见。匿名响应不返回邮箱、CPA authIndex、Codex 认证方式、账号配置校验字段或内部错误。新增或修改 CPA 绑定、同步、删除、公开状态修改、提醒和所有设置接口仍必须经过 `require` 的 session 与来源校验。 -JSON 解码限制为 1 MiB 并拒绝未知字段。统一安全头包括限制性 CSP、`nosniff`、禁止 iframe 和 same-origin referrer。前端路由、按钮禁用和邮箱掩码均不是服务端授权边界;所有新敏感端点必须在后端经过 `require`,改变 API 方法时还要核对来源头逻辑。 +JSON 解码限制为 1 MiB 并拒绝未知字段。统一安全头包括限制性 CSP、`nosniff`、禁止 iframe 和 same-origin referrer。前端路由、按钮禁用和邮箱掩码均不是服务端授权边界;所有新敏感端点必须在后端经过 `require`,改变 API 方法时还要核对来源头逻辑。公开账号响应必须清空 `authIndex`,避免把 CPA 内部凭据标识暴露给匿名访问者。 diff --git a/docs/backend/codex-integration-and-usage.md b/docs/backend/codex-integration-and-usage.md index cd03514..cda837b 100644 --- a/docs/backend/codex-integration-and-usage.md +++ b/docs/backend/codex-integration-and-usage.md @@ -1,39 +1,53 @@ -# Codex 集成与用量同步 +# CLIProxyAPI 与 Codex 用量同步 -## 账号隔离与进程生命周期 +## 外部依赖与凭据边界 -每个 `accounts` 记录对应一个 `accountRuntime` 和独立 `codex app-server` 子进程。账号 1 为升级兼容固定使用 `/data/codex`;其他账号使用 `/data/accounts//codex`。该目录通过子进程 `CODEX_HOME` 注入,保存并刷新 ChatGPT 登录凭据。 +Codex Helper 不再启动 `codex app-server`,也不保存 Codex OAuth token。所有 Codex 登录、凭据持久化和刷新均由外部 CLIProxyAPI(CPA)负责;本项目只通过环境变量读取: -`ensureReady` 用 lifecycle mutex 串行冷启动:创建目录、启动子进程、在 20 秒内调用 `initialize`,成功后才标记 ready。仅子进程存活不代表协议已初始化;失败进程必须关闭后重试。`syncing` mutex 串行同账号同步并保护 Dashboard,`stateMu` 保护 ready/stopped。删除账号先从应用 map 移除并停止进程,再删除数据库和精确凭据目录。 +- `CLIPROXY_API_BASE_URL`:CPA Management API 根地址。 +- `CLIPROXY_API_MANAGEMENT_KEY`:CPA management key,只进入服务端请求头,不写入 SQLite、日志或前端响应。 -## JSONL 协议与登录 +每条 `accounts` 记录保存一个唯一的非空 `auth_index`。添加或修改绑定时,后端先调用 CPA `GET /v0/management/auth-files?auth_index=...`,确认索引唯一匹配、provider/type 为 `codex`,且凭据未 disabled。CPA 的临时 `unavailable`/error 状态可能正是额度耗尽导致,不能据此拒绝读取,否则仪表盘无法显示 100% 用量和重置时间。账号邮箱、套餐和 ChatGPT account ID 来自该认证条目的安全元数据与 ID token claims;原始 token 不会进入 Codex Helper。 -客户端以 stdio 启动 `codex app-server`,每行一个 JSON 消息。递增 ID 将响应关联到 buffered channel;请求受调用 context 或 20 秒 timeout 控制。旧进程晚退出时不能把替代进程标记为断开。 +旧数据库迁移会为已有账号增加空 `auth_index`。这些账号会保持未连接,直到管理员在设置页绑定 CPA authIndex。新数据库不再自动创建无凭据的默认账号。 -设备码登录调用: +## Management API 代请求 -```text -account/login/start {type:"chatgptDeviceCode"} -``` +后端通过 CPA `POST /v0/management/api-call` 发起上游请求,请求体使用 `auth_index` 选择凭据,并在 Authorization 中传递 `Bearer $TOKEN$` 占位符,由 CPA 注入和刷新真实 access token。关键上游请求是: -前端展示 `verificationUrl` 与 `userCode`。收到 `account/login/completed`、`account/updated` 或 `account/rateLimits/updated` 后异步重拉数据;登录完成最多以 0、1、3 秒退避等待套餐分类可用。官方流程和字段见 [Codex App Server 文档](https://learn.chatgpt.com/docs/app-server)。Dockerfile 固定的 CLI 版本及项目测试仍是实际兼容基线。 +- `GET https://chatgpt.com/backend-api/wham/usage`:套餐、普通限额、代码审查限额、`additional_rate_limits` 和可用重置卡摘要。 +- `GET https://chatgpt.com/backend-api/wham/profiles/me`:Token 活动摘要与每日 bucket。 +- `GET https://chatgpt.com/backend-api/wham/rate-limit-reset-credits`:可选的重置卡详情及到期时间。 -## 同步数据流 +CPA HTTP 层和包装的上游状态分别校验。错误只包含安全的阶段、HTTP 状态和上游错误类型,不包含 management key、OAuth token、原始响应正文或 CPA 凭据内容。请求均有超时和响应体大小限制。 -一次同步依次读取 `account/read`、`account/rateLimits/read` 和 `account/usage/read`: +这些 `/wham/*` 路径属于 ChatGPT/Codex 内部接口,并非稳定公开 API;CLIProxyAPI 版本、当前解析代码和测试是本项目的实际兼容基线。 -- `account/read` 决定连接、邮箱、认证模式和套餐;读取失败会使整次同步失败。 -- 限额读取兼容 `rateLimitsByLimitId` 多 bucket 和旧 `rateLimits` 单 bucket,再把 primary/secondary 展平;同时读取 `rateLimitResetCredits`,只在有可用卡时保留数量和到期时间。失败时保留空限额而不令整次同步失败。 -- 套餐未知时,可从所有可分类且一致的限额 bucket 回填;冲突或未知时必须保持 unknown。 -- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。同步成功后,以当前仍有效的最长限额窗口作为当前重置周期,按每日 bucket 汇总周期起点至当前日期的 Token,写入 Dashboard 的 `currentCycle`;周期外和未来日期不会计入。 -- 每次限额同步写入快照、检测用量百分比显著回落,并更新账号元数据及内存 Dashboard。 +## 限额归一化 -`account/usage/read` 的 optional 指标和 daily buckets 可能暂未提供。仅 API Key 或 Bedrock 登录不能保证读取 ChatGPT 用量;不得在缺失数据时合成调用次数、输入/输出 Token、价格或账单日期。 +一次成功同步会把 `/wham/usage` 转换为现有 `Dashboard.Limits`: -重置卡来自 `account/rateLimits/read` 的 `rateLimitResetCredits`。该信息只保存在内存 Dashboard 中,下一次同步会重新读取;没有可用卡时不返回到 Dashboard。当前项目只展示数量和到期时间,不执行消耗操作。 +- 顶层 `rate_limit` 使用稳定 `limitId=codex`。 +- `code_review_rate_limit` 使用稳定 `limitId=code_review`。 +- `additional_rate_limits[]` 优先使用 `metered_feature` 作为 limit ID,`limit_name` 作为显示名称;例如 Spark 的 `codex_bengalfox`。 +- `primary_window` 和 `secondary_window` 分别保留为 `windowType=primary|secondary`,前端依据窗口时长识别小时和周额度,不假定字段顺序。 +- `limit_window_seconds` 向上取整转换为分钟。 +- `used_percent` 限制在 0–100,但保留小数。 +- 重置时间优先使用 Unix 秒 `reset_at`;缺失时以本次响应时间加 `reset_after_seconds` 推算。 +- `plan_type` 未知时按原值保留,并由现有套餐分类逻辑安全显示为 unknown。 -`currentCycle` 的周期长度和重置时间来自 `account/rateLimits/read`,不硬编码为七天;通常会选择 secondary 周窗口。由于 daily bucket 只有日期没有每次请求时间,周期边界所在日期按整日汇总,因此该值是当前周期的日级统计,不是 Credits 或美元估值。 +可用重置卡数量优先以详情接口的 `available_count` 为准,详情接口失败时可退回 `/wham/usage` 的 `rate_limit_reset_credits.available_count`。只有状态为 available 且能解析 RFC3339 `expires_at` 的记录才进入到期时间列表;详情失败不影响主同步。 -## 套餐类型 +## Token 活动与当前周期 -用户期望类型仅为连接后的校验提示:`any`、`personal`、`team`。当前 personal 包括 free/go/plus/pro/prolite;team 包括 team/business 及两种 self-serve business 标识。未知新套餐必须显示 unknown,不能静默当作某一类型。相同邮箱和相同已知实际类型的多个账号标记 `possibleDuplicate`,但不会自动合并或删除。 +`/wham/profiles/me` 的 `stats` 提供 lifetime、单日峰值、最长任务、连续天数以及 `daily_usage_buckets`。每日 bucket 继续写入按 `account_id` 隔离的 `daily_usage`;optional、`null`、未来新增字段和暂缺 bucket 必须安全降级,不能合成调用次数、输入/输出 Token、价格或账单日期。 + +同步成功后,以当前仍有效的最长限额窗口作为当前重置周期,按每日 bucket 汇总周期起点至当前日期的 Token,写入 `Dashboard.currentCycle`。周期边界只有日期粒度,因此边界日按整日汇总。额度和 Profile Token 活动来自不同上游聚合链路,数据新鲜度可能不同。 + +## 调度、并发与失败 + +应用启动后异步同步全部已绑定账号,之后固定每五分钟同步;手动同步和 Telegram 立即刷新使用同一链路。每个账号的 `syncing` mutex 串行同步并保护内存 Dashboard,不同账号仍保持数据库、历史和提醒隔离。 + +`auth_index` 缺失、CPA 未配置、认证条目不可用或核心 `/wham/usage` 失败会使本次同步失败:已有 Dashboard 保留并标记 `stale=true`,内部错误仅对已登录管理员可见。`/wham/profiles/me` 的 Token 活动和重置卡详情都是可选数据;Profile 失败时同一进程内保留上次成功的摘要和每日历史,没有旧值时降级为空,重置卡详情失败时退回用量接口的可用数量。两者都不会隐藏已成功读取的额度窗口。成功同步会清除 stale/error、写入限额快照、检测百分比回落并更新账号元数据。 + +删除账号只删除 Codex Helper 中的绑定、数据库历史和内存 Dashboard,不调用 CPA 删除认证文件。CPA OAuth 凭据的删除和恢复必须在 CLIProxyAPI 中单独执行。 diff --git a/docs/backend/data-notifications-and-backup.md b/docs/backend/data-notifications-and-backup.md index 037cbe1..d682550 100644 --- a/docs/backend/data-notifications-and-backup.md +++ b/docs/backend/data-notifications-and-backup.md @@ -6,18 +6,18 @@ - `settings` 保存通用、SMTP、Telegram、绑定码及安装标记;秘密单独以密文 key 保存。 - `admin` 与 `sessions` 保存唯一管理员和登录会话。 -- `accounts` 保存 Codex 连接元数据、期望套餐类型和 `public_visible`;该字段默认 `0`,旧账号迁移后保持私有,只有管理员明确开启后才进入匿名总览。 +- `accounts` 保存唯一 CPA `auth_index`、Codex 连接元数据、期望套餐类型和 `public_visible`;公开响应会隐藏 `auth_index`。旧账号迁移后索引为空且保持私有,只有管理员绑定 CPA 凭据并明确开启后才进入匿名总览。 - `daily_usage` 和 `limit_snapshots` 按 `account_id` 保存历史,删除账号时级联删除。 - `notifications` 保存稳定去重键、调度时间、结构化消息、状态、次数和错误。 - `telegram_updates` 保存 Bot API offset。 -启动迁移必须幂等并兼容早期单账号库:创建默认账号 1,把旧用量和限额数据迁入该账号,补 `expected_kind` 和通知 body。schema 变化应增加覆盖旧结构且保留已有行的测试。 +启动迁移必须幂等并兼容早期单账号库:已有默认账号 1 及其旧用量、限额数据继续保留,账号行补空 `auth_index`、`expected_kind` 和通知 body;新数据库不再主动创建默认账号。schema 变化应增加覆盖旧结构且保留已有行的测试。 ## 清理与备份 每分钟调度器根据 `retentionDays` 清理旧限额、通知和每日用量;允许范围为 30–365 天。`maintenance/backup` 使用 SQLite `VACUUM INTO` 创建独立一致性快照,包含已提交 WAL 数据且不中断写入。 -数据库快照不包含 `/data/secret.key`、`/data/codex` 或 `/data/accounts/*/codex`,因此不能单独恢复通知凭据和 Codex 登录。完整灾难恢复必须停止容器并备份、恢复整个 `/data`,同时保持 UID `10001` 可读。不得把 `docker compose down -v` 写成普通升级步骤。 +数据库快照不包含 `/data/secret.key`,因此不能单独恢复通知凭据。Codex OAuth 凭据位于外部 CLIProxyAPI,不属于 Codex Helper 备份;两套服务必须分别备份。恢复 Codex Helper 必须停止容器并备份、恢复整个 `/data`,同时保持 UID `10001` 可读。不得把 `docker compose down -v` 写成普通升级步骤。 ## 提醒生成与重试 diff --git a/docs/backend/runtime-and-api.md b/docs/backend/runtime-and-api.md index 8a3fc5a..4fcc5e9 100644 --- a/docs/backend/runtime-and-api.md +++ b/docs/backend/runtime-and-api.md @@ -1,26 +1,30 @@ -# 后端运行与 API +# 后端运行时与 API ## 启动与关闭 -入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、为数据库中的每个账号创建运行时对象,再组装 HTTP server。`Run` 启动 app-server 保活、后台调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭、停止所有账号进程并关闭数据库。 +入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、创建 CLIProxyAPI 客户端、为数据库中的每个账号创建内存 Dashboard 运行时,再组装 HTTP server。`Run` 启动首次账号同步、后台调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭并关闭数据库。 `codex-helper healthcheck` 将 `${LISTEN_ADDR:-:8080}` 的通配地址转换为 `127.0.0.1` 并请求 `/health/live`,供 Docker `HEALTHCHECK` 使用。 -## 请求链路 +## 组件职责 -- `internal/app/app.go`:应用生命周期、账号运行时、路由、静态前端和通用 HTTP 辅助函数。 -- `internal/app/api.go`:`/api/v1/` 分派、初始化、会话、账号和用量同步。 +- `internal/app/app.go`:应用生命周期、账号内存运行时、调度和 HTTP 路由。 +- `internal/app/api.go`:认证后的业务 API、CPA 账号绑定与 Dashboard 同步。 - `internal/app/notify.go`:SMTP、Telegram、提醒生成和发送。 - `internal/store/store.go`:SQLite schema、兼容迁移和数据方法。 -- `internal/codex/client.go`:与 `codex app-server` 的 JSONL 请求/响应关联。 +- `internal/cliproxy/`:CLIProxyAPI Management API、`api-call` 包装及 `/wham/*` 响应解析。 所有路由由 `http.ServeMux` 承载。API 先处理 status、setup、login 和初始化后的账号/Dashboard 匿名只读入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。 ## 后台任务 -- `keepCodex` 每秒检查未就绪的账号,串行完成进程启动与协议初始化,成功后立即同步。 -- `scheduler` 每五分钟固定触发全账号同步;每分钟清理过期历史并异步处理提醒。通用设置中的 `syncMinutes` 仅为旧客户端兼容字段,不改变固定调度周期。 +- 应用启动后异步同步全部已绑定 CPA authIndex,避免外部服务暂时不可用阻止 HTTP 启动。 +- 独立同步调度器每五分钟固定触发全账号同步,最多并发同步四个账号;通用设置中的 `syncMinutes` 仅为旧客户端兼容字段,不改变固定周期。 +- 独立维护调度器每分钟清理过期历史并异步处理提醒,CPA 同步变慢不得阻塞该分钟任务。 - `telegramLoop` 使用 Bot API long polling;仅已配置 Token 才请求更新。 -- app-server 的登录、账号和限额通知会触发带短退避的同步;多次失败将内存 Dashboard 标为 stale 并记录 `lastError`。 -这些 goroutine 都以应用 context 为退出边界。修改调度时必须避免无限阻塞、重复启动、停止后重启和同一账号并发同步。 +这些 goroutine 都以应用 context 为退出边界。每账号 `syncing` mutex 串行手动同步、定时同步和 Dashboard 读取;修改调度时必须避免无限阻塞、重复启动和同一账号并发同步。CPA 同步失败会保留旧 Dashboard 并标记 stale,不得清空仍有效的历史快照。 + +## 状态语义 + +`/health/live` 只表示 HTTP 进程存活;`/health/ready` 还检查 SQLite,并返回 `cpa` 布尔值。`/api/v1/system/status` 也返回同一 `cpa` 配置状态。该字段只表示 CPA base URL 和 management key 均已配置,不代表每个 authIndex 或上游 `/wham/*` 当前可用;具体账号状态由账号列表和 Dashboard 的 `connected`、`stale`、`lastError` 表达。 diff --git a/docs/frontend/application.md b/docs/frontend/application.md index 4794914..cf41101 100644 --- a/docs/frontend/application.md +++ b/docs/frontend/application.md @@ -18,9 +18,9 @@ API 类型精确区分后端 `null` 与 optional,并为秘密设置拆分读 ## 总览与设置 -总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 Token 合计、本周期单日峰值、其他摘要及每日 Token 图;账号有可用重置卡时,详情额外显示卡片数量和到期时间,公开账号的匿名总览也会显示。当前周期 Token 与本周期单日峰值都由后端按 app-server 的有效最长限额窗口和每日 bucket 汇总,不包含周期外或未来日期;详情会显示周期起止时间和“按每日数据汇总”提示。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。匿名公开 Dashboard 仍隐藏邮箱、认证方式和内部错误。 +总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 Token 合计、本周期单日峰值、其他摘要及每日 Token 图;账号有可用重置卡时,详情额外显示卡片数量和到期时间,公开账号的匿名总览也会显示。当前周期 Token 与本周期单日峰值都由后端按 CPA 返回的有效最长限额窗口和 `/wham/profiles/me` 每日 bucket 汇总,不包含周期外或未来日期;详情会显示周期起止时间和“按每日数据汇总”提示。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。匿名公开 Dashboard 仍隐藏邮箱、认证方式和内部错误。 -设置页四个 tab 全部保持挂载,以保留未提交表单状态;非活动 panel 使用 `aria-hidden` 和 `inert` 隔离。tab 支持方向键、Home 和 End,程序化切换不得改变页面滚动和布局。账号删除必须保留不可撤销确认,并清理正在显示的设备码和本地账号选择。 +设置页四个 tab 全部保持挂载,以保留未提交表单状态;非活动 panel 使用 `aria-hidden` 和 `inert` 隔离。tab 支持方向键、Home 和 End,程序化切换不得改变页面滚动和布局。Codex 设置通过 CPA `authIndex` 添加和修改本地绑定,不再提供设备码登录或退出按钮。账号删除必须明确说明只删除本地绑定与历史、不会删除 CLIProxyAPI 凭据,并清理本地账号选择。 ## 验证重点 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index 982d18c..b4f877b 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -4,7 +4,7 @@ ## 镜像结构 -`Dockerfile` 有四个阶段:Node 24.19.0 构建 React 静态资源;Go 1.26.0 以 `CGO_ENABLED=0` 构建后端;Node 阶段安装固定 `@openai/codex`;最终 Debian bookworm 镜像只包含 CA、时区、后端、Node runtime 和 Codex 包。`APP_VERSION` 构建参数通过 Go linker 注入状态 API,未指定时默认为 `0.3.0`,前端在品牌区域显示该版本。 +`Dockerfile` 有三个阶段:Node 24.19.0 构建 React 静态资源;Go 1.26.0 以 `CGO_ENABLED=0` 构建后端;最终 Debian bookworm 镜像只包含 CA、时区和后端二进制,不再包含 Node runtime 或 Codex CLI。`APP_VERSION` 构建参数通过 Go linker 注入状态 API,未指定时默认为 `0.3.0`,前端在品牌区域显示该版本。 前端产物复制到 `backend/internal/web/dist` 后嵌入二进制。运行层使用 UID `10001` 的 system 用户 `helper`,默认 `DATA_DIR=/data`、`LISTEN_ADDR=:8080`,并暴露 `/data` volume 和 8080。健康检查调用二进制自身的 `healthcheck` 子命令。 @@ -22,7 +22,7 @@ bash push-image.sh 0.3.0 ## Compose 与持久化 -`docker-compose.yml` 本地构建 `codex-helper:latest`,将宿主机 8180 映射到容器 8080,并把命名卷 `codex-helper-data` 挂载到 `/data`。全部数据库、密钥、Codex 配置和多账号凭据都依赖这个卷。 +`docker-compose.yml` 本地构建 `codex-helper:latest`,将宿主机 8180 映射到容器 8080,并把命名卷 `codex-helper-data` 挂载到 `/data`。该卷保存数据库和通知解密密钥;Codex OAuth 凭据由外部 CLIProxyAPI 保存。Compose 通过 `CLIPROXY_API_BASE_URL` 和 `CLIPROXY_API_MANAGEMENT_KEY` 注入 CPA Management API 配置,并为宿主机 CPA 提供 `host.docker.internal` 映射。 升级应使用: @@ -35,10 +35,10 @@ docker compose up -d --build ## 网络与安全 -应用自身监听 HTTP。公网部署应放在 HTTPS 反向代理后,初始化前限制访问来源,并保证到 OpenAI 登录/Codex 服务、Telegram Bot API 和所选 SMTP 服务的出站连接。当前只有 `LISTEN_ADDR` 是 Compose 显式环境变量;运行配置由前端保存到数据库。 +应用自身监听 HTTP。公网部署应放在 HTTPS 反向代理后,初始化前限制访问来源,并保证到 CLIProxyAPI、Telegram Bot API 和所选 SMTP 服务的出站连接。CPA 自身负责访问 ChatGPT Codex 服务。通用和通知配置由前端保存到数据库;CPA 根地址和 management key 只通过环境变量提供。 -设备码登录不需要 OpenAI API Key。Codex app-server 为每个 `CODEX_HOME` 管理 ChatGPT token;不要把这些目录暴露为静态文件或外部共享目录。 +`CLIPROXY_API_MANAGEMENT_KEY` 不得写入镜像、Compose 明文仓库配置、日志或前端。Codex Helper 从独立容器访问 CPA 时,CPA 必须启用带 secret key 的 Management API,并允许来自该容器网络的远程管理请求。只开放必要网络路径,不应把 CPA Management API 直接暴露到公网。 ## 备份与恢复 -维护接口下载的 SQLite 快照适合查看或数据库级备份,但不包含解密密钥和 Codex 凭据。完整恢复步骤是:停止容器、备份或恢复整个 `/data`、确认 UID `10001` 可读写、再启动并检查 `/health/live`、`/health/ready`、管理员登录和各账号连接。恢复过程不得只替换数据库而遗失对应 `secret.key`。 +维护接口下载的 SQLite 快照适合查看或数据库级备份,但不包含解密密钥,也不包含外部 CPA 的 Codex 凭据。Codex Helper 恢复步骤是:停止容器、备份或恢复整个 `/data`、确认 UID `10001` 可读写、恢复正确的 CPA 环境变量,再启动并检查 `/health/live`、`/health/ready`、管理员登录和各账号连接。CLIProxyAPI 需要按其自身机制单独备份和恢复。恢复过程不得只替换数据库而遗失对应 `secret.key`。 diff --git a/docs/guides/development-and-validation.md b/docs/guides/development-and-validation.md index 25895a9..fe9b73f 100644 --- a/docs/guides/development-and-validation.md +++ b/docs/guides/development-and-validation.md @@ -64,7 +64,7 @@ docker build -t codex-helper:test . | --- | --- | | 后端普通逻辑 | 相关 Go test、gofmt 检查、build、vet | | SQLite schema、迁移、备份 | 新旧 schema 测试、WAL 快照测试、完整后端门禁 | -| app-server 生命周期或同步 | codex/app runtime 单测、并发和失败重试路径 | +| CLIProxyAPI 客户端或同步 | Management API `httptest`、账号绑定、并发、失败与不泄密路径 | | 鉴权、session 或秘密 | security 与 app 测试、成功和拒绝路径 | | 通知 | reminder 渲染、去重、计划与异常重置测试 | | 前端 | 相关 Vitest、生产 build | diff --git a/docs/reference/code-review-rules.md b/docs/reference/code-review-rules.md index 4d50700..cc170b0 100644 --- a/docs/reference/code-review-rules.md +++ b/docs/reference/code-review-rules.md @@ -25,9 +25,10 @@ findings 优先,按 `P0` 至 `P3` 排序。每条必须包含严重级别和 - 匿名端点是否意外扩大;session、过期检查和非只读来源头是否在所有路径生效;cookie 改动是否适配 HTTPS 反代。 - setup 是否事务化且不能覆盖管理员;登录限流是否存在竞态、无限内存或错误信任代理头。 - SQLite migration 是否可从旧 schema 启动且保留数据;查询是否带正确 `account_id`;rows、transaction 和临时文件是否关闭。 -- 删除账号是否精确停止 runtime、级联历史并删除正确目录,尤其是账号 1 的旧路径。 -- app-server 生命周期是否可能双启动、停止后重启、死锁或由旧进程回调污染新进程;pending 请求在所有结束路径是否释放。 -- app-server optional/null、多 bucket、未知套餐和部分接口失败是否降级,而非生成错误数据或清除有效凭据。 +- 删除账号是否只删除精确的本地 runtime 和级联历史,且不会调用 CPA 删除凭据或误导用户。 +- CPA authIndex 是否在创建/修改前验证唯一、provider、状态和本地重复;失败是否可能留下半绑定账号。 +- CPA Management API 是否有 timeout、响应体大小限制、包装状态与上游状态双重校验;错误或日志是否泄露 management key、OAuth token 或原始正文。 +- `/wham/*` optional/null、多 bucket、未知套餐和部分接口失败是否按契约降级,而非生成错误数据或清除有效绑定。 - 提醒是否稳定去重、限定六小时重试、正确区分 before/after/detected reset;多渠道部分失败是否按既定语义重试。 - SMTP、Telegram 错误是否有 timeout、TLS 和 HTML 转义;设置接口、日志和错误是否泄露秘密。 - 备份是否包含 committed WAL 且不阻塞写入;文案是否误称数据库快照为完整恢复包。 @@ -35,15 +36,15 @@ findings 优先,按 `P0` 至 `P3` 排序。每条必须包含严重级别和 ## 前端专项 - 初始化、未登录、登录态路由是否无闪烁或循环;401 后是否进入可恢复状态。 -- 切换或删除账号时,旧 Dashboard、设备码和 localStorage 是否清理;异步旧响应是否覆盖新账号。 +- 切换或删除账号时,旧 Dashboard 和 localStorage 是否清理;异步旧响应是否覆盖新账号。 - API 类型是否准确表达 `null`、optional、unknown 和空列表;错误响应是否可能被当作成功。 - 邮箱是否在所有可见位置掩码;服务端文本和外部 URL 是否以安全方式渲染。 -- effect、30 秒 polling、设备码 polling、timer 和 event handler 是否在卸载时清理。 +- effect、30 秒 polling、timer 和 event handler 是否在卸载时清理。 - 设置 tab 是否保持键盘操作、`inert` 隔离、表单状态、滚动位置和窄屏无溢出。 ## 部署与验证专项 -- 固定 Go、Node、Codex CLI 和 Playwright 版本是否同步 Dockerfile、lockfile 与文档;架构和静态构建是否匹配运行层。 +- 固定 Go、Node 和 Playwright 版本是否同步 Dockerfile、lockfile 与文档;架构和静态构建是否匹配运行层。 - 静态前端是否在 Go build 前正确复制;`.dockerignore` 是否会丢失必须资源或带入运行数据。 - 最终镜像是否继续非 root,`/data` 权限是否兼容 UID `10001`;Compose 升级是否复用原卷。 - 新环境变量是否同步代码、Compose、README 和部署文档;秘密是否可能进入 build arg、镜像层或日志。 diff --git a/docs/reference/engineering-invariants.md b/docs/reference/engineering-invariants.md index 0e32620..8f6f97f 100644 --- a/docs/reference/engineering-invariants.md +++ b/docs/reference/engineering-invariants.md @@ -12,17 +12,19 @@ ## 数据与账号 - `internal/store/store.go` 是 SQLite schema 和兼容迁移的事实来源。启动迁移必须幂等、保留旧数据并启用 foreign keys。 -- 旧单账号数据迁到账号 1;账号 1 的凭据路径永久为 `/data/codex`,不能统一搬到 `accounts/1`。 -- 删除账号必须先停止并移除精确 runtime,再删除该账号数据库行和精确凭据目录;不得使用未校验路径、glob 或宽泛递归删除。 +- 旧单账号数据及账号 1 历史必须保留;迁移只为旧账号补空 `auth_index`,不得因切换 CPA 删除旧快照。新数据库不再预建空账号。 +- 每个非空 CPA `auth_index` 最多绑定一个本地账号。添加或修改绑定前必须验证索引唯一指向未禁用的 Codex auth,失败不得留下半创建或半更新记录;CPA 的 transient unavailable/error 可能表示额度耗尽,不能阻止额度读取。修改到不同 authIndex 时必须原子清除该本地账号的旧用量、限额和提醒历史,禁止串身份。 +- 删除账号只删除本地 runtime、数据库行及其级联历史,绝不能调用 CPA 删除凭据或误导用户认为 CPA auth 已删除。 - `daily_usage` 与 `limit_snapshots` 以 `account_id` 隔离。任何查询、更新、提醒 key 或清理不得串账号。 - `expectedKind` 只是校验期望;未知套餐保持 unknown。相同邮箱提示重复不能成为自动合并依据。 -## Codex 运行时 +## CLIProxyAPI 运行时 -- 每账号一套 app-server 和 `CODEX_HOME`。进程存活与协议 ready 是不同状态,初始化失败必须关闭旧进程。 -- lifecycle mutex 串行启动/停止,syncing mutex 串行同步和 Dashboard 访问,state mutex 保护 ready/stopped;不得以无锁读写替换。 -- 旧子进程晚退出不能断开新子进程或失败新请求。请求取消和 20 秒 timeout 必须清除 pending channel。 -- `account/read` 是同步的硬依赖;rate limits 与 usage 是可缺失数据。optional、`null`、多 bucket 和未知字段必须安全降级。 +- Codex Helper 不保存、返回或记录 CPA management key、OAuth access token、refresh token 或原始认证文件。公开账号响应必须清空 `authIndex`。 +- CPA base URL 和 management key 只从服务端环境读取。未配置不得阻止 setup/login,但所有账号同步必须明确失败并保留旧 Dashboard 为 stale。 +- 每账号 `syncing` mutex 继续串行同步和 Dashboard 访问;不得以无锁读写替换。 +- auth-files 验证、`api-call` 包装状态和上游状态必须分别检查。请求必须保留超时和响应体大小限制,错误不得包含原始上游正文。 +- `/wham/usage` 是额度同步的硬依赖;`/wham/profiles/me` Token 活动和重置卡详情是可缺失数据。optional、`null`、多 bucket、未知套餐和畸形附加限额必须安全降级或产生不泄密的同步错误,Profile 失败不得隐藏有效限额。 ## 通知、外部输入与秘密 @@ -33,7 +35,7 @@ ## 前端与部署 -- Web 界面中的所有可见账号邮箱继续掩码;Telegram `/account` 当前会向已绑定会话显示完整邮箱。账号切换清空旧 Dashboard;删除账号清除设备码和 localStorage 选择。 +- Web 界面中的所有可见账号邮箱继续掩码;Telegram `/account` 当前会向已绑定会话显示完整邮箱。账号切换清空旧 Dashboard;删除账号清除 localStorage 选择。公开账号响应不得暴露 CPA authIndex。 - 非活动设置 panel 保持 `inert`,tab 键盘和布局稳定性是现有可访问性契约。 - 前端生产资源嵌入 Go 二进制;Docker build stage 的复制顺序变化必须验证实际嵌入的是新产物。 - `/data` 是唯一完整恢复单元。数据库快照不含 `secret.key` 和 Codex 凭据;任何文档不得暗示其可完整灾难恢复。 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 641f9c9..0705302 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -85,8 +85,13 @@ export const post = ( { method: "POST", body: JSON.stringify(value), signal }, timeoutMs, ); -export const put = (path: string, decoder: Decoder, value: unknown) => - api(path, decoder, { method: "PUT", body: JSON.stringify(value) }); +export const put = ( + path: string, + decoder: Decoder, + value: unknown, + timeoutMs = 30_000, +) => + api(path, decoder, { method: "PUT", body: JSON.stringify(value) }, timeoutMs); export const del = (path: string, decoder: Decoder) => api(path, decoder, { method: "DELETE" }); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a64ed3c..6204ccf 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -39,14 +39,12 @@ import { decodeAction, decodeCode, decodeDashboard, - decodeDeviceLogin, decodeGeneral, decodeOK, decodeSMTP, decodeTelegram, type Account, type Dashboard as Dash, - type DeviceLogin, type GeneralSettings, type Limit, type SMTPSettingsForm, @@ -1083,8 +1081,8 @@ function General() { } function CodexSettings() { const [xs, setXs] = useState([]), - [deviceLogin, setDeviceLogin] = useState(null), [active, setActive] = useState(0), + [newAuthIndex, setNewAuthIndex] = useState(""), [newKind, setNewKind] = useState<"personal" | "team">("team"), [newPublicVisible, setNewPublicVisible] = useState(false), [busy, setBusy] = useState(false), @@ -1097,81 +1095,62 @@ function CodexSettings() { useEffect(() => { void load().catch((error) => setErr(toErrorMessage(error))); }, []); - useEffect(() => { - if (!deviceLogin) return; - const started = Date.now(); - const controller = new AbortController(); - let timer = 0; - const poll = async () => { - try { - const accounts = await load(controller.signal); - const account = accounts.find((x) => x.id === deviceLogin.accountId); - if (!account) { - setDeviceLogin(null); - return; - } - if (["matched", "mismatch"].includes(account.validationStatus)) return; - if (Date.now() - started >= 120_000) { - setErr("设备码登录检测已超时,请重新生成设备码"); - return; - } - timer = window.setTimeout(poll, 2000); - } catch (error) { - if (!controller.signal.aborted) { - setErr(toErrorMessage(error)); - timer = window.setTimeout(poll, 2000); - } - } - }; - timer = window.setTimeout(poll, 2000); - return () => { - controller.abort(); - window.clearTimeout(timer); - }; - }, [deviceLogin]); - const login = async (id: number) => { + const updateAccount = async ( + account: Account, + changes: Partial< + Pick< + Account, + "displayName" | "authIndex" | "expectedKind" | "publicVisible" + > + >, + ) => { try { - setBusy(true); - setActive(id); setErr(""); - setDeviceLogin(null); - const result = await post( - `accounts/${id}/login/device`, - decodeDeviceLogin, - {}, - undefined, - 60_000, + await put( + `accounts/${account.id}`, + decodeOK, + { + displayName: changes.displayName ?? account.displayName, + authIndex: changes.authIndex ?? account.authIndex, + expectedKind: changes.expectedKind ?? account.expectedKind, + publicVisible: changes.publicVisible ?? account.publicVisible, + }, + changes.authIndex === undefined ? 30_000 : 60_000, ); - setDeviceLogin({ accountId: id, ...result }); - } catch (q) { - setErr(toErrorMessage(q)); - } finally { - setBusy(false); + await load(); + return true; + } catch (error) { + setErr(toErrorMessage(error)); + return false; } }; const add = async () => { + const authIndex = newAuthIndex.trim(); + if (!authIndex) { + setErr("请输入 CPA authIndex"); + return; + } try { setBusy(true); setActive(0); setErr(""); - setDeviceLogin(null); - const x = await post("accounts", (value) => decodeAccounts([value])[0], { - displayName: `账号 ${xs.length + 1}`, - expectedKind: newKind, - publicVisible: newPublicVisible, - }); - await load(); - setActive(x.id); - const result = await post( - `accounts/${x.id}/login/device`, - decodeDeviceLogin, - {}, + const account = await post( + "accounts", + (value) => decodeAccounts([value])[0], + { + displayName: "", + authIndex, + expectedKind: newKind, + publicVisible: newPublicVisible, + }, undefined, 60_000, ); - setDeviceLogin({ accountId: x.id, ...result }); - } catch (q) { - setErr(toErrorMessage(q)); + setNewAuthIndex(""); + await load(); + setActive(account.id); + } catch (error) { + setErr(toErrorMessage(error)); } finally { setBusy(false); } @@ -1181,21 +1160,32 @@ function CodexSettings() {

Codex 账户与工作区

- 个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。 + 账号凭据和 OAuth 刷新由 CLIProxyAPI 管理;这里通过 CPA authIndex + 绑定需要展示的 Codex 账号。

-

添加新连接

-

选择要连接的订阅类型,然后使用设备码完成授权。

+

添加 CPA 账号

+

authIndex 可从 CLIProxyAPI 的认证文件列表中取得。

+
{xs.length === 0 && (
- 尚未添加 Codex 账号,请使用上方的“添加账号”创建连接。 + 尚未绑定 CPA Codex 账号,请在上方输入 authIndex。
)} - {xs.map((x) => ( -
+ {xs.map((account) => ( +
- +
{ - const name = e.target.value.trim(); - if (name && name !== x.displayName) { - await put(`accounts/${x.id}`, decodeOK, { - displayName: name, - expectedKind: x.expectedKind, - publicVisible: x.publicVisible, - }); - void load().catch((error) => - setErr(toErrorMessage(error)), - ); - } + defaultValue={account.displayName} + onBlur={async (event) => { + const input = event.currentTarget; + const displayName = input.value.trim(); + if ( + displayName && + displayName !== account.displayName && + !(await updateAccount(account, { displayName })) + ) + input.value = account.displayName; }} />
- {x.email ? maskEmail(x.email) : "尚未登录"} - {planLabel(x.planType)} + + {account.email + ? maskEmail(account.email) + : "账号信息暂不可用"} + + {planLabel(account.planType)} - {validationLabel(x)} + {validationLabel(account)}
- {x.possibleDuplicate && ( + {account.possibleDuplicate && ( - 同一邮箱已有相同类型连接,请确认没有重复授权同一工作区 + 同一邮箱已有相同类型绑定,请确认 authIndex 没有指向重复凭据 )}
+
-
- {deviceLogin?.accountId === x.id && ( -
- - 在浏览器中访问{" "} - - {deviceLogin.verificationUrl} - - ,然后输入设备码 - - {deviceLogin.userCode} -
- )}
))}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6866a25..bd46524 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1339,12 +1339,20 @@ html[data-theme="dark"] .codebox { .add-account-controls { display: flex; + flex-wrap: wrap; + justify-content: flex-end; align-items: end; gap: 10px; } .add-account-controls label { - min-width: 250px; + flex: 1 1 220px; + min-width: 220px; +} + +.add-account-controls .checks { + flex: 0 1 auto; + min-width: 0; } .add-account-controls button { @@ -1419,6 +1427,11 @@ html[data-theme="dark"] .codebox { min-width: 285px; } +.account-card-actions > label:not(.checks) input, +.account-card-actions > label:not(.checks) select { + width: 100%; +} + .account-buttons { display: flex; gap: 8px; diff --git a/frontend/src/types.test.ts b/frontend/src/types.test.ts index 9f3243b..0ebac67 100644 --- a/frontend/src/types.test.ts +++ b/frontend/src/types.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { ApiError, toErrorMessage } from "./api"; import { decodeAction, + decodeAccount, decodeDashboard, - decodeDeviceLogin, decodeGeneral, decodeTelegram, } from "./types"; @@ -77,19 +77,21 @@ describe("API decoders", () => { ).toThrow("theme格式无效"); }); - it("只接受 HTTPS 设备授权地址", () => { - expect(() => - decodeDeviceLogin({ - verificationUrl: "http://example.com", - userCode: "ABC", - }), - ).toThrow("HTTPS"); - expect( - decodeDeviceLogin({ - verificationUrl: "https://example.com", - userCode: "ABC", - }).userCode, - ).toBe("ABC"); + it("解码账号的 CPA authIndex", () => { + const account = decodeAccount({ + id: 1, + displayName: "主账号", + authIndex: "index-1", + email: null, + planType: "plus", + expectedKind: "personal", + publicVisible: false, + actualKind: "personal", + validationStatus: "matched", + possibleDuplicate: false, + connected: true, + }); + expect(account.authIndex).toBe("index-1"); }); it("保留 Telegram 操作警告", () => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0e332a1..db05915 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -17,12 +17,13 @@ export type AsyncState = export interface Status { initialized: boolean; - appServer: boolean; + cpa: boolean; version: string; } export interface Account { id: number; displayName: string; + authIndex: string; email: string | null; planType: string | null; expectedKind: ExpectedKind; @@ -32,11 +33,6 @@ export interface Account { possibleDuplicate: boolean; connected: boolean; } -export interface DeviceLogin { - accountId: number; - verificationUrl: string; - userCode: string; -} export interface Limit { limitId: string; limitName: string | null; @@ -134,7 +130,7 @@ export const decodeStatus: Decoder = (value) => { const x = record(value); return { initialized: boolean(x.initialized, "initialized"), - appServer: boolean(x.appServer, "appServer"), + cpa: boolean(x.cpa, "cpa"), version: string(x.version, "version"), }; }; @@ -143,6 +139,7 @@ export const decodeAccount: Decoder = (value) => { return { id: number(x.id, "id"), displayName: string(x.displayName, "displayName"), + authIndex: string(x.authIndex ?? "", "authIndex"), email: nullableString(x.email, "email"), planType: nullableString(x.planType, "planType"), expectedKind: enumValue( @@ -296,20 +293,6 @@ export const decodeSMTP: Decoder = (value) => { configured: boolean(x.configured, "configured"), }; }; -export const decodeDeviceLogin: Decoder> = ( - value, -) => { - const x = record(value), - verificationUrl = string(x.verificationUrl, "verificationUrl"); - let url: URL; - try { - url = new URL(verificationUrl); - } catch { - throw new Error("设备授权地址无效"); - } - if (url.protocol !== "https:") throw new Error("设备授权地址必须使用 HTTPS"); - return { verificationUrl, userCode: string(x.userCode, "userCode") }; -}; export const decodeOK: Decoder<{ ok: boolean }> = (value) => { const x = record(value); return { ok: boolean(x.ok, "ok") }; diff --git a/frontend/tests/settings-layout.spec.ts b/frontend/tests/settings-layout.spec.ts index 5fd961f..6722dad 100644 --- a/frontend/tests/settings-layout.spec.ts +++ b/frontend/tests/settings-layout.spec.ts @@ -3,7 +3,7 @@ import { expect, test, type Page } from "@playwright/test"; const responses: Record = { "system/status": { initialized: true, - appServer: true, + cpa: true, version: "0.3.0-beta.1", }, "auth/me": { username: "admin" }, @@ -20,6 +20,7 @@ const responses: Record = { { id: 1, displayName: "默认账号", + authIndex: "index-default", email: "test@example.com", planType: "plus", expectedKind: "personal", @@ -56,7 +57,7 @@ async function openSettings(page: Page, version = "0.3.0-beta.1") { await new Promise((resolve) => setTimeout(resolve, 40)); if (key === "system/status") { await route.fulfill({ - json: { initialized: true, appServer: true, version }, + json: { initialized: true, cpa: true, version }, }); return; } @@ -159,7 +160,7 @@ test("shows every account as a collapsible summary on the overview", async ({ const key = new URL(request.url()).pathname.replace("/api/v1/", ""); if (key === "system/status") return route.fulfill({ - json: { initialized: true, appServer: true, version: "test" }, + json: { initialized: true, cpa: true, version: "test" }, }); if (key === "auth/me") return route.fulfill({ json: { username: "admin" } }); @@ -247,7 +248,7 @@ test("shows a read-only overview before login", async ({ page }) => { const key = new URL(route.request().url()).pathname.replace("/api/v1/", ""); if (key === "system/status") return route.fulfill({ - json: { initialized: true, appServer: true, version: "test" }, + json: { initialized: true, cpa: true, version: "test" }, }); if (key === "auth/me") return route.fulfill({ status: 401, json: { error: "未登录" } }); @@ -354,7 +355,7 @@ test("keeps the authenticated brand on one line with a release version", async ( test("shows the build version before setup", async ({ page }) => { await page.route("**/api/v1/system/status", (route) => route.fulfill({ - json: { initialized: false, appServer: false, version: "test" }, + json: { initialized: false, cpa: false, version: "test" }, }), ); await page.goto("/"); @@ -370,7 +371,7 @@ test("shows the build version before setup", async ({ page }) => { test("shows the build version on login", async ({ page }) => { await page.route("**/api/v1/system/status", (route) => route.fulfill({ - json: { initialized: true, appServer: false, version: "test" }, + json: { initialized: true, cpa: false, version: "test" }, }), ); await page.route("**/api/v1/auth/me", (route) => @@ -633,56 +634,50 @@ test("programmatic tab changes do not clamp an existing scroll position", async .toEqual(before.content); }); -test("deleting a newly added account clears its device authorization", async ({ +test("deleting a newly added account keeps the CPA credential external", async ({ page, }) => { let accounts: Array<(typeof responses.accounts)[number]> = []; + let createdAuthIndex = ""; await page.route("**/api/v1/**", async (route) => { const request = route.request(); const key = new URL(request.url()).pathname.replace("/api/v1/", ""); if (key === "accounts" && request.method() === "GET") return route.fulfill({ json: accounts }); if (key === "accounts" && request.method() === "POST") { + const body = request.postDataJSON() as { authIndex: string }; + createdAuthIndex = body.authIndex; const account = { ...responses.accounts[0], id: 2, - displayName: "账号 1", - email: "", - connected: false, - validationStatus: "pending" as const, + displayName: "CPA 账号", + authIndex: body.authIndex, }; accounts = [account]; return route.fulfill({ json: account }); } - if (key === "accounts/2/login/device") - return route.fulfill({ - json: { - verificationUrl: "https://auth.openai.com/codex/device", - userCode: "PLTJ-7M6I6", - }, - }); if (key === "accounts/2" && request.method() === "DELETE") { accounts = []; return route.fulfill({ json: { ok: true } }); } return route.fulfill({ json: responses[key] ?? {} }); }); - page.on("dialog", (dialog) => dialog.accept()); + page.on("dialog", (dialog) => { + expect(dialog.message()).toContain("不会删除 CLIProxyAPI 中的凭据"); + void dialog.accept(); + }); await page.goto("/settings"); await page.getByRole("tab", { name: "Codex" }).click(); + await page.getByLabel("CPA authIndex", { exact: true }).fill("index-new"); await page.getByRole("button", { name: "添加账号" }).click(); - await expect(page.getByText("PLTJ-7M6I6")).toBeVisible(); - await expect( - page.getByRole("link", { name: "https://auth.openai.com/codex/device" }), - ).toBeVisible(); + await expect.poll(() => createdAuthIndex).toBe("index-new"); + await expect(page.getByLabel("“CPA 账号”的 CPA authIndex")).toHaveValue( + "index-new", + ); - await page.getByRole("button", { name: "删除“账号 1”" }).click(); - await expect(page.getByText("PLTJ-7M6I6")).toHaveCount(0); + await page.getByRole("button", { name: "删除“CPA 账号”" }).click(); await expect( - page.getByRole("link", { name: "https://auth.openai.com/codex/device" }), - ).toHaveCount(0); - await expect( - page.getByText("尚未添加 Codex 账号", { exact: false }), + page.getByText("尚未绑定 CPA Codex 账号", { exact: false }), ).toBeVisible(); }); @@ -690,7 +685,7 @@ test("sets public visibility when adding and editing an account", async ({ page, }) => { let accounts: Array<(typeof responses.accounts)[number]> = []; - let createdBody: { publicVisible?: boolean } | undefined; + let createdBody: { authIndex?: string; publicVisible?: boolean } | undefined; let updatedBody: { publicVisible?: boolean } | undefined; await page.route("**/api/v1/**", async (route) => { const request = route.request(); @@ -698,11 +693,15 @@ test("sets public visibility when adding and editing an account", async ({ if (key === "accounts" && request.method() === "GET") return route.fulfill({ json: accounts }); if (key === "accounts" && request.method() === "POST") { - createdBody = request.postDataJSON() as { publicVisible?: boolean }; + createdBody = request.postDataJSON() as { + authIndex?: string; + publicVisible?: boolean; + }; const account = { ...responses.accounts[0], id: 2, displayName: "账号 1", + authIndex: createdBody.authIndex ?? "", email: "", connected: false, validationStatus: "pending" as const, @@ -711,13 +710,6 @@ test("sets public visibility when adding and editing an account", async ({ accounts = [account]; return route.fulfill({ json: account }); } - if (key === "accounts/2/login/device") - return route.fulfill({ - json: { - verificationUrl: "https://auth.openai.com/codex/device", - userCode: "PLTJ-7M6I6", - }, - }); if (key === "accounts/2" && request.method() === "PUT") { updatedBody = request.postDataJSON() as { publicVisible?: boolean }; accounts = [ @@ -738,8 +730,10 @@ test("sets public visibility when adding and editing an account", async ({ ); await expect(addVisibility).not.toBeChecked(); await addVisibility.check(); + await page.getByLabel("CPA authIndex", { exact: true }).fill("index-public"); await page.getByRole("button", { name: "添加账号" }).click(); await expect.poll(() => createdBody?.publicVisible).toBe(true); + expect(createdBody?.authIndex).toBe("index-public"); const accountCard = page.locator(".account-card"); const accountVisibility = accountCard.locator('input[type="checkbox"]'); @@ -751,6 +745,35 @@ test("sets public visibility when adding and editing an account", async ({ await expect(accountVisibility).not.toBeChecked(); }); +test("changing authIndex confirms local history deletion", async ({ page }) => { + let account = { ...responses.accounts[0] }; + let updatedBody: { authIndex?: string } | undefined; + await page.route("**/api/v1/**", async (route) => { + const request = route.request(); + const key = new URL(request.url()).pathname.replace("/api/v1/", ""); + if (key === "accounts" && request.method() === "GET") + return route.fulfill({ json: [account] }); + if (key === "accounts/1" && request.method() === "PUT") { + updatedBody = request.postDataJSON() as { authIndex?: string }; + account = { ...account, authIndex: updatedBody.authIndex ?? "" }; + return route.fulfill({ json: { ok: true } }); + } + return route.fulfill({ json: responses[key] ?? {} }); + }); + page.on("dialog", (dialog) => { + expect(dialog.message()).toContain("会清除该本地账号已有的 Token 历史"); + void dialog.accept(); + }); + + await page.goto("/settings"); + await page.getByRole("tab", { name: "Codex" }).click(); + const input = page.getByLabel("“默认账号”的 CPA authIndex"); + await input.fill("index-rebound"); + await input.blur(); + await expect.poll(() => updatedBody?.authIndex).toBe("index-rebound"); + await expect(input).toHaveValue("index-rebound"); +}); + test("Codex account cards fit within the viewport", async ({ page }) => { await openSettings(page); await page.getByRole("tab", { name: "Codex" }).click();