Compare commits
11
Commits
1ec9001387
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8997886eef | ||
|
|
0b2f969c6d | ||
|
|
75fba7ab69 | ||
|
|
0a639c1f68 | ||
|
|
54e20f7b8c | ||
|
|
6a711c1fff | ||
|
|
45ceeee936 | ||
|
|
5f50669b98 | ||
|
|
c1985852e1 | ||
|
|
915d26d51d | ||
|
|
bf37999833 |
@@ -4,3 +4,7 @@ frontend/node_modules
|
||||
.DS_Store
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.zip
|
||||
|
||||
@@ -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
|
||||
@@ -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/<id>/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`。
|
||||
- 前端路由和隐藏控件只负责交互,安全边界必须由后端强制执行;账号邮箱在界面中继续掩码显示。
|
||||
|
||||
+1
-8
@@ -8,7 +8,7 @@ COPY frontend/index.html ./index.html
|
||||
RUN npm ci && npm run build
|
||||
|
||||
FROM golang:1.26.0-bookworm AS backend
|
||||
ARG APP_VERSION=0.2.0
|
||||
ARG APP_VERSION=0.3.0
|
||||
WORKDIR /src/backend
|
||||
COPY backend/go.mod backend/go.sum* ./
|
||||
RUN go mod download
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
# Codex Helper
|
||||
|
||||
一个单容器运行的 Codex 账户用量仪表盘。通过 Codex app-server 读取当前 ChatGPT/Codex 账户、套餐、限额窗口、重置时间和每日 Token 历史,并支持 Telegram 与 SMTP 重置提醒。
|
||||
一个单容器运行的 Codex 账户用量仪表盘。通过 CLIProxyAPI(CPA)Management API 读取已托管 Codex 凭据对应的 ChatGPT 账户、套餐、限额窗口、重置时间和每日 Token 历史,并支持 Telegram 与 SMTP 重置提醒。
|
||||
|
||||
## 功能
|
||||
|
||||
- 支持在同一总览页查看多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区)
|
||||
- 展示 Codex 账户、套餐、剩余额度和下次重置时间
|
||||
- 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势
|
||||
- 展示当前重置周期 Token 合计、本周期单日峰值及每日 Token 趋势
|
||||
- 有可用重置卡时展示卡片数量和到期时间
|
||||
- 在本地 SQLite 中保留历史用量和限额快照
|
||||
- 在限额重置前、重置后发送 Telegram 或邮件提醒
|
||||
- 通过 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
|
||||
@@ -46,36 +59,34 @@ http://服务器地址:8180
|
||||
|
||||
首次打开页面时创建管理员账号,并设置所在时区。用户名至少 3 位,密码至少 10 位。
|
||||
|
||||
初始化完成后,首页会显示已勾选“公开显示到未登录总览”的账号;点击“登录后配置”并使用管理员账号登录,才能进入设置中心添加账号、同步数据、修改账号公开状态或配置提醒。未勾选公开的账号只会在登录后的总览中显示。
|
||||
|
||||
> 首次初始化没有额外安装码。创建管理员之前,不要将端口直接暴露到不可信网络。公网部署应使用 HTTPS 反向代理,并限制初始化阶段的访问来源。
|
||||
|
||||
## 连接 Codex 账户
|
||||
|
||||
1. 使用管理员账号登录 Codex Helper。
|
||||
2. 打开“设置中心” → “Codex”。
|
||||
3. 点击“生成设备码”。
|
||||
4. 点击页面显示的 OpenAI 验证地址,或在另一台设备的浏览器中打开该地址。
|
||||
5. 登录需要监控的 ChatGPT/Codex 账户。
|
||||
6. 输入页面显示的一次性设备码并确认授权。
|
||||
7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“刷新全部”或对应账号卡片中的“刷新账号”。
|
||||
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,不会自动猜测类型。
|
||||
|
||||
## 通用设置与提醒时间
|
||||
|
||||
进入“设置中心” → “通用”,可设置:
|
||||
|
||||
- 时区:用于初始化配置;界面时间按浏览器本地时区显示
|
||||
- 同步间隔:1–60 分钟
|
||||
- 自动同步:服务器固定每 5 分钟同步全部账号
|
||||
- 历史保留时间:30、60、90、180 或 365 天
|
||||
- 提前提醒时间:重置前 1–1440 分钟
|
||||
- 是否发送重置前提醒和重置后确认;重置后确认也会通过额度百分比回落识别并提醒官方活动、临时补发等提前重置
|
||||
|
||||
提醒只会针对 Codex app-server 返回的限额窗口发送。发送失败的提醒会在计划时间后的六小时内自动重试。
|
||||
提醒只会针对通过 CLIProxyAPI 读取到的 Codex 限额窗口发送。发送失败的提醒会在计划时间后的六小时内自动重试。
|
||||
|
||||
## 配置 Telegram
|
||||
|
||||
@@ -151,7 +162,8 @@ curl http://localhost:8180/health/ready
|
||||
|
||||
- `codex-helper.db`:管理员、设置、历史用量和通知记录
|
||||
- `secret.key`:用于解密 SMTP 密码和 Telegram Token
|
||||
- `codex/`:Codex 登录凭据及配置
|
||||
|
||||
Codex OAuth 凭据不在该数据卷中,而是保存在 CLIProxyAPI 自己的凭据存储中。
|
||||
|
||||
登录管理页面后,可直接在浏览器访问以下地址下载一致性 SQLite 快照:
|
||||
|
||||
@@ -159,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 桶
|
||||
|
||||
当前接口不提供调用次数、输入 Token、输出 Token、订阅价格或账单续期日,因此总览使用“最长任务时长”作为替代摘要指标。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 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 后仍显示“尚未连接”
|
||||
|
||||
- 等待几秒后点击“刷新全部”或对应账号卡片中的“刷新账号”。
|
||||
- 确认授权的是需要监控的 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 无法绑定
|
||||
|
||||
@@ -229,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`。
|
||||
|
||||
+24
-16
@@ -5,10 +5,10 @@
|
||||
## 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` 匿名可用。status 的其他方法返回 405;其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。当前 dispatcher 只对部分路由显式限制 HTTP method;下文使用“任意方法”或“非 `GET`”的地方是对实际兼容行为的记录。
|
||||
- `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`”的地方是对实际兼容行为的记录。
|
||||
- session 有效期七天,cookie 为 `HttpOnly`、`SameSite=Strict`、`Path=/`;数据库只保存 token 摘要。登录失败按 `RemoteAddr` 在进程内限制为 15 分钟最多 10 次,超限返回 429。
|
||||
- 未命中静态文件的非 API GET 路径返回嵌入的 `index.html`,供前端路由回退。
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
### Account
|
||||
|
||||
```text
|
||||
{id, displayName, email:string|null, planType:string|null,
|
||||
expectedKind:"any"|"personal"|"team",
|
||||
{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",
|
||||
possibleDuplicate:bool, connected:bool, createdAt, updatedAt}
|
||||
@@ -31,19 +31,23 @@
|
||||
account:{email:string|null, authMode:string|null, planType:string|null, connected:bool},
|
||||
limits:[{limitId, limitName:string|null, windowType,
|
||||
usedPercent, windowDurationMinutes, resetsAt, planType:string|null}],
|
||||
currentCycle?:{limitId, windowType, windowDurationMinutes,
|
||||
startedAt, resetsAt, totalTokens},
|
||||
resetCredits?:{availableCount, expiresAt:[Unix seconds]},
|
||||
summary:{lifetimeTokens?, peakDailyTokens?, longestRunningTurnSec?,
|
||||
currentStreakDays?, longestStreakDays?, callCount?, inputTokens?, outputTokens?},
|
||||
usage:[{date,totalTokens,callCount?,inputTokens?,outputTokens?}],
|
||||
fetchedAt, stale, lastError?}
|
||||
```
|
||||
|
||||
时间字段为 Unix 秒。app-server 未提供的摘要字段可以是 `null`;列表应返回数组而非 `null`。
|
||||
登录态 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.2.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`。 |
|
||||
@@ -55,14 +59,13 @@
|
||||
|
||||
| 方法与路径 | 请求与响应 |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/accounts` | 返回 `200 Account[]`,按 ID 升序。 |
|
||||
| `POST /api/v1/accounts` | body `{displayName,expectedKind}`;空名称默认为 `新账号`,空类型默认为 `any`;成功返回 `201 Account`。 |
|
||||
| `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?}`;名称不能为空,省略类型时保留旧值;成功返回 `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。 |
|
||||
| `任意方法 /api/v1/dashboard?accountId={id}` | 返回内存中的 `Dashboard`;非 `GET`/`HEAD` 还需来源头。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 |
|
||||
| `GET`/`HEAD /api/v1/dashboard?accountId={id}` | 初始化后匿名可读公开账号,返回内存中的 `Dashboard`;匿名访问未公开账号返回 404,匿名响应隐藏邮箱、认证方式和内部错误字段,但保留重置卡信息。登录后可读取全部账号。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 |
|
||||
| `任意非读方法 /api/v1/dashboard?accountId={id}` | 要求 session;非 `GET`/`HEAD` 还需来源头。保持兼容的读取行为,省略或无效的零值 ID 使用账号 1。 |
|
||||
| `POST /api/v1/sync?accountId={id}` | 旧兼容入口,同步指定账号;省略或零值 ID 使用账号 1。 |
|
||||
|
||||
账号不存在返回 404 `账号不存在`;非法路径 ID 返回 400 `账号 ID 无效`;无效 `expectedKind` 返回 400 `连接类型无效`。未知账号套餐不得猜测为个人或团队。
|
||||
@@ -77,7 +80,7 @@
|
||||
{timezone,theme,syncMinutes,retentionDays,beforeMinutes,notifyBefore,notifyAfter}
|
||||
```
|
||||
|
||||
任意非 `GET` 方法都按更新处理,前端使用 `PUT`;请求接受完整对象。`syncMinutes` 为 1–60,`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。
|
||||
任意非 `GET` 方法都按更新处理,前端使用 `PUT`;请求接受完整对象。`syncMinutes` 为兼容旧客户端保留的字段,服务端始终按 5 分钟自动同步并返回 `5`;`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。
|
||||
|
||||
### SMTP
|
||||
|
||||
@@ -99,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 协议、当前解析代码和测试作为兼容基线。
|
||||
|
||||
+666
-335
File diff suppressed because it is too large
Load Diff
+112
-209
@@ -2,8 +2,6 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -11,67 +9,69 @@ 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"
|
||||
)
|
||||
|
||||
// Version is overridden at build time for release images.
|
||||
var Version = "0.2.0"
|
||||
var Version = "0.3.0"
|
||||
|
||||
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 codexClient interface {
|
||||
Start(context.Context) error
|
||||
Initialize(context.Context) error
|
||||
Call(context.Context, string, any, any) error
|
||||
Close() error
|
||||
Connected() bool
|
||||
type cpaClient interface {
|
||||
Configured() bool
|
||||
Snapshot(context.Context, string) (cliproxy.Snapshot, error)
|
||||
}
|
||||
|
||||
const automaticSyncInterval = time.Duration(automaticSyncMinutes) * time.Minute
|
||||
|
||||
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)
|
||||
@@ -79,236 +79,140 @@ 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() {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
|
||||
func (a *App) syncScheduler() {
|
||||
a.syncAll(a.ctx)
|
||||
ticker := time.NewTicker(automaticSyncInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
case <-t.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()
|
||||
if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 {
|
||||
a.syncAll(context.Background())
|
||||
}
|
||||
_, _ = a.store.Cleanup(g.RetentionDays)
|
||||
go a.processReminders()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -319,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")
|
||||
@@ -328,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
|
||||
|
||||
@@ -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, "🔄 <b>刷新请求已提交</b>\n\n如果已有刷新正在执行,将复用该轮结果;稍后可再次查询当前用量。")
|
||||
return
|
||||
}
|
||||
msg := ""
|
||||
a.mu.RLock()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,22 +4,125 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,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"`
|
||||
@@ -41,23 +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)
|
||||
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"`
|
||||
@@ -67,180 +270,321 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
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 TestCreateAccountRequiresAuthIndexAndImmediatelySyncs(t *testing.T) {
|
||||
a := newReminderTestApp(t)
|
||||
configureTestCPA(a, "auth-1")
|
||||
|
||||
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:]
|
||||
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())
|
||||
}
|
||||
started, release := f.initStarted, f.initRelease
|
||||
f.mu.Unlock()
|
||||
if started != nil {
|
||||
select {
|
||||
case started <- struct{}{}:
|
||||
default:
|
||||
|
||||
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(recorder.Body.Bytes(), &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.AuthIndex != "auth-1" || created.DisplayName != "CPA 标签" || !created.Connected || created.Email == nil {
|
||||
t.Fatalf("created = %#v", created)
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
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)
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
if release != nil {
|
||||
<-release
|
||||
}
|
||||
|
||||
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}},
|
||||
now.Unix(),
|
||||
)
|
||||
if cycle == nil || cycle.WindowType != "secondary" || cycle.TotalTokens != 600 {
|
||||
t.Fatalf("cycle = %#v", cycle)
|
||||
}
|
||||
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"}
|
||||
func TestCurrentTokenCycleRequiresAValidFutureResetWindow(t *testing.T) {
|
||||
cycle := currentTokenCycle(
|
||||
[]LimitBucket{{WindowDurationMinutes: 0, ResetsAt: 1}},
|
||||
[]UsagePoint{{Date: "2026-08-14", TotalTokens: 100}},
|
||||
time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC).Unix(),
|
||||
)
|
||||
if cycle != nil {
|
||||
t.Fatalf("cycle = %#v; want nil", cycle)
|
||||
}
|
||||
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())
|
||||
}()
|
||||
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()}
|
||||
peak := peakDailyTokensForCycle(cycle, []UsagePoint{
|
||||
{Date: "2026-08-07", TotalTokens: 900},
|
||||
{Date: "2026-08-08", TotalTokens: 100},
|
||||
{Date: "2026-08-10", TotalTokens: 200},
|
||||
{Date: "2026-08-14", TotalTokens: 300},
|
||||
{Date: "2026-08-15", TotalTokens: 800},
|
||||
}, now.Unix())
|
||||
if peak == nil || *peak != 300 {
|
||||
t.Fatalf("peak = %v; want 300", peak)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
starts, initializes, _, _ := client.counts()
|
||||
if starts != 1 || initializes != 1 {
|
||||
t.Fatalf("cold starts = %d, initializes = %d; want 1 each", starts, initializes)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
func TestAutomaticSyncIntervalIsFixedAtFiveMinutes(t *testing.T) {
|
||||
a := newReminderTestApp(t)
|
||||
if err := a.store.SetJSON("general", GeneralSettings{SyncMinutes: 60, RetentionDays: 90, BeforeMinutes: 30}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
if got := a.general().SyncMinutes; got != automaticSyncMinutes || automaticSyncInterval != 5*time.Minute {
|
||||
t.Fatalf("sync minutes = %d interval = %s", got, automaticSyncInterval)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ type GeneralSettings struct {
|
||||
NotifyBefore bool `json:"notifyBefore"`
|
||||
NotifyAfter bool `json:"notifyAfter"`
|
||||
}
|
||||
|
||||
const automaticSyncMinutes = 5
|
||||
|
||||
type SMTPSettings struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
@@ -41,7 +44,7 @@ type AccountView struct {
|
||||
}
|
||||
type LimitWindow struct {
|
||||
UsedPercent float64 `json:"usedPercent"`
|
||||
WindowDurationMins int `json:"windowDurationMinutes"`
|
||||
WindowDurationMins int `json:"windowDurationMins"`
|
||||
ResetsAt int64 `json:"resetsAt"`
|
||||
}
|
||||
type LimitBucket struct {
|
||||
@@ -70,18 +73,32 @@ type UsagePoint struct {
|
||||
InputTokens *int64 `json:"inputTokens"`
|
||||
OutputTokens *int64 `json:"outputTokens"`
|
||||
}
|
||||
type TokenCycle struct {
|
||||
LimitID string `json:"limitId"`
|
||||
WindowType string `json:"windowType"`
|
||||
WindowDurationMinutes int `json:"windowDurationMinutes"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
ResetsAt int64 `json:"resetsAt"`
|
||||
TotalTokens int64 `json:"totalTokens"`
|
||||
}
|
||||
type ResetCreditsSummary struct {
|
||||
AvailableCount int `json:"availableCount"`
|
||||
ExpiresAt []int64 `json:"expiresAt"`
|
||||
}
|
||||
type Dashboard struct {
|
||||
AccountID int64 `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Account AccountView `json:"account"`
|
||||
Limits []LimitBucket `json:"limits"`
|
||||
Summary UsageSummary `json:"summary"`
|
||||
Usage []UsagePoint `json:"usage"`
|
||||
FetchedAt int64 `json:"fetchedAt"`
|
||||
Stale bool `json:"stale"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
AccountID int64 `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Account AccountView `json:"account"`
|
||||
Limits []LimitBucket `json:"limits"`
|
||||
Summary UsageSummary `json:"summary"`
|
||||
Usage []UsagePoint `json:"usage"`
|
||||
CurrentCycle *TokenCycle `json:"currentCycle,omitempty"`
|
||||
ResetCredits *ResetCreditsSummary `json:"resetCredits,omitempty"`
|
||||
FetchedAt int64 `json:"fetchedAt"`
|
||||
Stale bool `json:"stale"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
func defaults() GeneralSettings {
|
||||
return GeneralSettings{Timezone: "UTC", Theme: "system", SyncMinutes: 5, RetentionDays: 90, BeforeMinutes: 30, NotifyBefore: true, NotifyAfter: true}
|
||||
return GeneralSettings{Timezone: "UTC", Theme: "system", SyncMinutes: automaticSyncMinutes, RetentionDays: 90, BeforeMinutes: 30, NotifyBefore: true, NotifyAfter: true}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+100
-12
@@ -94,14 +94,16 @@ 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,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
var hasExpectedKind bool
|
||||
var hasAuthIndex, hasExpectedKind, hasPublicVisible bool
|
||||
rows, qerr := tx.Query("PRAGMA table_info(accounts)")
|
||||
if qerr != nil {
|
||||
return qerr
|
||||
@@ -111,21 +113,51 @@ 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
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if !hasPublicVisible {
|
||||
if _, err = tx.Exec("ALTER TABLE accounts ADD COLUMN public_visible INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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"} {
|
||||
@@ -180,7 +212,9 @@ 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"`
|
||||
ValidationStatus string `json:"validationStatus"`
|
||||
PossibleDuplicate bool `json:"possibleDuplicate"`
|
||||
@@ -190,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,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
|
||||
}
|
||||
@@ -198,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.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)
|
||||
@@ -217,21 +251,59 @@ func (s *Store) Accounts() ([]Account, error) {
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (s *Store) CreateAccount(name string, kinds ...string) (Account, error) {
|
||||
|
||||
func (s *Store) Account(id int64) (Account, error) {
|
||||
var a Account
|
||||
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
|
||||
}
|
||||
a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAccount(name, authIndex string, kinds ...string) (Account, error) {
|
||||
return s.createAccount(name, authIndex, false, kinds...)
|
||||
}
|
||||
|
||||
func (s *Store) CreateAccountWithVisibility(name, authIndex, expectedKind string, publicVisible bool) (Account, error) {
|
||||
return s.createAccount(name, authIndex, publicVisible, expectedKind)
|
||||
}
|
||||
|
||||
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,created_at,updated_at) VALUES(?,?,?,?)", name, expectedKind, 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, 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 {
|
||||
r, e := s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,updated_at=? WHERE id=?", name, expectedKind, time.Now().Unix(), id)
|
||||
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 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 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
|
||||
}
|
||||
@@ -241,6 +313,12 @@ func (s *Store) UpdateAccountSettings(id int64, name, expectedKind string) error
|
||||
}
|
||||
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 {
|
||||
@@ -285,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) {
|
||||
|
||||
@@ -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 {
|
||||
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,11 +109,52 @@ 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].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])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountVisibilitySettings(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.DB.Close()
|
||||
|
||||
private, err := s.CreateAccount("私有账号", "private-auth")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
public, err := s.CreateAccountWithVisibility("公开账号", "public-auth", "team", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if private.PublicVisible || !public.PublicVisible {
|
||||
t.Fatalf("created accounts = %#v, %#v", private, public)
|
||||
}
|
||||
if err := s.UpdateAccountSettings(public.ID, "公开账号重命名", "team"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unchanged, err := s.Account(public.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !unchanged.PublicVisible {
|
||||
t.Fatal("legacy settings update unexpectedly changed public visibility")
|
||||
}
|
||||
visible := false
|
||||
if err := s.UpdateAccountSettingsWithVisibility(public.ID, "公开账号重命名", "team", &visible); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := s.Account(public.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.PublicVisible {
|
||||
t.Fatal("explicit false visibility update was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(value string) *string { return &value }
|
||||
|
||||
func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
@@ -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,8 +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 边界
|
||||
|
||||
JSON 解码限制为 1 MiB 并拒绝未知字段。统一安全头包括限制性 CSP、`nosniff`、禁止 iframe 和 same-origin referrer。前端路由、按钮禁用和邮箱掩码均不是服务端授权边界;所有新敏感端点必须在后端经过 `require`,改变 API 方法时还要核对来源头逻辑。
|
||||
初始化完成后,标记为公开的账号列表和 Dashboard 以匿名只读方式开放,供公开总览加载;未标记账号对匿名请求不可见。匿名响应不返回邮箱、CPA authIndex、Codex 认证方式、账号配置校验字段或内部错误。新增或修改 CPA 绑定、同步、删除、公开状态修改、提醒和所有设置接口仍必须经过 `require` 的 session 与来源校验。
|
||||
|
||||
JSON 解码限制为 1 MiB 并拒绝未知字段。统一安全头包括限制性 CSP、`nosniff`、禁止 iframe 和 same-origin referrer。前端路由、按钮禁用和邮箱掩码均不是服务端授权边界;所有新敏感端点必须在后端经过 `require`,改变 API 方法时还要核对来源头逻辑。公开账号响应必须清空 `authIndex`,避免把 CPA 内部凭据标识暴露给匿名访问者。
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
# Codex 集成与用量同步
|
||||
# CLIProxyAPI 与 Codex 用量同步
|
||||
|
||||
## 账号隔离与进程生命周期
|
||||
## 外部依赖与凭据边界
|
||||
|
||||
每个 `accounts` 记录对应一个 `accountRuntime` 和独立 `codex app-server` 子进程。账号 1 为升级兼容固定使用 `/data/codex`;其他账号使用 `/data/accounts/<id>/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 展平。失败时保留空限额而不令整次同步失败。
|
||||
- 套餐未知时,可从所有可分类且一致的限额 bucket 回填;冲突或未知时必须保持 unknown。
|
||||
- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。
|
||||
- 每次限额同步写入快照、检测用量百分比显著回落,并更新账号元数据及内存 Dashboard。
|
||||
## 限额归一化
|
||||
|
||||
`account/usage/read` 的 optional 指标和 daily buckets 可能暂未提供。仅 API Key 或 Bedrock 登录不能保证读取 ChatGPT 用量;不得在缺失数据时合成调用次数、输入/输出 Token、价格或账单日期。
|
||||
一次成功同步会把 `/wham/usage` 转换为现有 `Dashboard.Limits`:
|
||||
|
||||
## 套餐类型
|
||||
- 顶层 `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。
|
||||
|
||||
用户期望类型仅为连接后的校验提示:`any`、`personal`、`team`。当前 personal 包括 free/go/plus/pro/prolite;team 包括 team/business 及两种 self-serve business 标识。未知新套餐必须显示 unknown,不能静默当作某一类型。相同邮箱和相同已知实际类型的多个账号标记 `possibleDuplicate`,但不会自动合并或删除。
|
||||
可用重置卡数量优先以详情接口的 `available_count` 为准,详情接口失败时可退回 `/wham/usage` 的 `rate_limit_reset_credits.available_count`。只有状态为 available 且能解析 RFC3339 `expires_at` 的记录才进入到期时间列表;详情失败不影响主同步。
|
||||
|
||||
## Token 活动与当前周期
|
||||
|
||||
`/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 中单独执行。
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
|
||||
- `settings` 保存通用、SMTP、Telegram、绑定码及安装标记;秘密单独以密文 key 保存。
|
||||
- `admin` 与 `sessions` 保存唯一管理员和登录会话。
|
||||
- `accounts` 保存 Codex 连接元数据与期望套餐类型。
|
||||
- `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` 写成普通升级步骤。
|
||||
|
||||
## 提醒生成与重试
|
||||
|
||||
|
||||
@@ -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 先处理三个匿名入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。
|
||||
所有路由由 `http.ServeMux` 承载。API 先处理 status、setup、login 和初始化后的账号/Dashboard 匿名只读入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。
|
||||
|
||||
## 后台任务
|
||||
|
||||
- `keepCodex` 每秒检查未就绪的账号,串行完成进程启动与协议初始化,成功后立即同步。
|
||||
- `scheduler` 每分钟按 `syncMinutes` 的 Unix 时间取模触发全账号同步,清理过期历史,并异步处理提醒。
|
||||
- 应用启动后异步同步全部已绑定 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` 表达。
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
## 状态与路由
|
||||
|
||||
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。
|
||||
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;初始化后未登录渲染公开只读 `/` 总览和 `/login` 登录页,登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未登录访问 `/settings` 回到公开总览,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页、公开总览和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。
|
||||
|
||||
主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后刷新;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图;顶部“刷新全部”和卡片内的账号级刷新都会先调用对应的 sync,再重新读取 Dashboard。
|
||||
主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后重新读取;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。公开总览使用匿名只读接口并隐藏身份配置字段,隐藏手动刷新按钮和设置导航;服务器固定每 5 分钟同步全部账号,登录后总览默认只展示账号摘要。折叠摘要分别显示小时额度和周额度的剩余百分比及各自重置倒计时;周额度固定取 Codex 的 168 小时窗口,`gpt-reserve` 等同周期的命名附加额度不得替代它;仅有周额度时摘要居中显示该窗口。展开卡片后显示完整限额、统计和 Token 图。
|
||||
|
||||
桌面端使用固定侧栏,`800px` 及以下改为紧凑顶栏和固定底部主导航;GitHub、主题和退出位于顶栏辅助菜单。移动布局最低支持 320px,使用动态视口高度和 CSS safe-area 环境变量避开 iOS 浏览器工具栏与设备安全区。内容必须为底部导航保留空间,表单控件避免 iOS 聚焦缩放,主要交互保持至少 44px 触控区域。
|
||||
|
||||
@@ -18,9 +18,9 @@ API 类型精确区分后端 `null` 与 optional,并为秘密设置拆分读
|
||||
|
||||
## 总览与设置
|
||||
|
||||
总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、四项摘要及每日 Token 图。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。
|
||||
总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 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 凭据,并清理本地账号选择。
|
||||
|
||||
## 验证重点
|
||||
|
||||
|
||||
@@ -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.2.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`。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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、镜像层或日志。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## API 与认证
|
||||
|
||||
- [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 是路径、状态码、响应字段和兼容文案的契约。匿名入口只能是当前 status、setup 和 login。
|
||||
- [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 是路径、状态码、响应字段和兼容文案的契约。匿名入口包括 status、setup、login,以及初始化完成后已标记公开账号的列表和 Dashboard 只读总览;新增或修改配置、账号、同步和凭据接口仍必须要求 session。
|
||||
- 所有受保护端点必须回查 session;非只读请求还必须验证 `X-Requested-With`。前端路由与按钮不能代替后端门禁。
|
||||
- session 原 token 只进入 cookie,SQLite 只保存摘要;密码保持 argon2id。错误和日志不得包含密码、cookie、Bot Token、SMTP 密码或 Codex token。
|
||||
- 初始化是事务性单管理员创建。新增自动初始化能力前必须保留并发与首次公网暴露的安全边界。
|
||||
@@ -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 凭据;任何文档不得暗示其可完整灾难恢复。
|
||||
|
||||
+7
-2
@@ -85,8 +85,13 @@ export const post = <T>(
|
||||
{ method: "POST", body: JSON.stringify(value), signal },
|
||||
timeoutMs,
|
||||
);
|
||||
export const put = <T>(path: string, decoder: Decoder<T>, value: unknown) =>
|
||||
api(path, decoder, { method: "PUT", body: JSON.stringify(value) });
|
||||
export const put = <T>(
|
||||
path: string,
|
||||
decoder: Decoder<T>,
|
||||
value: unknown,
|
||||
timeoutMs = 30_000,
|
||||
) =>
|
||||
api(path, decoder, { method: "PUT", body: JSON.stringify(value) }, timeoutMs);
|
||||
export const del = <T>(path: string, decoder: Decoder<T>) =>
|
||||
api(path, decoder, { method: "DELETE" });
|
||||
|
||||
|
||||
+333
-299
@@ -2,6 +2,7 @@ import React, { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Link,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
@@ -11,23 +12,21 @@ import {
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Coins,
|
||||
Flame,
|
||||
Github,
|
||||
Gauge,
|
||||
LogIn,
|
||||
Mail,
|
||||
LogOut,
|
||||
Moon,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Sun,
|
||||
Ticket,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
Timer,
|
||||
Zap,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
@@ -40,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,
|
||||
@@ -83,7 +80,13 @@ function App() {
|
||||
{authenticated ? (
|
||||
<Route path="*" element={<Shell version={status.version} />} />
|
||||
) : (
|
||||
<Route path="*" element={<Login version={status.version} />} />
|
||||
<>
|
||||
<Route path="/login" element={<Login version={status.version} />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<PublicShell version={status.version} />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
@@ -206,10 +209,32 @@ function Login({ version }: { version: string }) {
|
||||
</label>
|
||||
{e && <p className="error">{e}</p>}
|
||||
<button>登录</button>
|
||||
<p className="hint public-login-link">
|
||||
<Link to="/">查看公开总览</Link>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
function PublicShell({ version }: { version: string }) {
|
||||
const nav = useNavigate();
|
||||
return (
|
||||
<div className="public-app">
|
||||
<header className="public-header">
|
||||
<Brand version={version} />
|
||||
<button className="secondary" onClick={() => nav("/login")}>
|
||||
<LogIn /> 登录后配置
|
||||
</button>
|
||||
</header>
|
||||
<main className="public-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard publicView />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Shell({ version }: { version: string }) {
|
||||
const { logout } = useAuth();
|
||||
const nav = useNavigate();
|
||||
@@ -348,22 +373,19 @@ type DashboardCardState = {
|
||||
dashboard: Dash | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
refresh: "idle" | "loading" | "done";
|
||||
};
|
||||
|
||||
const emptyDashboardCardState = (): DashboardCardState => ({
|
||||
dashboard: null,
|
||||
loading: false,
|
||||
error: "",
|
||||
refresh: "idle",
|
||||
});
|
||||
|
||||
function Dashboard() {
|
||||
function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
||||
const [accounts, setAccounts] = useState<Account[] | null>(null),
|
||||
[cards, setCards] = useState<Record<number, DashboardCardState>>({}),
|
||||
[expanded, setExpanded] = useState<Record<number, boolean>>({}),
|
||||
[error, setError] = useState(""),
|
||||
[refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle");
|
||||
[error, setError] = useState("");
|
||||
const requestRef = useRef(new Map<number, number>()),
|
||||
activeControllersRef = useRef(new Map<number, AbortController>());
|
||||
|
||||
@@ -380,11 +402,7 @@ function Dashboard() {
|
||||
}));
|
||||
};
|
||||
|
||||
const loadDashboard = async (
|
||||
accountId: number,
|
||||
signal?: AbortSignal,
|
||||
manual = false,
|
||||
) => {
|
||||
const loadDashboard = async (accountId: number, signal?: AbortSignal) => {
|
||||
const request = (requestRef.current.get(accountId) || 0) + 1;
|
||||
requestRef.current.set(accountId, request);
|
||||
let ownController: AbortController | undefined;
|
||||
@@ -397,7 +415,6 @@ function Dashboard() {
|
||||
updateCard(accountId, {
|
||||
loading: true,
|
||||
error: "",
|
||||
...(manual ? { refresh: "loading" as const } : {}),
|
||||
});
|
||||
try {
|
||||
const dashboard = await get(
|
||||
@@ -414,26 +431,13 @@ function Dashboard() {
|
||||
dashboard,
|
||||
loading: false,
|
||||
error: "",
|
||||
...(manual ? { refresh: "done" as const } : {}),
|
||||
});
|
||||
if (manual) {
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setCards((current) => {
|
||||
const card = current[accountId];
|
||||
if (!card || card.refresh !== "done") return current;
|
||||
return { ...current, [accountId]: { ...card, refresh: "idle" } };
|
||||
}),
|
||||
2000,
|
||||
);
|
||||
}
|
||||
} catch (value) {
|
||||
if (signal.aborted || requestRef.current.get(accountId) !== request)
|
||||
return;
|
||||
updateCard(accountId, {
|
||||
loading: false,
|
||||
error: toErrorMessage(value),
|
||||
...(manual ? { refresh: "idle" as const } : {}),
|
||||
});
|
||||
} finally {
|
||||
if (
|
||||
@@ -486,42 +490,6 @@ function Dashboard() {
|
||||
[],
|
||||
);
|
||||
|
||||
const syncAccount = async (accountId: number) => {
|
||||
try {
|
||||
setError("");
|
||||
await post(`accounts/${accountId}/sync`, decodeOK, {}, undefined, 60_000);
|
||||
await loadDashboard(accountId, undefined, true);
|
||||
} catch (value) {
|
||||
updateCard(accountId, {
|
||||
loading: false,
|
||||
refresh: "idle",
|
||||
error: toErrorMessage(value),
|
||||
});
|
||||
}
|
||||
};
|
||||
const syncAll = async () => {
|
||||
if (!accounts?.length) return;
|
||||
setRefresh("loading");
|
||||
setError("");
|
||||
const results = await Promise.allSettled(
|
||||
accounts.map(async (account) => {
|
||||
await post(
|
||||
`accounts/${account.id}/sync`,
|
||||
decodeOK,
|
||||
{},
|
||||
undefined,
|
||||
60_000,
|
||||
);
|
||||
await loadDashboard(account.id, undefined, true);
|
||||
}),
|
||||
);
|
||||
const failed = results.filter((result) => result.status === "rejected");
|
||||
if (failed.length)
|
||||
setError(`${failed.length} 个账号刷新失败,请展开对应卡片查看详情。`);
|
||||
setRefresh(failed.length ? "idle" : "done");
|
||||
if (!failed.length) window.setTimeout(() => setRefresh("idle"), 2000);
|
||||
};
|
||||
|
||||
if (accounts === null)
|
||||
return (
|
||||
<>
|
||||
@@ -539,7 +507,12 @@ function Dashboard() {
|
||||
if (!accounts.length)
|
||||
return (
|
||||
<>
|
||||
<Header title="用量总览" sub="请先在设置中心添加账号" />
|
||||
<Header
|
||||
title="用量总览"
|
||||
sub={
|
||||
publicView ? "当前还没有可公开展示的账号" : "请先在设置中心添加账号"
|
||||
}
|
||||
/>
|
||||
<div className="panel empty">尚未添加 Codex 账号</div>
|
||||
</>
|
||||
);
|
||||
@@ -547,21 +520,8 @@ function Dashboard() {
|
||||
<>
|
||||
<Header
|
||||
title="用量总览"
|
||||
sub={`共 ${accounts.length} 个账号,点击卡片查看完整详情`}
|
||||
>
|
||||
<button
|
||||
className={"secondary refresh " + refresh}
|
||||
disabled={refresh === "loading"}
|
||||
onClick={() => void syncAll()}
|
||||
>
|
||||
{refresh === "done" ? <Check /> : <RefreshCw />}
|
||||
{refresh === "loading"
|
||||
? "刷新中"
|
||||
: refresh === "done"
|
||||
? "刷新完成"
|
||||
: "刷新全部"}
|
||||
</button>
|
||||
</Header>
|
||||
sub={`${publicView ? "公开只读 · " : ""}共 ${accounts.length} 个账号,服务器每 5 分钟自动同步`}
|
||||
/>
|
||||
{error && <div className="banner">{error}</div>}
|
||||
<div className="account-overview-list">
|
||||
{accounts.map((account) => (
|
||||
@@ -569,6 +529,7 @@ function Dashboard() {
|
||||
key={account.id}
|
||||
account={account}
|
||||
state={cards[account.id] || emptyDashboardCardState()}
|
||||
publicView={publicView}
|
||||
expanded={Boolean(expanded[account.id])}
|
||||
onToggle={() =>
|
||||
setExpanded((current) => ({
|
||||
@@ -576,7 +537,6 @@ function Dashboard() {
|
||||
[account.id]: !current[account.id],
|
||||
}))
|
||||
}
|
||||
onRefresh={() => void syncAccount(account.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -587,15 +547,15 @@ function Dashboard() {
|
||||
function AccountOverviewCard({
|
||||
account,
|
||||
state,
|
||||
publicView,
|
||||
expanded,
|
||||
onToggle,
|
||||
onRefresh,
|
||||
}: {
|
||||
account: Account;
|
||||
state: DashboardCardState;
|
||||
publicView: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const dashboard = state.dashboard;
|
||||
const email = dashboard?.account.email ?? account.email;
|
||||
@@ -619,7 +579,7 @@ function AccountOverviewCard({
|
||||
<span className="account-summary-identity">
|
||||
<strong>{title}</strong>
|
||||
<span>
|
||||
{email ? maskEmail(email) : "尚未登录"} ·{" "}
|
||||
{publicView ? "公开只读" : email ? maskEmail(email) : "尚未登录"} ·{" "}
|
||||
{connected ? "已连接" : "未连接"}
|
||||
</span>
|
||||
</span>
|
||||
@@ -637,7 +597,7 @@ function AccountOverviewCard({
|
||||
<AccountDashboardDetails
|
||||
dashboard={dashboard}
|
||||
state={state}
|
||||
onRefresh={onRefresh}
|
||||
publicView={publicView}
|
||||
/>
|
||||
) : (
|
||||
<div className="account-detail-state">
|
||||
@@ -645,11 +605,6 @@ function AccountOverviewCard({
|
||||
<p className={state.error ? "error" : ""}>
|
||||
{state.error || "正在读取账号用量…"}
|
||||
</p>
|
||||
{state.error && (
|
||||
<button className="secondary" onClick={onRefresh}>
|
||||
<RefreshCw /> 重试
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -675,19 +630,44 @@ function AccountQuickSummary({
|
||||
: "等待数据"}
|
||||
</span>
|
||||
);
|
||||
const remaining = dashboard.limits.length
|
||||
? Math.min(...dashboard.limits.map((limit) => remainingPercent(limit)))
|
||||
: null;
|
||||
const reset = dashboard.limits
|
||||
.map((limit) => limit.resetsAt)
|
||||
.filter((value) => value > 0)
|
||||
.sort((a, b) => a - b)[0];
|
||||
const limits = quickSummaryLimits(dashboard.limits);
|
||||
if (!limits.length)
|
||||
return <span className="account-summary-metrics">限额暂无</span>;
|
||||
return (
|
||||
<span className="account-summary-metrics">
|
||||
<span>
|
||||
{remaining == null ? "限额暂无" : `最低 ${Math.round(remaining)}% 剩余`}
|
||||
</span>
|
||||
<span>{reset ? `最近重置 ${relative(reset)}` : "暂无重置时间"}</span>
|
||||
<span
|
||||
className={`account-summary-metrics account-summary-limits${limits.length === 1 ? " single" : ""}`}
|
||||
>
|
||||
{limits.map(({ kind, limit }) => {
|
||||
const remaining = Math.round(remainingPercent(limit));
|
||||
return (
|
||||
<span
|
||||
className="account-summary-limit"
|
||||
data-window-kind={kind}
|
||||
key={`${kind}-${limit.limitId}-${limit.windowType}`}
|
||||
aria-label={`${quickLimitLabel(kind, limit)},剩余 ${remaining}%,${limit.resetsAt ? `${relative(limit.resetsAt)}重置` : "重置时间暂无"}`}
|
||||
>
|
||||
<span className="account-summary-limit-name">
|
||||
{quickLimitLabel(kind, limit)}
|
||||
</span>
|
||||
<strong style={{ color: `hsl(${remaining * 1.2} 70% 45%)` }}>
|
||||
{remaining}%
|
||||
</strong>
|
||||
<span className="account-summary-limit-remaining">剩余</span>
|
||||
<span
|
||||
className="account-summary-limit-reset"
|
||||
title={
|
||||
limit.resetsAt
|
||||
? new Date(limit.resetsAt * 1000).toLocaleString()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{limit.resetsAt
|
||||
? `${relative(limit.resetsAt)}重置`
|
||||
: "重置时间暂无"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -695,11 +675,11 @@ function AccountQuickSummary({
|
||||
function AccountDashboardDetails({
|
||||
dashboard,
|
||||
state,
|
||||
onRefresh,
|
||||
publicView,
|
||||
}: {
|
||||
dashboard: Dash;
|
||||
state: DashboardCardState;
|
||||
onRefresh: () => void;
|
||||
publicView: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -718,18 +698,6 @@ function AccountDashboardDetails({
|
||||
? new Date(dashboard.fetchedAt * 1000).toLocaleString()
|
||||
: "尚未同步"}
|
||||
</span>
|
||||
<button
|
||||
className={`secondary refresh ${state.refresh}`}
|
||||
disabled={state.refresh === "loading"}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{state.refresh === "done" ? <Check /> : <RefreshCw />}
|
||||
{state.refresh === "loading"
|
||||
? "刷新中"
|
||||
: state.refresh === "done"
|
||||
? "刷新完成"
|
||||
: "刷新账号"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{(state.error || dashboard.lastError) && (
|
||||
@@ -742,9 +710,11 @@ function AccountDashboardDetails({
|
||||
icon={<Mail />}
|
||||
label="登录邮箱"
|
||||
value={
|
||||
dashboard.account.email
|
||||
? maskEmail(dashboard.account.email)
|
||||
: "尚未连接"
|
||||
publicView
|
||||
? "登录后查看"
|
||||
: dashboard.account.email
|
||||
? maskEmail(dashboard.account.email)
|
||||
: "尚未连接"
|
||||
}
|
||||
/>
|
||||
<AccountInfo
|
||||
@@ -763,6 +733,19 @@ function AccountDashboardDetails({
|
||||
value={dashboard.stale ? "数据可能已过期" : "数据已更新"}
|
||||
/>
|
||||
</div>
|
||||
{dashboard.resetCredits && dashboard.resetCredits.availableCount > 0 && (
|
||||
<div className="reset-credits" data-testid="reset-credits">
|
||||
<Ticket />
|
||||
<div className="reset-credits-main">
|
||||
<small>重置卡</small>
|
||||
<b>可用 {dashboard.resetCredits.availableCount} 张</b>
|
||||
</div>
|
||||
<div className="reset-credits-expiry">
|
||||
<small>到期时间</small>
|
||||
<b>{formatResetCreditExpiry(dashboard.resetCredits.expiresAt)}</b>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="limits">
|
||||
{dashboard.limits.map((limit, index) => (
|
||||
<LimitCard
|
||||
@@ -773,34 +756,33 @@ function AccountDashboardDetails({
|
||||
</div>
|
||||
{!dashboard.limits.length && (
|
||||
<div className="panel empty">
|
||||
该连接暂无限额数据,请确认登录后刷新。
|
||||
{publicView
|
||||
? "该连接暂无限额数据"
|
||||
: "该连接暂无限额数据,服务器会自动同步。"}
|
||||
</div>
|
||||
)}
|
||||
{dashboard.currentCycle && (
|
||||
<div className="cycle-note">
|
||||
<Clock />
|
||||
<span>
|
||||
本周期:{cycleTimestamp(dashboard.currentCycle.startedAt)} 至{" "}
|
||||
{cycleTimestamp(dashboard.currentCycle.resetsAt)}(
|
||||
{cycleDuration(dashboard.currentCycle.windowDurationMinutes)}
|
||||
,按每日数据汇总)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="stats">
|
||||
<Stat
|
||||
icon={<Coins />}
|
||||
label="累计 Tokens"
|
||||
v={num(dashboard.summary.lifetimeTokens)}
|
||||
label="本周期 Tokens"
|
||||
v={num(dashboard.currentCycle?.totalTokens)}
|
||||
/>
|
||||
<Stat
|
||||
icon={<TrendingUp />}
|
||||
label="单日峰值"
|
||||
label="本周期单日峰值"
|
||||
v={num(dashboard.summary.peakDailyTokens)}
|
||||
/>
|
||||
<Stat
|
||||
icon={<Flame />}
|
||||
label="连续使用"
|
||||
v={
|
||||
dashboard.summary.currentStreakDays == null
|
||||
? "暂无"
|
||||
: dashboard.summary.currentStreakDays + " 天"
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
icon={<Timer />}
|
||||
label="最长任务时长"
|
||||
v={duration(dashboard.summary.longestRunningTurnSec)}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={<SettingsLoading />}>
|
||||
<UsageChart data={dashboard.usage} accountId={dashboard.accountId} />
|
||||
@@ -1052,16 +1034,10 @@ function General() {
|
||||
<option value="light">浅色</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
同步间隔(分钟)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={v.syncMinutes}
|
||||
onChange={(e) => setV({ ...v, syncMinutes: +e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="fixed-setting">
|
||||
<span>自动同步</span>
|
||||
<b>服务器每 5 分钟自动刷新所有账号</b>
|
||||
</div>
|
||||
<label>
|
||||
历史保留(天)
|
||||
<select
|
||||
@@ -1105,9 +1081,10 @@ function General() {
|
||||
}
|
||||
function CodexSettings() {
|
||||
const [xs, setXs] = useState<Account[]>([]),
|
||||
[deviceLogin, setDeviceLogin] = useState<DeviceLogin | null>(null),
|
||||
[active, setActive] = useState(0),
|
||||
[newAuthIndex, setNewAuthIndex] = useState(""),
|
||||
[newKind, setNewKind] = useState<"personal" | "team">("team"),
|
||||
[newPublicVisible, setNewPublicVisible] = useState(false),
|
||||
[busy, setBusy] = useState(false),
|
||||
[err, setErr] = useState("");
|
||||
const load = async (signal?: AbortSignal) => {
|
||||
@@ -1118,80 +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,
|
||||
});
|
||||
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);
|
||||
}
|
||||
@@ -1201,144 +1160,198 @@ function CodexSettings() {
|
||||
<div className="codex-heading">
|
||||
<h2>Codex 账户与工作区</h2>
|
||||
<p>
|
||||
个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
|
||||
账号凭据和 OAuth 刷新由 CLIProxyAPI 管理;这里通过 CPA authIndex
|
||||
绑定需要展示的 Codex 账号。
|
||||
</p>
|
||||
</div>
|
||||
<section className="panel add-account-card">
|
||||
<div>
|
||||
<h3>添加新连接</h3>
|
||||
<p>选择要连接的订阅类型,然后使用设备码完成授权。</p>
|
||||
<h3>添加 CPA 账号</h3>
|
||||
<p>authIndex 可从 CLIProxyAPI 的认证文件列表中取得。</p>
|
||||
</div>
|
||||
<div className="add-account-controls">
|
||||
<label>
|
||||
连接类型
|
||||
CPA authIndex
|
||||
<input
|
||||
aria-label="CPA authIndex"
|
||||
autoComplete="off"
|
||||
placeholder="例如 6c4894e0cb312814"
|
||||
value={newAuthIndex}
|
||||
onChange={(event) => setNewAuthIndex(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
预期连接类型
|
||||
<select
|
||||
value={newKind}
|
||||
onChange={(e) =>
|
||||
setNewKind(e.target.value as "personal" | "team")
|
||||
onChange={(event) =>
|
||||
setNewKind(event.target.value as "personal" | "team")
|
||||
}
|
||||
>
|
||||
<option value="team">Team / Business 工作区</option>
|
||||
<option value="personal">个人订阅</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="checks">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newPublicVisible}
|
||||
onChange={(event) => setNewPublicVisible(event.target.checked)}
|
||||
/>
|
||||
公开显示到未登录总览
|
||||
</label>
|
||||
<button disabled={busy} onClick={add}>
|
||||
{busy && active === 0 ? "服务启动中…" : "添加账号"}
|
||||
{busy && active === 0 ? "正在验证并添加…" : "添加账号"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="account-list">
|
||||
{xs.length === 0 && (
|
||||
<div className="panel empty account-empty">
|
||||
尚未添加 Codex 账号,请使用上方的“添加账号”创建连接。
|
||||
尚未绑定 CPA Codex 账号,请在上方输入 authIndex。
|
||||
</div>
|
||||
)}
|
||||
{xs.map((x) => (
|
||||
<section className="panel account-card" key={x.id}>
|
||||
{xs.map((account) => (
|
||||
<section className="panel account-card" key={account.id}>
|
||||
<div className="account-card-main">
|
||||
<span className={"dot " + (x.connected ? "ok" : "")} />
|
||||
<span className={"dot " + (account.connected ? "ok" : "")} />
|
||||
<div className="account-details">
|
||||
<input
|
||||
aria-label="连接名称"
|
||||
defaultValue={x.displayName}
|
||||
onBlur={async (e) => {
|
||||
const name = e.target.value.trim();
|
||||
if (name && name !== x.displayName) {
|
||||
await put(`accounts/${x.id}`, decodeOK, {
|
||||
displayName: name,
|
||||
expectedKind: x.expectedKind,
|
||||
});
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
<div className="account-meta">
|
||||
<span>{x.email ? maskEmail(x.email) : "尚未登录"}</span>
|
||||
<span>{planLabel(x.planType)}</span>
|
||||
<span>
|
||||
{account.email
|
||||
? maskEmail(account.email)
|
||||
: "账号信息暂不可用"}
|
||||
</span>
|
||||
<span>{planLabel(account.planType)}</span>
|
||||
<span
|
||||
className={x.validationStatus === "mismatch" ? "error" : ""}
|
||||
className={
|
||||
account.validationStatus === "mismatch" ? "error" : ""
|
||||
}
|
||||
>
|
||||
{validationLabel(x)}
|
||||
{validationLabel(account)}
|
||||
</span>
|
||||
</div>
|
||||
{x.possibleDuplicate && (
|
||||
{account.possibleDuplicate && (
|
||||
<small className="hint">
|
||||
同一邮箱已有相同类型连接,请确认没有重复授权同一工作区
|
||||
同一邮箱已有相同类型绑定,请确认 authIndex 没有指向重复凭据
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-card-actions">
|
||||
<label>
|
||||
CPA authIndex
|
||||
<input
|
||||
aria-label={`“${account.displayName}”的 CPA authIndex`}
|
||||
autoComplete="off"
|
||||
defaultValue={account.authIndex}
|
||||
onBlur={async (event) => {
|
||||
const input = event.currentTarget;
|
||||
const authIndex = input.value.trim();
|
||||
if (!authIndex || authIndex === account.authIndex) return;
|
||||
if (
|
||||
!confirm(
|
||||
`更换“${account.displayName}”的 authIndex 会清除该本地账号已有的 Token 历史、限额快照和提醒记录。继续吗?`,
|
||||
)
|
||||
) {
|
||||
input.value = account.authIndex;
|
||||
return;
|
||||
}
|
||||
if (!(await updateAccount(account, { authIndex })))
|
||||
input.value = account.authIndex;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
预期连接类型
|
||||
<select
|
||||
aria-label="预期连接类型"
|
||||
value={x.expectedKind}
|
||||
onChange={async (e) => {
|
||||
await put(`accounts/${x.id}`, decodeOK, {
|
||||
displayName: x.displayName,
|
||||
expectedKind: e.target.value,
|
||||
});
|
||||
void load().catch((error) => setErr(toErrorMessage(error)));
|
||||
}}
|
||||
value={account.expectedKind}
|
||||
onChange={(event) =>
|
||||
void updateAccount(account, {
|
||||
expectedKind: event.target
|
||||
.value as Account["expectedKind"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="any">不校验</option>
|
||||
<option value="personal">个人订阅</option>
|
||||
<option value="team">Team / Business</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="checks">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={account.publicVisible}
|
||||
onChange={(event) =>
|
||||
void updateAccount(account, {
|
||||
publicVisible: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
公开显示到未登录总览
|
||||
</label>
|
||||
<div className="account-buttons">
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => login(x.id)}
|
||||
>
|
||||
{busy && active === x.id ? "服务启动中…" : "设备码登录"}
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(`确定要退出“${x.displayName}”的 Codex 账号吗?`)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
setBusy(true);
|
||||
setActive(account.id);
|
||||
setErr("");
|
||||
await post(`accounts/${x.id}/logout`, decodeOK);
|
||||
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
|
||||
await post(
|
||||
`accounts/${account.id}/sync`,
|
||||
decodeOK,
|
||||
{},
|
||||
undefined,
|
||||
60_000,
|
||||
);
|
||||
await load();
|
||||
} catch (q) {
|
||||
setErr(toErrorMessage(q));
|
||||
} catch (error) {
|
||||
setErr(toErrorMessage(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
退出
|
||||
{busy && active === account.id ? "同步中…" : "立即同步"}
|
||||
</button>
|
||||
<button
|
||||
className="icon danger"
|
||||
disabled={busy}
|
||||
title="删除连接"
|
||||
aria-label={`删除“${x.displayName}”`}
|
||||
title="删除本地绑定"
|
||||
aria-label={`删除“${account.displayName}”`}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(
|
||||
`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`,
|
||||
`删除“${account.displayName}”的本地绑定及全部历史数据?此操作不会删除 CLIProxyAPI 中的凭据,且无法撤销。`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
setErr("");
|
||||
await del(`accounts/${x.id}`, decodeOK);
|
||||
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
|
||||
if (active === x.id) setActive(0);
|
||||
if (+localStorage.accountId === x.id)
|
||||
await del(`accounts/${account.id}`, decodeOK);
|
||||
if (active === account.id) setActive(0);
|
||||
if (+localStorage.accountId === account.id)
|
||||
localStorage.removeItem("accountId");
|
||||
await load();
|
||||
} catch (q) {
|
||||
setErr(toErrorMessage(q));
|
||||
} catch (error) {
|
||||
setErr(toErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1346,22 +1359,6 @@ function CodexSettings() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{deviceLogin?.accountId === x.id && (
|
||||
<div className="codebox">
|
||||
<span>
|
||||
在浏览器中访问{" "}
|
||||
<a
|
||||
href={deviceLogin.verificationUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{deviceLogin.verificationUrl}
|
||||
</a>
|
||||
,然后输入设备码
|
||||
</span>
|
||||
<strong>{deviceLogin.userCode}</strong>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
@@ -1664,20 +1661,57 @@ function Header({
|
||||
const num = (v?: number | null) =>
|
||||
v == null
|
||||
? "暂无"
|
||||
: new Intl.NumberFormat("zh-CN", {
|
||||
: new Intl.NumberFormat("en", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(v);
|
||||
const duration = (v?: number | null) =>
|
||||
v == null
|
||||
? "暂无"
|
||||
: v < 60
|
||||
? `${v} 秒`
|
||||
: v < 3600
|
||||
? `${Math.floor(v / 60)} 分 ${v % 60} 秒`
|
||||
: `${(v / 3600).toFixed(1)} 小时`;
|
||||
const cycleTimestamp = (value: number) =>
|
||||
new Date(value * 1000).toLocaleString();
|
||||
const formatResetCreditExpiry = (values: number[]) =>
|
||||
values.length ? values.map(cycleTimestamp).join("、") : "服务端未提供";
|
||||
const cycleDuration = (minutes: number) =>
|
||||
minutes % (24 * 60) === 0
|
||||
? `${minutes / (24 * 60)} 天窗口`
|
||||
: minutes % 60 === 0
|
||||
? `${minutes / 60} 小时窗口`
|
||||
: `${minutes} 分钟窗口`;
|
||||
const remainingPercent = (limit: Limit) =>
|
||||
100 - Math.min(100, Math.max(0, limit.usedPercent));
|
||||
type QuickLimitKind = "hour" | "week" | "other";
|
||||
const quickSummaryLimits = (
|
||||
limits: Limit[],
|
||||
): Array<{ kind: QuickLimitKind; limit: Limit }> => {
|
||||
const sorted = [...limits].sort(
|
||||
(a, b) => a.windowDurationMinutes - b.windowDurationMinutes,
|
||||
);
|
||||
const hour = sorted.find(
|
||||
(limit) =>
|
||||
limit.windowDurationMinutes > 0 && limit.windowDurationMinutes < 24 * 60,
|
||||
);
|
||||
const week =
|
||||
sorted.find(
|
||||
(limit) =>
|
||||
limit.limitId.toLowerCase() === "codex" &&
|
||||
limit.windowDurationMinutes === 168 * 60,
|
||||
) ??
|
||||
sorted.find(
|
||||
(limit) =>
|
||||
limit.limitName == null && limit.windowDurationMinutes === 168 * 60,
|
||||
);
|
||||
const selected: Array<{ kind: QuickLimitKind; limit: Limit }> = [];
|
||||
if (hour) selected.push({ kind: "hour", limit: hour });
|
||||
if (week && week !== hour) selected.push({ kind: "week", limit: week });
|
||||
if (selected.length) return selected;
|
||||
return sorted.slice(0, 2).map((limit) => ({ kind: "other", limit }));
|
||||
};
|
||||
const quickLimitLabel = (kind: QuickLimitKind, limit: Limit) => {
|
||||
if (kind === "week") return "周额度";
|
||||
if (kind === "hour") {
|
||||
const hours = limit.windowDurationMinutes / 60;
|
||||
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} 小时额度`;
|
||||
}
|
||||
return limitLabel(limit).replace(/限额$/, "额度");
|
||||
};
|
||||
const limitLabel = (limit: Limit) => {
|
||||
if (limit.limitName) return limit.limitName;
|
||||
if (limit.windowDurationMinutes >= 60) {
|
||||
|
||||
+867
-162
File diff suppressed because it is too large
Load Diff
+55
-14
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { ApiError, toErrorMessage } from "./api";
|
||||
import {
|
||||
decodeAction,
|
||||
decodeAccount,
|
||||
decodeDashboard,
|
||||
decodeDeviceLogin,
|
||||
decodeGeneral,
|
||||
decodeTelegram,
|
||||
} from "./types";
|
||||
@@ -24,6 +24,45 @@ describe("API decoders", () => {
|
||||
expect(value.account.email).toBeNull();
|
||||
});
|
||||
|
||||
it("解码当前重置周期 Token 合计", () => {
|
||||
const value = decodeDashboard({
|
||||
accountId: 1,
|
||||
displayName: "个人账号",
|
||||
account: { email: "user@example.com", planType: "plus", connected: true },
|
||||
limits: [],
|
||||
currentCycle: {
|
||||
limitId: "codex",
|
||||
windowType: "secondary",
|
||||
windowDurationMinutes: 10080,
|
||||
startedAt: 1_900_000_000,
|
||||
resetsAt: 1_900_604_800,
|
||||
totalTokens: 123_456,
|
||||
},
|
||||
summary: {},
|
||||
usage: [],
|
||||
fetchedAt: 1_900_000_000,
|
||||
stale: false,
|
||||
});
|
||||
expect(value.currentCycle?.windowType).toBe("secondary");
|
||||
expect(value.currentCycle?.totalTokens).toBe(123_456);
|
||||
});
|
||||
|
||||
it("解码可用重置卡及到期时间", () => {
|
||||
const value = decodeDashboard({
|
||||
accountId: 1,
|
||||
displayName: "个人账号",
|
||||
account: { email: null, planType: "plus", connected: true },
|
||||
limits: [],
|
||||
resetCredits: { availableCount: 2, expiresAt: [1_784_246_400] },
|
||||
summary: {},
|
||||
usage: [],
|
||||
fetchedAt: 1_900_000_000,
|
||||
stale: false,
|
||||
});
|
||||
expect(value.resetCredits?.availableCount).toBe(2);
|
||||
expect(value.resetCredits?.expiresAt).toEqual([1_784_246_400]);
|
||||
});
|
||||
|
||||
it("拒绝未知主题", () => {
|
||||
expect(() =>
|
||||
decodeGeneral({
|
||||
@@ -38,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 操作警告", () => {
|
||||
|
||||
+48
-21
@@ -17,25 +17,22 @@ export type AsyncState<T> =
|
||||
|
||||
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;
|
||||
publicVisible: boolean;
|
||||
actualKind: "unknown" | "personal" | "team";
|
||||
validationStatus: ValidationStatus;
|
||||
possibleDuplicate: boolean;
|
||||
connected: boolean;
|
||||
}
|
||||
export interface DeviceLogin {
|
||||
accountId: number;
|
||||
verificationUrl: string;
|
||||
userCode: string;
|
||||
}
|
||||
export interface Limit {
|
||||
limitId: string;
|
||||
limitName: string | null;
|
||||
@@ -48,6 +45,18 @@ export interface Point {
|
||||
date: string;
|
||||
totalTokens: number;
|
||||
}
|
||||
export interface TokenCycle {
|
||||
limitId: string;
|
||||
windowType: string;
|
||||
windowDurationMinutes: number;
|
||||
startedAt: number;
|
||||
resetsAt: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
export interface ResetCreditsSummary {
|
||||
availableCount: number;
|
||||
expiresAt: number[];
|
||||
}
|
||||
export interface Dashboard {
|
||||
accountId: number;
|
||||
displayName: string;
|
||||
@@ -57,6 +66,7 @@ export interface Dashboard {
|
||||
connected: boolean;
|
||||
};
|
||||
limits: Limit[];
|
||||
currentCycle?: TokenCycle;
|
||||
summary: {
|
||||
lifetimeTokens?: number | null;
|
||||
peakDailyTokens?: number | null;
|
||||
@@ -66,6 +76,7 @@ export interface Dashboard {
|
||||
usage: Point[];
|
||||
fetchedAt: number;
|
||||
stale: boolean;
|
||||
resetCredits?: ResetCreditsSummary;
|
||||
lastError?: string;
|
||||
}
|
||||
export interface GeneralSettings {
|
||||
@@ -119,7 +130,7 @@ export const decodeStatus: Decoder<Status> = (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"),
|
||||
};
|
||||
};
|
||||
@@ -128,6 +139,7 @@ export const decodeAccount: Decoder<Account> = (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(
|
||||
@@ -135,6 +147,7 @@ export const decodeAccount: Decoder<Account> = (value) => {
|
||||
["any", "personal", "team"],
|
||||
"expectedKind",
|
||||
),
|
||||
publicVisible: boolean(x.publicVisible, "publicVisible"),
|
||||
actualKind: enumValue(
|
||||
x.actualKind,
|
||||
["unknown", "personal", "team"],
|
||||
@@ -153,6 +166,28 @@ export const decodeAccounts: Decoder<Account[]> = (value) => {
|
||||
if (!Array.isArray(value)) throw new Error("账号列表格式无效");
|
||||
return value.map(decodeAccount);
|
||||
};
|
||||
const decodeTokenCycle: Decoder<TokenCycle> = (value) => {
|
||||
const x = record(value, "currentCycle");
|
||||
return {
|
||||
limitId: string(x.limitId, "limitId"),
|
||||
windowType: string(x.windowType, "windowType"),
|
||||
windowDurationMinutes: number(
|
||||
x.windowDurationMinutes,
|
||||
"windowDurationMinutes",
|
||||
),
|
||||
startedAt: number(x.startedAt, "startedAt"),
|
||||
resetsAt: number(x.resetsAt, "resetsAt"),
|
||||
totalTokens: number(x.totalTokens, "totalTokens"),
|
||||
};
|
||||
};
|
||||
const decodeResetCredits: Decoder<ResetCreditsSummary> = (value) => {
|
||||
const x = record(value, "resetCredits");
|
||||
if (!Array.isArray(x.expiresAt)) throw new Error("expiresAt格式无效");
|
||||
return {
|
||||
availableCount: number(x.availableCount, "availableCount"),
|
||||
expiresAt: x.expiresAt.map((v, index) => number(v, `expiresAt[${index}]`)),
|
||||
};
|
||||
};
|
||||
export const decodeDashboard: Decoder<Dashboard> = (value) => {
|
||||
const x = record(value, "Dashboard"),
|
||||
account = record(x.account, "account"),
|
||||
@@ -181,6 +216,12 @@ export const decodeDashboard: Decoder<Dashboard> = (value) => {
|
||||
resetsAt: number(l.resetsAt, "resetsAt"),
|
||||
};
|
||||
}),
|
||||
...(x.currentCycle == null
|
||||
? {}
|
||||
: { currentCycle: decodeTokenCycle(x.currentCycle) }),
|
||||
...(x.resetCredits == null
|
||||
? {}
|
||||
: { resetCredits: decodeResetCredits(x.resetCredits) }),
|
||||
summary: {
|
||||
lifetimeTokens: optionalNumber(summary.lifetimeTokens, "lifetimeTokens"),
|
||||
peakDailyTokens: optionalNumber(
|
||||
@@ -252,20 +293,6 @@ export const decodeSMTP: Decoder<SMTPSettingsResponse> = (value) => {
|
||||
configured: boolean(x.configured, "configured"),
|
||||
};
|
||||
};
|
||||
export const decodeDeviceLogin: Decoder<Omit<DeviceLogin, "accountId">> = (
|
||||
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") };
|
||||
|
||||
@@ -48,14 +48,21 @@ export default function UsageChart({
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#5ee7f7" stopOpacity={0.35} />
|
||||
<stop offset="1" stopColor="#5ee7f7" stopOpacity={0} />
|
||||
<stop offset="0%" stopColor="#3b82a6" stopOpacity={0.35} />
|
||||
<stop offset="70%" stopColor="#6b61a6" stopOpacity={0.08} />
|
||||
<stop offset="100%" stopColor="#6b61a6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="var(--grid)" vertical={false} />
|
||||
<CartesianGrid
|
||||
stroke="var(--grid)"
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--muted)"
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "var(--border)" }}
|
||||
tickFormatter={
|
||||
compact ? (date: string) => date.slice(5) : undefined
|
||||
}
|
||||
@@ -66,6 +73,8 @@ export default function UsageChart({
|
||||
tickCount={compact ? 4 : 5}
|
||||
tickFormatter={compactAxis}
|
||||
stroke="var(--muted)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
@@ -75,15 +84,33 @@ export default function UsageChart({
|
||||
contentStyle={{
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 12,
|
||||
borderRadius: 8,
|
||||
boxShadow: "var(--shadow-md)",
|
||||
padding: "8px 12px",
|
||||
color: "var(--text)",
|
||||
}}
|
||||
labelStyle={{
|
||||
fontWeight: 600,
|
||||
color: "var(--muted)",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
itemStyle={{
|
||||
color: "var(--accent)",
|
||||
fontWeight: 650,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="totalTokens"
|
||||
stroke="#4dd8ed"
|
||||
stroke="#3b82a6"
|
||||
fill={`url(#${gradientId})`}
|
||||
strokeWidth={2}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: "#3b82a6",
|
||||
stroke: "var(--panel)",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
const responses: Record<string, unknown> = {
|
||||
"system/status": {
|
||||
initialized: true,
|
||||
appServer: true,
|
||||
cpa: true,
|
||||
version: "0.3.0-beta.1",
|
||||
},
|
||||
"auth/me": { username: "admin" },
|
||||
@@ -20,9 +20,11 @@ const responses: Record<string, unknown> = {
|
||||
{
|
||||
id: 1,
|
||||
displayName: "默认账号",
|
||||
authIndex: "index-default",
|
||||
email: "test@example.com",
|
||||
planType: "plus",
|
||||
expectedKind: "personal",
|
||||
publicVisible: false,
|
||||
actualKind: "personal",
|
||||
validationStatus: "matched",
|
||||
possibleDuplicate: false,
|
||||
@@ -55,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;
|
||||
}
|
||||
@@ -82,6 +84,14 @@ test("shows every account as a collapsible summary on the overview", async ({
|
||||
connected: true,
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
limitId: "base_model_inference",
|
||||
limitName: "gpt-reserve",
|
||||
windowType: "primary",
|
||||
usedPercent: 0,
|
||||
windowDurationMinutes: 10_080,
|
||||
resetsAt: 1_900_700_000,
|
||||
},
|
||||
{
|
||||
limitId: "primary",
|
||||
limitName: "5 小时限额",
|
||||
@@ -90,13 +100,33 @@ test("shows every account as a collapsible summary on the overview", async ({
|
||||
windowDurationMinutes: 300,
|
||||
resetsAt: 1_900_000_000,
|
||||
},
|
||||
{
|
||||
limitId: "codex",
|
||||
limitName: null,
|
||||
windowType: "secondary",
|
||||
usedPercent: 20,
|
||||
windowDurationMinutes: 10_080,
|
||||
resetsAt: 1_900_604_800,
|
||||
},
|
||||
],
|
||||
currentCycle: {
|
||||
limitId: "codex",
|
||||
windowType: "secondary",
|
||||
windowDurationMinutes: 10_080,
|
||||
startedAt: 1_899_999_000,
|
||||
resetsAt: 1_900_604_800,
|
||||
totalTokens: 123_456,
|
||||
},
|
||||
summary: {
|
||||
lifetimeTokens: 12_345,
|
||||
peakDailyTokens: 3_000,
|
||||
currentStreakDays: 4,
|
||||
longestRunningTurnSec: 80,
|
||||
},
|
||||
resetCredits: {
|
||||
availableCount: 2,
|
||||
expiresAt: [1_784_246_400],
|
||||
},
|
||||
usage: [{ date: "2026-08-13", totalTokens: 3_000 }],
|
||||
fetchedAt: 1_900_000_000,
|
||||
stale: false,
|
||||
@@ -109,7 +139,16 @@ test("shows every account as a collapsible summary on the overview", async ({
|
||||
planType: "team",
|
||||
connected: true,
|
||||
},
|
||||
limits: [],
|
||||
limits: [
|
||||
{
|
||||
limitId: "codex",
|
||||
limitName: null,
|
||||
windowType: "secondary",
|
||||
usedPercent: 40,
|
||||
windowDurationMinutes: 10_080,
|
||||
resetsAt: 1_900_604_800,
|
||||
},
|
||||
],
|
||||
summary: {},
|
||||
usage: [],
|
||||
fetchedAt: 0,
|
||||
@@ -121,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" } });
|
||||
@@ -150,8 +189,31 @@ test("shows every account as a collapsible summary on the overview", async ({
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".account-overview")).toHaveCount(2);
|
||||
await expect.poll(() => requestedDashboards.size).toBe(2);
|
||||
await expect(page.getByText("最低 65% 剩余")).toBeVisible();
|
||||
await expect(page.getByText("限额暂无")).toBeVisible();
|
||||
const firstSummary = page
|
||||
.locator('[data-account-id="1"]')
|
||||
.locator(".account-summary-limits");
|
||||
await expect(firstSummary.locator('[data-window-kind="hour"]')).toContainText(
|
||||
"5 小时额度65%剩余",
|
||||
);
|
||||
const weeklySummary = firstSummary.locator('[data-window-kind="week"]');
|
||||
await expect(weeklySummary).toContainText("周额度80%剩余");
|
||||
await expect(weeklySummary).not.toContainText("100%剩余");
|
||||
await expect(weeklySummary).toHaveAttribute("aria-label", /周额度,剩余 80%/);
|
||||
await expect(
|
||||
firstSummary.locator(".account-summary-limit-reset"),
|
||||
).toHaveCount(2);
|
||||
expect(
|
||||
await firstSummary.evaluate(
|
||||
(element) => element.scrollWidth <= element.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
const weeklyOnlySummary = page
|
||||
.locator('[data-account-id="2"]')
|
||||
.locator(".account-summary-limits.single");
|
||||
await expect(weeklyOnlySummary).toContainText("周额度60%剩余");
|
||||
await expect(weeklyOnlySummary).toHaveCSS("justify-content", "center");
|
||||
await expect(page.getByText("本周期 123.5K Tokens")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "刷新全部" })).toHaveCount(0);
|
||||
await expect(page.locator(".account-detail")).toHaveCount(0);
|
||||
await expect(page.locator(".account-select")).toHaveCount(0);
|
||||
|
||||
@@ -162,10 +224,77 @@ test("shows every account as a collapsible summary on the overview", async ({
|
||||
await expect(
|
||||
first.getByRole("heading", { name: "每日 Token 趋势" }),
|
||||
).toBeVisible();
|
||||
await expect(first.getByText("本周期 Tokens")).toBeVisible();
|
||||
await expect(first.getByText("本周期单日峰值")).toBeVisible();
|
||||
await expect(first.getByTestId("reset-credits")).toContainText("重置卡");
|
||||
await expect(first.getByTestId("reset-credits")).toContainText("可用 2 张");
|
||||
await expect(first.getByTestId("reset-credits")).toContainText("到期时间");
|
||||
await expect(first.getByText("连续使用", { exact: true })).toHaveCount(0);
|
||||
await expect(first.getByText("最长任务时长", { exact: true })).toHaveCount(0);
|
||||
await expect(first.getByText("123.5K", { exact: true })).toBeVisible();
|
||||
await expect(first.getByText("按每日数据汇总")).toBeVisible();
|
||||
await expect(page.locator(".account-detail")).toHaveCount(1);
|
||||
|
||||
await first.getByRole("button", { name: "收起个人账号详情" }).click();
|
||||
await expect(page.locator(".account-detail")).toHaveCount(0);
|
||||
|
||||
const second = page.locator('[data-account-id="2"]');
|
||||
await second.getByRole("button", { name: "展开Team 工作区详情" }).click();
|
||||
await expect(second.getByTestId("reset-credits")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows a read-only overview before login", async ({ page }) => {
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const key = new URL(route.request().url()).pathname.replace("/api/v1/", "");
|
||||
if (key === "system/status")
|
||||
return route.fulfill({
|
||||
json: { initialized: true, cpa: true, version: "test" },
|
||||
});
|
||||
if (key === "auth/me")
|
||||
return route.fulfill({ status: 401, json: { error: "未登录" } });
|
||||
if (key === "accounts") return route.fulfill({ json: responses.accounts });
|
||||
if (key === "dashboard")
|
||||
return route.fulfill({
|
||||
json: {
|
||||
accountId: 1,
|
||||
displayName: "默认账号",
|
||||
account: {
|
||||
email: null,
|
||||
planType: "plus",
|
||||
connected: true,
|
||||
},
|
||||
limits: [],
|
||||
resetCredits: {
|
||||
availableCount: 2,
|
||||
expiresAt: [1_784_246_400],
|
||||
},
|
||||
summary: {},
|
||||
usage: [],
|
||||
fetchedAt: 1_900_000_000,
|
||||
stale: false,
|
||||
},
|
||||
});
|
||||
return route.fulfill({ json: responses[key] ?? {} });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("button", { name: "登录后配置" })).toBeVisible();
|
||||
await expect(page.locator(".account-overview")).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "刷新全部" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "设置中心" })).toHaveCount(0);
|
||||
|
||||
const account = page.locator('[data-account-id="1"]');
|
||||
await account.getByRole("button", { name: "展开默认账号详情" }).click();
|
||||
await expect(account.getByText("登录后查看")).toBeVisible();
|
||||
await expect(account.getByTestId("reset-credits")).toContainText("可用 2 张");
|
||||
await expect(account.getByTestId("reset-credits")).toContainText("到期时间");
|
||||
await expect(account.locator(".account-detail .refresh")).toHaveCount(0);
|
||||
|
||||
await page.goto("/settings");
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await page.getByRole("button", { name: "登录后配置" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the build version in the authenticated brand", async ({ page }) => {
|
||||
@@ -204,14 +333,14 @@ test("shows the build version in the authenticated brand", async ({ page }) => {
|
||||
test("keeps the authenticated brand on one line with a release version", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSettings(page, "0.2.0");
|
||||
await openSettings(page, "0.3.0");
|
||||
const brand = page.locator(
|
||||
(page.viewportSize()?.width ?? 0) <= 800
|
||||
? ".mobile-topbar .logo"
|
||||
: "aside .logo",
|
||||
);
|
||||
await expect(brand).toContainText("Codex Helper");
|
||||
await expect(brand.locator(".version-badge")).toHaveText("v0.2.0");
|
||||
await expect(brand.locator(".version-badge")).toHaveText("v0.3.0");
|
||||
const centers = await brand
|
||||
.locator("svg, .brand-name, .version-badge")
|
||||
.evaluateAll((elements) =>
|
||||
@@ -226,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("/");
|
||||
@@ -242,13 +371,13 @@ 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) =>
|
||||
route.fulfill({ status: 401, json: { error: "unauthorized" } }),
|
||||
);
|
||||
await page.goto("/");
|
||||
await page.goto("/login");
|
||||
const badge = page.locator(".login .version-badge");
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toHaveText("vtest");
|
||||
@@ -505,59 +634,146 @@ 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();
|
||||
});
|
||||
|
||||
test("sets public visibility when adding and editing an account", async ({
|
||||
page,
|
||||
}) => {
|
||||
let accounts: Array<(typeof responses.accounts)[number]> = [];
|
||||
let createdBody: { authIndex?: string; publicVisible?: boolean } | undefined;
|
||||
let updatedBody: { publicVisible?: boolean } | 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: accounts });
|
||||
if (key === "accounts" && request.method() === "POST") {
|
||||
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,
|
||||
publicVisible: createdBody.publicVisible === true,
|
||||
};
|
||||
accounts = [account];
|
||||
return route.fulfill({ json: account });
|
||||
}
|
||||
if (key === "accounts/2" && request.method() === "PUT") {
|
||||
updatedBody = request.postDataJSON() as { publicVisible?: boolean };
|
||||
accounts = [
|
||||
{
|
||||
...accounts[0],
|
||||
publicVisible: updatedBody.publicVisible === true,
|
||||
},
|
||||
];
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
return route.fulfill({ json: responses[key] ?? {} });
|
||||
});
|
||||
|
||||
await page.goto("/settings");
|
||||
await page.getByRole("tab", { name: "Codex" }).click();
|
||||
const addVisibility = page.locator(
|
||||
".add-account-controls input[type=checkbox]",
|
||||
);
|
||||
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"]');
|
||||
await expect(accountVisibility).toBeChecked();
|
||||
await accountVisibility.evaluate((checkbox) =>
|
||||
(checkbox as HTMLInputElement).click(),
|
||||
);
|
||||
await expect.poll(() => updatedBody?.publicVisible).toBe(false);
|
||||
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();
|
||||
@@ -633,12 +849,12 @@ test("Codex account emails are masked everywhere they are displayed", async ({
|
||||
await expect(page.locator("body")).not.toContainText("test@example.com");
|
||||
});
|
||||
|
||||
test("returns to login when an authenticated request receives 401", async ({
|
||||
test("shows the public overview when the session is unavailable", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const key = new URL(route.request().url()).pathname.replace("/api/v1/", "");
|
||||
if (key === "accounts/1/sync")
|
||||
if (key === "auth/me")
|
||||
return route.fulfill({ status: 401, json: { error: "未登录" } });
|
||||
if (key === "dashboard")
|
||||
return route.fulfill({
|
||||
@@ -657,8 +873,7 @@ test("returns to login when an authenticated request receives 401", async ({
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".account-overview")).toBeVisible();
|
||||
await page.getByRole("button", { name: "刷新全部" }).click();
|
||||
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "登录后配置" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps each account dashboard in its own card when responses finish out of order", async ({
|
||||
|
||||
Reference in New Issue
Block a user