fix: refine docker update workflow

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-04 02:58:16 +08:00
co-authored by Codex
parent 3203ecdb67
commit 663226af6b
12 changed files with 512 additions and 197 deletions
+153 -82
View File
@@ -7,126 +7,197 @@
<p align="center">
<img src="https://img.shields.io/badge/Rust-1.95-000?style=flat-square&logo=rust" />
<img src="https://img.shields.io/badge/Axum-0.8-2f7d95?style=flat-square" />
<img src="https://img.shields.io/badge/Vue-3-42b883?style=flat-square&logo=vuedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/Vue-3.5-42b883?style=flat-square&logo=vuedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/Vite-8-646cff?style=flat-square&logo=vite&logoColor=white" />
<img src="https://img.shields.io/badge/SQLite-local-003b57?style=flat-square&logo=sqlite" />
<img src="https://img.shields.io/badge/License-MIT-blue?style=flat-square" />
</p>
## 概览
Codex Proxy RS 是一个单进程服务:
- Rust/Axum 后端提供 OpenAI 兼容 `/v1/*` 代理、管理端 API、SQLite 持久化和静态前端托管。
- Vue 管理端用于账号导入、API Key、用量统计、请求记录、运行参数、模型映射和系统更新。
- 运行数据默认收敛到仓库 `.runtime/`,Docker 也使用同一套宿主机目录做 bind mount。
## 环境
- Rust ≥ 1.95
- Node ≥ 20,pnpm ≥ 9(仅开发管理端前端时需要)
- Rust 1.95
- Node 24 或兼容版本,pnpm 11(只在开发前端或构建镜像时需要)
- Docker / Docker Compose(部署或验证镜像时需要)
## 快速开始
## 本地运行
本地直接运行后端时,配置文件由 `CPR_CONFIG_FILE` 指定;未指定时读取当前工作目录下的 `config.yaml`。
```bash
cargo run --manifest-path backend/Cargo.toml
cp deploy/config.example.yaml .runtime/config.yaml
```
启动后监听 `http://0.0.0.0:8080`,默认配置从运行目录下的 `config.yaml` 读取,也可以用 `CPR_CONFIG_FILE=/path/to/config.yaml` 显式指定。首次启动会自动创建管理端账号;`admin.default_password` 必须显式设置为至少 12 位的非默认密码。
Docker 部署:
```bash
cp deploy/config.example.yaml deploy/config.yaml
# 编辑 deploy/config.yaml,设置 admin.default_password
docker compose -f deploy/docker-compose.yml build
docker compose -f deploy/docker-compose.yml up -d
```
默认只监听宿主机 `127.0.0.1:8080`。挂载关系:`deploy/config.yaml` → `/app/config.yaml`,数据和日志写入 Compose 命名卷 `cpr-data`、`cpr-logs`。
## API
所有接口通过 Authorization Bearer header 鉴权,API Key 在管理端创建和管理。
| 端点 | 说明 |
| --- | --- |
| `POST /v1/responses` | OpenAI responses 接口 |
| `POST /v1/chat/completions` | Chat completions 接口 |
| `GET /v1/models` | 模型列表 |
| `GET /v1/models/catalog` | 模型目录 |
| `/api/admin/*` | 管理端 API(前端调用) |
```bash
curl http://127.0.0.1:8080/v1/responses \
-H 'Authorization: Bearer <your-api-key>' \
-H 'Content-Type: application/json' \
-d '{"model": "gpt-5.5", "input": "Say hello", "stream": true}'
```
## 管理控制台
前端是 Vue 3 单页应用,源码在 `frontend/`。开发时:
```bash
pnpm --dir frontend dev
```
生产构建后由后端直接托管:
```bash
pnpm --dir frontend build
cargo run --manifest-path backend/Cargo.toml
```
管理端功能包括账号管理、API Key 管理、用量仪表盘、请求记录和运行时设置。账号、Key、模型路由等运行时配置在管理端操作,不写在 YAML 里。
## 配置
最小 `config.yaml`:
如果直接用本地二进制而不是 Docker,确保 `.runtime/config.yaml` 里的路径是宿主机路径:
```yaml
server:
host: '0.0.0.0'
port: 8080
api:
base_url: 'https://chatgpt.com/backend-api'
database:
url: 'sqlite://.runtime/data/codex-proxy-rs.sqlite'
logging:
directory: .runtime/logs
retention_days: 14
admin:
default_password: '<set-a-long-random-password>'
```
完整配置项见 `deploy/config.example.yaml`,包括 TLS、WebSocket 连接池、请求指纹、账号额度策略等。
启动:
运行数据目录:
```text
.runtime/data/ # SQLite 数据库
.runtime/logs/ # 日志文件
```bash
mkdir -p .runtime/data .runtime/logs
CPR_CONFIG_FILE=.runtime/config.yaml cargo run --manifest-path backend/Cargo.toml
```
## 架构
服务默认监听 `http://0.0.0.0:8080`。
Codex Proxy RS 是单主服务架构:Rust 后端同时承载 OpenAI 兼容代理、管理端 API、SQLite 持久化、前端静态资源托管和在线更新。详细模块边界、请求链路、发布和更新流程见 [架构说明](docs/architecture.md)。
## Docker 部署
Compose 默认把宿主机 `.runtime` 映射进容器:
- `.runtime/config.yaml` -> `/app/config.yaml`
- `.runtime/data` -> `/app/data`
- `.runtime/logs` -> `/app/logs`
初始化配置:
```bash
mkdir -p .runtime/data .runtime/logs
cp deploy/config.example.yaml .runtime/config.yaml
# 编辑 .runtime/config.yaml,设置 admin.default_password
```
Docker 配置文件里路径保持容器路径:
```yaml
database:
url: 'sqlite:///app/data/codex-proxy-rs.sqlite'
logging:
directory: /app/logs
```
构建并启动:
```bash
docker compose -f deploy/docker-compose.yml build
docker compose -f deploy/docker-compose.yml up -d
```
默认只绑定宿主机 `127.0.0.1:8080`。如需覆盖路径:
```bash
CPR_CONFIG_FILE=/path/to/config.yaml \
CPR_DATA_DIR=/path/to/data \
CPR_LOG_DIR=/path/to/logs \
docker compose -f deploy/docker-compose.yml up -d
```
## API
客户端 API Key 在管理端创建。OpenAI 兼容接口通过 `Authorization: Bearer <key>` 鉴权。
| 端点 | 说明 |
| --- | --- |
| `POST /v1/responses` | OpenAI Responses 兼容接口 |
| `POST /v1/chat/completions` | Chat Completions 兼容接口 |
| `GET /v1/models` | 模型列表 |
| `GET /v1/models/{id}` | 模型详情 |
| `GET /v1/models/catalog` | 管理端可见模型目录 |
| `/api/admin/*` | 管理端 API |
示例:
```bash
curl http://127.0.0.1:8080/v1/responses \
-H 'Authorization: Bearer <your-api-key>' \
-H 'Content-Type: application/json' \
-d '{"model":"gpt-5.5","input":"Say hello","stream":true}'
```
## 管理端
生产环境前端由后端直接托管,不需要单独 Node 服务。开发前端时:
```bash
pnpm --dir frontend install
pnpm --dir frontend dev
```
生产构建:
```bash
pnpm --dir frontend build
CPR_CONFIG_FILE=.runtime/config.yaml cargo run --manifest-path backend/Cargo.toml
```
管理端主要功能:
- 账号导入、OAuth 授权、连接测试、quota 刷新、token 刷新。
- 客户端 API Key 管理。
- 请求记录、Token 用量、模型用量和账号额度。
- 运行参数、模型别名、账号选择策略。
- 版本检查、在线更新、更新日志、重启和回滚。
## 配置与运行数据
配置文件只承载启动必需项。账号、API Key、模型映射、账号选择策略和多数运行参数由管理端写入 SQLite。
`.runtime` 约定:
```text
.runtime/config.yaml # 本地或 Docker 启动配置,按实际运行环境写路径
.runtime/data/codex-proxy-rs.sqlite # SQLite 数据库
.runtime/data/update-state.json # 在线更新状态
.runtime/data/update.lock # 在线更新锁
.runtime/logs/ # 日志
```
## 发布与更新
版本信息在 `release/version.yaml`,发布平台在 `release/platforms.yaml`。
发布命令:
```bash
release/publish 0.1.5
```
该命令会更新版本文件、生成版本提交、创建 `v<version>` tag 并推送,随后 GitHub Actions 构建 Release 归档和 GHCR 多平台镜像。
在线更新由主服务处理,管理端调用 `/api/admin/system/*`:
- 检查 GitHub Release。
- 下载匹配当前平台的归档和 `checksums.txt`。
- 校验 checksum。
- 替换二进制和 `web/dist`。
- Docker 模式下依赖 `restart: unless-stopped` 和自重启重新拉起服务。
## 项目结构
```text
backend/
src/ Rust 源码
tests/ 集成测试
build/ 构建脚本
frontend/ Vue 3 管理控制台
deploy/ Dockerfile、Compose、示例配置
docs/ 架构与维护文档
backend/ Rust/Axum 后端、SQLite migration、集成测试
frontend/ Vue 3 管理端
deploy/ Dockerfile、Compose、部署配置模板
docs/ 架构、审计和维护文档
release/ 版本、平台和发布脚本
skills/ 项目本地 Codex skill
```
## 开发
## 开发检查
```bash
cargo fmt --manifest-path backend/Cargo.toml --check
cargo clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
cargo test --manifest-path backend/Cargo.toml --test main
cargo test --manifest-path backend/Cargo.toml --test main --locked
pnpm --dir frontend build
docker compose -f deploy/docker-compose.yml config
```
## License
+16 -6
View File
@@ -114,6 +114,7 @@ struct SystemUpdateConfig {
web_dist_dir: PathBuf,
update_state_file: PathBuf,
update_lock_file: PathBuf,
update_temp_dir: PathBuf,
}
/// `GET /api/admin/system/version`
@@ -336,6 +337,9 @@ impl SystemUpdateConfig {
let update_lock_file = env_string("CPR_UPDATE_LOCK_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| state_file.with_extension("lock"));
let update_temp_dir = env_string("CPR_UPDATE_TEMP_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| default_update_temp_dir(&state_file));
Self {
version: build_version(),
git_sha: build_git_sha(),
@@ -354,6 +358,7 @@ impl SystemUpdateConfig {
.unwrap_or_else(|| PathBuf::from(DEFAULT_WEB_DIST_DIR)),
update_state_file: state_file,
update_lock_file,
update_temp_dir,
}
}
@@ -465,12 +470,9 @@ async fn perform_release_update(
Some("prepare"),
"正在创建临时更新目录",
);
let exe_path = config.executable_path()?;
let exe_dir = exe_path
.parent()
.ok_or_else(|| AdminError::internal("Executable path has no parent"))?;
let temp_dir = fs::canonicalize(exe_dir)
.and_then(|dir| fs::create_dir_all(&dir).map(|()| dir))
fs::create_dir_all(&config.update_temp_dir)
.map_err(internal_error_with("Failed to prepare update temp dir"))?;
let temp_dir = fs::canonicalize(&config.update_temp_dir)
.and_then(|dir| tempfile_dir_in(&dir, ".codex-proxy-rs-update-"))
.map_err(internal_error_with("Failed to create update temp dir"))?;
let archive_path = temp_dir.join(&archive.name);
@@ -532,6 +534,7 @@ async fn perform_release_update(
Some("replace"),
"正在替换应用文件",
);
let exe_path = config.executable_path()?;
replace_release_files(&exe_path, &config.web_dist_dir, extracted)?;
emit_update_event(
UpdateLogLevel::Success,
@@ -561,6 +564,13 @@ fn tempfile_dir_in(parent: &Path, prefix: &str) -> io::Result<PathBuf> {
))
}
fn default_update_temp_dir(state_file: &Path) -> PathBuf {
state_file
.parent()
.map(|parent| parent.join("update-tmp"))
.unwrap_or_else(|| env::temp_dir().join("codex-proxy-rs-update"))
}
fn env_string(key: &str) -> Option<String> {
env::var(key)
.ok()
+2
View File
@@ -995,6 +995,7 @@ fn set_system_update_env(repository: &str, github_api_base: &str) {
std::env::remove_var("CPR_WEB_DIST_DIR");
std::env::remove_var("CPR_UPDATE_STATE_FILE");
std::env::remove_var("CPR_UPDATE_LOCK_FILE");
std::env::remove_var("CPR_UPDATE_TEMP_DIR");
std::env::remove_var("CPR_ENABLE_SELF_RESTART");
}
@@ -1006,6 +1007,7 @@ fn set_system_update_paths(root: &Path, exe_path: &Path, web_dist: &Path) {
root.join(".runtime/update-state.json"),
);
std::env::set_var("CPR_UPDATE_LOCK_FILE", root.join(".runtime/update.lock"));
std::env::set_var("CPR_UPDATE_TEMP_DIR", root.join(".runtime/update-tmp"));
}
async fn wait_for_operation_status(app: &axum::Router, expected: &str) -> Value {
+6 -5
View File
@@ -54,22 +54,23 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& groupadd --system --gid 10001 cpr \
&& useradd --system --uid 10001 --gid cpr --home-dir /app --shell /usr/sbin/nologin cpr \
&& install -d -o cpr -g cpr /app/data /app/logs /app/web \
&& install -d -o cpr -g cpr /app/bin /app/data /app/logs /app/web \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV CPR_DEPLOYMENT_MODE=docker
ENV PATH="/app/bin:${PATH}"
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
CMD ["codex-proxy-rs"]
CMD ["/app/bin/codex-proxy-rs"]
FROM runtime-base AS runtime
COPY --from=backend-builder --chown=cpr:cpr /tmp/codex-proxy-rs /usr/local/bin/codex-proxy-rs
COPY --from=backend-builder --chown=cpr:cpr /tmp/codex-proxy-rs /app/bin/codex-proxy-rs
COPY --from=frontend-builder --chown=cpr:cpr /app/frontend/dist ./web/dist
USER cpr
@@ -78,7 +79,7 @@ FROM runtime-base AS runtime-prebuilt
ARG TARGETARCH
ARG CPR_PREBUILT_DIR=dist/docker
COPY --chown=cpr:cpr ${CPR_PREBUILT_DIR}/linux-${TARGETARCH}/codex-proxy-rs /usr/local/bin/codex-proxy-rs
COPY --chown=cpr:cpr ${CPR_PREBUILT_DIR}/linux-${TARGETARCH}/codex-proxy-rs /app/bin/codex-proxy-rs
COPY --chown=cpr:cpr ${CPR_PREBUILT_DIR}/web/dist ./web/dist
RUN chmod +x /usr/local/bin/codex-proxy-rs
RUN chmod +x /app/bin/codex-proxy-rs
USER cpr
+11 -9
View File
@@ -7,10 +7,16 @@ Docker 部署入口集中在本目录。
首次部署先复制样例配置:
```bash
cp deploy/config.example.yaml deploy/config.yaml
mkdir -p .runtime/data .runtime/logs
cp deploy/config.example.yaml .runtime/config.yaml
```
`deploy/config.example.yaml` 是 Docker 部署模板,默认将数据写入容器内 `/app/data`、日志写入 `/app/logs`,由 Compose 命名卷持久化。
`deploy/config.example.yaml` 是 Docker 部署模板。Compose 默认把配置、数据和日志都放在仓库 `.runtime/` 下:
- `.runtime/config.yaml` -> `/app/config.yaml`
- `.runtime/data` -> `/app/data`
- `.runtime/logs` -> `/app/logs`
复制后必须设置 `admin.default_password`,空值或常见弱口令会被后端拒绝启动。
如果前面还有 Nginx、Caddy、Cloudflare Tunnel 等反向代理,只在该代理会覆盖或清理 `X-Forwarded-For` / `X-Real-IP` / `CF-Connecting-IP` 时,把代理的 IP 或 CIDR 写入 `server.trusted_proxies`;默认空列表会剥离这些客户端可伪造的头并使用 TCP peer IP。
@@ -21,16 +27,12 @@ docker compose -f deploy/docker-compose.yml build
docker compose -f deploy/docker-compose.yml up -d
```
默认挂载和卷:
- `deploy/config.yaml` -> `/app/config.yaml`
- `cpr-data` -> `/app/data`
- `cpr-logs` -> `/app/logs`
可以通过环境变量覆盖宿主机配置文件路径;容器内会通过 `CPR_CONFIG_FILE=/app/config.yaml` 显式读取挂载后的配置:
可以通过环境变量覆盖宿主机路径;容器内会通过 `CPR_CONFIG_FILE=/app/config.yaml` 显式读取挂载后的配置:
```bash
CPR_CONFIG_FILE=/path/to/config.yaml \
CPR_DATA_DIR=/path/to/data \
CPR_LOG_DIR=/path/to/logs \
docker compose -f deploy/docker-compose.yml up -d
```
+2 -2
View File
@@ -11,7 +11,7 @@ api:
base_url: 'https://chatgpt.com/backend-api'
database:
# Docker 部署默认写入 /app/data,由 Compose 挂载到宿主机 deploy/data。
# Docker 部署默认写入 /app/data,由 Compose 挂载到仓库 .runtime/data。
url: 'sqlite:///app/data/codex-proxy-rs.sqlite'
quota:
@@ -89,7 +89,7 @@ admin:
default_password: ''
logging:
# Docker 部署默认写入 /app/logs,由 Compose 挂载到宿主机 deploy/logs。
# Docker 部署默认写入 /app/logs,由 Compose 挂载到仓库 .runtime/logs。
directory: /app/logs
# 日志保留天数。
retention_days: 14
+6 -7
View File
@@ -21,9 +21,9 @@ services:
ports:
- "127.0.0.1:8080:8080"
volumes:
- ${CPR_CONFIG_FILE:-./config.yaml}:/app/config.yaml:ro
- cpr-data:/app/data
- cpr-logs:/app/logs
- ${CPR_CONFIG_FILE:-../.runtime/config.yaml}:/app/config.yaml:ro
- ${CPR_DATA_DIR:-../.runtime/data}:/app/data
- ${CPR_LOG_DIR:-../.runtime/logs}:/app/logs
security_opt:
- no-new-privileges:true
cap_drop:
@@ -37,15 +37,14 @@ services:
retries: 3
start_period: 10s
environment:
HOME: /app
XDG_DATA_HOME: /app/data
CPR_CONFIG_FILE: /app/config.yaml
CPR_DEPLOYMENT_MODE: docker
CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs
CPR_UPDATE_CHANNEL: stable
CPR_UPDATE_TEMP_DIR: /app/data/update-tmp
CPR_UPDATE_STATE_FILE: /app/data/update-state.json
CPR_UPDATE_LOCK_FILE: /app/data/update.lock
CPR_WEB_DIST_DIR: /app/web/dist
CPR_ENABLE_SELF_RESTART: "true"
volumes:
cpr-data:
cpr-logs:
+141 -76
View File
@@ -2,114 +2,164 @@
## 总览
Codex Proxy RS 是一个单主服务应用:Rust 后端提供 OpenAI 兼容代理、管理端 API、SQLite 持久化、静态前端托管和在线更新能力;Vue 管理端作为 SPA 由同一个后端进程托管。
Codex Proxy RS 是一个单主进程应用。Rust 后端同时承担 OpenAI 兼容代理、管理端 API、SQLite 持久化、后台任务、静态前端托管和在线更新;Vue 管理端作为 SPA 由同一个后端进程服务。
```mermaid
flowchart LR
Client["客户端 / SDK"] -->|"OpenAI API /v1/*"| Http["Axum HTTP Router"]
Browser["浏览器"] -->|"管理端 SPA"| Web["web 静态资源托管"]
Client["客户端 / OpenAI SDK"] -->|"Authorization: Bearer<br/>/v1/*"| Http["Axum Router"]
Browser["浏览器"] -->|"管理端 SPA"| Web["web/dist"]
Browser -->|"/api/admin/*"| Http
subgraph App["codex-proxy-rs 单主进程"]
Http --> Proxy["proxy<br/>API Key 鉴权<br/>OpenAI 兼容协议"]
subgraph App["codex-proxy-rs"]
Http --> Proxy["proxy<br/>OpenAI 兼容入口<br/>客户端 API Key 鉴权"]
Http --> Admin["admin<br/>账号 / Key / 设置 / 监控 / 系统更新"]
Http --> Web
Proxy --> Dispatch["dispatch<br/>账号选择 / 限流 / 亲和性 / 失败回退"]
Dispatch --> Protocol["upstream protocol<br/>请求转换 / SSE 转换"]
Protocol --> Transport["upstream transport<br/>HTTP SSE / WebSocket"]
Proxy --> Dispatch["dispatch<br/>账号选择<br/>限流 / quota / 亲和性 / 回退"]
Dispatch --> Protocol["upstream protocol<br/>请求转换<br/>SSE / 错误映射"]
Protocol --> Transport["upstream transport<br/>HTTP / SSE / WebSocket"]
Admin --> Runtime["runtime services<br/>账号池 / 后台任务 / 更新状态"]
Dispatch --> Runtime
Runtime --> Store[("SQLite<br/>账号 / 会话 / 使用记录 / 设置")]
Runtime --> Store[("SQLite<br/>账号 / Key / session / 用量 / 设置")]
Admin --> Store
end
Transport --> Upstream["ChatGPT / Codex Upstream"]
Web --> Frontend["frontend/dist<br/>Vue 3 管理端"]
Release["GitHub Release<br/>tar.gz + checksums"] --> Update["在线更新<br/>下载 / 校验 / 替换 / 重启"]
Update --> Admin
GHCR["GHCR<br/>linux/amd64 + linux/arm64"] --> Docker["Docker Compose<br/>restart: unless-stopped"]
Release["GitHub Release<br/>archives + checksums"] --> Updater["self update<br/>下载 / 校验 / 替换 / 重启"]
Updater --> Admin
GHCR["GHCR image<br/>linux/amd64 + linux/arm64"] --> Docker["Docker Compose<br/>.runtime bind mounts"]
```
## 仓库边界
- `backend/`:Rust/Axum 后端、SQLite schema、运行时服务、集成测试和 Cargo 构建脚本。
- `frontend/`:Vue 3 管理端,使用 Vite、Tailwind v4、Pinia、Vue Router 和 ECharts。
- `deploy/`:Dockerfile、Compose 文件、Docker ignore 文件和部署样例配置。
- `docs/`:长期维护文档,只记录当前真实架构和约定,不保留迁移过程作为开发约束。
- `backend/`:Rust/Axum 后端、SQLite migration、构建脚本和集成测试。
- `frontend/`:Vue 3 管理端,使用 Vite 8、Tailwind v4、Pinia、Vue Router、ECharts 和 lucide 图标。
- `deploy/`:Dockerfile、Compose 和 Docker 部署配置模板。
- `docs/`:长期维护文档和审计记录,只记录当前有效结构与决策。
- `release/`:版本号、平台矩阵和发布脚本。
- `skills/`:项目本地 Codex skill。
当前后端是 `backend/` 单 crate。不要在文档里保留旧迁移布局或兼容性重导出约束,除非代码重新引入对应结构。
## 后端结构
后端入口是 `backend/src/main.rs`,公共库入口是 `backend/src/lib.rs`。运行时由 `runtime::bootstrap` 初始化,服务集合在 `runtime::services` 构造,并通过 `AppState` 注入到路由。
入口:
- `backend/src/main.rs`:二进制入口。
- `backend/src/lib.rs`:集成测试和二进制共享的库入口。
- `backend/src/runtime/bootstrap.rs`:加载配置、初始化日志、连接数据库、构造服务、启动 HTTP。
- `backend/src/runtime/services.rs`:集中构造账号、模型、监控、更新等运行时服务。
主要模块:
- `admin/`:管理端 API,包括认证、账号、API Key、监控、设置和系统更新。
- `proxy/`:OpenAI 兼容代理入口、鉴权、请求转换和账号调度。
- `upstream/`:ChatGPT/Codex 上游协议、传输层、账号模型、token 刷新、quota 刷新和 fingerprint。
- `config/`:配置加载、运行时设置和配置类型。
- `infra/`:数据库、日志、时间、格式化、JSON 和 schema。
- `http/`:HTTP router、中间件和统一请求上下文。
- `web/`:前端静态资源和 SPA fallback 托管。
- `admin/`:管理端 API,包括登录 session、账号、API Key、监控、运行设置和系统更新。
- `proxy/`:OpenAI 兼容 HTTP 入口、客户端 API Key 鉴权、请求分派。
- `proxy/openai/`:Responses、Chat Completions 和 Models 的协议入口。
- `proxy/dispatch/`:账号选择、quota/限流处理、错误映射、session affinity 和失败回退。
- `upstream/`:ChatGPT/Codex 上游账号、模型、协议、传输、token 刷新和 fingerprint。
- `config/`:启动配置、运行时设置和管理端设置落库。
- `infra/`:SQLite、migration、日志、时间、格式化、JSON 和身份哈希。
- `http/`:router、中间件、可信代理和请求上下文。
- `web/`:前端静态资源和 SPA fallback。
测试只放在 `backend/tests/`,不要在 `backend/src/` 中新增测试模块。
测试统一放在 `backend/tests/`,不在 `backend/src/` 新增测试模块。
## 数据和运行时
## 配置和运行时目录
后端以 SQLite 作为主要持久化存储。默认运行时数据位于 `.runtime/`,Docker 部署时通过 Compose 挂载到 `/app/data` 和 `/app/logs`。
启动配置由 `CPR_CONFIG_FILE` 指定;未指定时读取当前工作目录的 `config.yaml`。
运行时状态分两类:
配置文件只负责启动必需项:
- 持久化状态:账号、cookie、API Key、session、使用记录、时间桶、fingerprint、运行时设置等。
- 内存状态:账号池、调度状态、运行中任务、SSE 更新日志广播、缓存的 release 检查结果等。
- HTTP 监听。
- 上游 base URL。
- SQLite URL。
- 日志目录。
- 管理员首次初始化密码。
- TLS、WebSocket 池、quota 周期和 fingerprint 默认值。
运行时设置通过管理端 API 修改后写入数据库,并同步到内存服务。
账号、客户端 API Key、模型别名、账号选择策略和多数运行参数由管理端写入 SQLite。
`.runtime` 是本项目统一运行目录:
```text
.runtime/config.yaml
.runtime/data/codex-proxy-rs.sqlite
.runtime/data/update-state.json
.runtime/data/update.lock
.runtime/logs/
```
Docker 也使用宿主机 `.runtime`,但容器内配置路径仍然写 `/app/data` 和 `/app/logs`。Compose 默认映射:
```text
.runtime/config.yaml -> /app/config.yaml
.runtime/data -> /app/data
.runtime/logs -> /app/logs
```
容器以非 root 用户 `10001:10001` 运行。Compose 设置 `HOME=/app` 和 `XDG_DATA_HOME=/app/data`,确保 installation id、SQLite、更新状态和日志都写入可持久化目录。
## 代理请求链路
客户端请求进入 `/v1/*`:
1. `proxy/auth` 校验客户端 API Key。
1. `proxy/auth` 校验客户端 API Key,并记录调用上下文。
2. `proxy/openai` 解析 OpenAI 兼容请求。
3. `proxy/dispatch` 选择账号、处理限流、亲和性和失败回退。
4. `upstream/protocol` 转换 Codex/ChatGPT 上游协议。
5. `upstream/transport` 使用 HTTP SSE 或 WebSocket 访问上游。
6. 响应被转换回 OpenAI 兼容格式,并记录使用数据。
3. `proxy/dispatch` 按模型、quota、账号状态和选择策略挑选账号。
4. `upstream/protocol` 转换为 ChatGPT/Codex 上游协议。
5. `upstream/transport` 使用 HTTP、SSE 或 WebSocket 访问上游。
6. 响应和错误被转换回 OpenAI 兼容格式。
7. 用量、模型维度统计、请求记录、账号窗口和失败状态写回 SQLite/账号池。
账号运行时状态由 SQLite 快照恢复,并在请求、刷新、quota 检查和失败回退时更新。
账号状态由 SQLite 恢复到内存账号池,并在请求、token 刷新、quota 刷新、Cloudflare 处理和失败回退时同步更新。
## 管理端
管理端 API 位于 `/api/admin/*`,使用 session cookie 保护。前端入口由后端托管,不单独部署 Node 服务。
管理端 API 位于 `/api/admin/*`,使用 session cookie 保护。前端由后端托管,不单独部署 Node 服务。
前端结构:
- `frontend/src/api/modules`:管理端 API 客户端。
- `frontend/src/components/base`:基础 UI 组件。
- `frontend/src/layout`:主布局、侧边栏、系统更新入口和更新弹窗。
- `frontend/src/views`:各业务页面。
- `frontend/src/layout`:主布局、侧边栏和系统更新弹窗。
- `frontend/src/views`:业务页面。
- `frontend/src/stores`:Pinia 状态。
- `frontend/src/styles/tokens.css`:亮色/暗色主题 token。
前端只展示后端返回的版本、状态和更新日志,不在前端做状态文案映射。
管理端页面包括:
## 版本和发布
- 账号管理:导入、OAuth、连接测试、额度、模型、调度状态、刷新能力。
- API Key 管理:客户端 Key 和管理员 API Key。
- Dashboard:请求趋势、账号概览、服务状态。
- 请求明细:网关调用、上游状态、Token 消耗、错误和详情。
- 设置:运行参数、模型映射、账号选择策略。
- 系统更新:版本、检查更新、SSE 更新日志、更新、重启、回滚。
版本和平台发布契约集中在 `release/`:
## 发布
- `release/version.yaml`:产品版本,发布 tag 固定使用 `v` 前缀。
- `release/platforms.yaml`:Release asset 构建平台、归档格式和 Docker 平台。
版本契约集中在 `release/`:
日常发布只需要执行 `release/publish 0.1.4`。该命令会更新 `release/version.yaml`、生成带署名的版本提交、创建 `v0.1.4` tag,并 push 分支和 tag 触发发布 workflow。
- `release/version.yaml`:当前产品版本。
- `release/platforms.yaml`:Release asset 和 Docker 平台矩阵。
- `release/publish`:更新版本文件、生成版本提交、创建 tag 并 push。
发布 workflow 会校验触发 tag 必须等于 `v` + `version`。发布构建时通过 `CPR_VERSION`、`CPR_GIT_SHA`、`CPR_BUILD_TIME` 和 `CPR_BUILD_TYPE=release` 注入编译期元数据。
发布命令:
`backend/build/build.rs` 负责把这些值写入二进制。没有 `CPR_VERSION` 时,版本回落到 `release/version.yaml`,最后才回落到 Cargo 的 `CARGO_PKG_VERSION`。
```bash
release/publish 0.1.5
```
GitHub Release 产物:
发布 workflow 校验触发 tag 必须等于 `v` + `release/version.yaml`。构建时注入:
- `CPR_VERSION`
- `CPR_GIT_SHA`
- `CPR_BUILD_TIME`
- `CPR_BUILD_TYPE=release`
`backend/build/build.rs` 负责把这些值写入二进制。没有 `CPR_VERSION` 时,版本回落到 `release/version.yaml`,最后才回落到 Cargo 包版本。
Release 产物:
```text
codex-proxy-rs_<version>_linux_amd64.tar.gz
@@ -120,11 +170,13 @@ codex-proxy-rs_<version>_windows_amd64.zip
checksums.txt
```
GHCR 镜像发布为 `linux/amd64` 和 `linux/arm64` 多平台 manifest。
GHCR 镜像发布 `linux/amd64` 和 `linux/arm64` 多平台 manifest。
## 在线更新
在线更新由主服务自己处理,不依赖独立 updater sidecar。核心接口:
在线更新由主服务处理,没有独立 updater sidecar。
核心接口:
- `GET /api/admin/system/version`
- `GET /api/admin/system/check-updates`
@@ -132,44 +184,57 @@ GHCR 镜像发布为 `linux/amd64` 和 `linux/arm64` 多平台 manifest。
- `GET /api/admin/system/update-status`
- `POST /api/admin/system/update`
- `POST /api/admin/system/restart`
- `POST /api/admin/system/rollback`
更新流程:
1. 后端检查 GitHub latest release。
2. 按当前 OS/arch 选择匹配 asset,兼容 `amd64/x86_64` 和 `arm64/aarch64` 命名。
3. 下载 release tar 包并用 `checksums.txt` 校验。
4. 解压二进制和 `web/dist`。
5. 替换当前二进制和前端静态资源。
6. 前端通过 SSE 实时展示后端推送的中文更新日志。
7. Docker 部署依靠 `restart: unless-stopped` 在自重启后拉起新进程。
1. 查询 GitHub Release。
2. 按当前 OS/arch 选择 asset,支持 `amd64/x86_64` 与 `arm64/aarch64` 命名。
3. 下载归档和 `checksums.txt`。
4. 校验 checksum。
5. 解压二进制和 `web/dist`。
6. 替换当前二进制和前端静态资源。
7. 写入 `.runtime/data/update-state.json`。
8. 通过 SSE 推送中文更新日志。
9. 管理端触发重启;Docker 模式依靠 `restart: unless-stopped` 拉起新进程。
替换逻辑必须支持跨文件系统 fallback,因为容器文件系统里 `rename` 可能遇到 `Invalid cross-device link`。
替换逻辑必须支持跨文件系统 fallback,因为容器环境中 `rename` 可能遇到 `Invalid cross-device link`。
## Docker 部署
## Docker
Docker 构建入口是 `deploy/Dockerfile`,上下文保持仓库根目录。
`deploy/Dockerfile` 的构建上下文是仓库根目录。
构建阶段:
阶段:
- `frontend-builder`:构建 Vue SPA。
- `frontend-builder`:安装前端依赖并构建 Vue SPA。
- `backend-builder`:构建 Rust release 二进制,并复制前端 dist。
- `release-asset`:导出 GitHub Release tar 包。
- `runtime`:运行镜像基于 `debian:bookworm-slim`,包含 CA、curl healthcheck、后端二进制和 `web/dist`,默认以非 root 用户运行。
- `release-asset-builder` / `release-asset`:生成 GitHub Release 归档。
- `runtime-base`:Debian slim、CA、curl、非 root 用户、运行目录。
- `runtime`:从源码构建出的运行镜像。
- `runtime-prebuilt`:发布 workflow 使用的预构建二进制镜像阶段。
Compose 文件是 `deploy/docker-compose.yml`。示例配置只保留 `deploy/config.example.yaml`,用户复制为 `deploy/config.yaml` 后部署。
Compose 默认:
## CI 和安全扫描
- 镜像:`ghcr.io/zyycn/codex-proxy-rs:latest`,可用 `CPR_IMAGE` 覆盖。
- 端口:`127.0.0.1:8080:8080`。
- 数据:`.runtime/data:/app/data`。
- 日志:`.runtime/logs:/app/logs`。
- 配置:`.runtime/config.yaml:/app/config.yaml:ro`。
- 更新:`CPR_UPDATE_REPOSITORY=zyycn/codex-proxy-rs`,`CPR_ENABLE_SELF_RESTART=true`。
## CI
常规 CI 位于 `.github/workflows/ci.yml`:
- Rust fmt、clippy、集成测试。
- 前端依赖安装和 build。
- Compose 配置校验。
- Rust fmt、clippy 和集成测试。
- 前端 install、typecheck/build。
- Docker Compose 配置校验。
- Runtime image build。
安全扫描位于 `.github/workflows/security-scan.yml`:
- `cargo audit` 扫描 Rust 依赖。
- `pnpm audit --prod --audit-level=high` 扫描前端生产依赖。
- 每周定时运行,同时覆盖 push 和 pull request。
- `cargo audit`。
- `pnpm audit --prod --audit-level=high`。
- Docker runtime image security scan。
发布流程位于 `.github/workflows/release.yml`,只由 `v*` tag 或手动指定 tag 触发。
发布流程位于 `.github/workflows/release.yml`,由 `v*` tag 或手动指定 tag 触发。
+2
View File
@@ -17,9 +17,11 @@
"@vueuse/core": "^14.3.0",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"dompurify": "^3.4.11",
"echarts": "^6.1.0",
"es-toolkit": "^1.49.0",
"gsap": "^3.15.0",
"marked": "^18.0.5",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.39",
+26
View File
@@ -26,6 +26,9 @@ importers:
dayjs:
specifier: ^1.11.21
version: 1.11.21
dompurify:
specifier: ^3.4.11
version: 3.4.11
echarts:
specifier: ^6.1.0
version: 6.1.0
@@ -35,6 +38,9 @@ importers:
gsap:
specifier: ^3.15.0
version: 3.15.0
marked:
specifier: ^18.0.5
version: 18.0.5
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3))
@@ -362,6 +368,9 @@ packages:
'@types/node@26.1.0':
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@@ -541,6 +550,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dompurify@3.4.11:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -751,6 +763,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@18.0.5:
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1244,6 +1261,9 @@ snapshots:
dependencies:
undici-types: 8.3.0
'@types/trusted-types@2.0.7':
optional: true
'@types/web-bluetooth@0.0.21': {}
'@vitejs/plugin-vue@6.0.7(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))':
@@ -1457,6 +1477,10 @@ snapshots:
detect-libc@2.1.2: {}
dompurify@3.4.11:
optionalDependencies:
'@types/trusted-types': 2.0.7
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -1629,6 +1653,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@18.0.5: {}
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
@@ -4,6 +4,7 @@ import {
ArrowUpCircle,
CheckCircle2,
Circle,
ExternalLink,
Power,
RefreshCw,
Terminal,
@@ -15,6 +16,7 @@ import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { toast } from '@/components/base/BaseToast'
import { useSystemUpdate, type SystemUpdateLogLevel } from '@/composables/useSystemUpdate'
import { renderMarkdown } from '@/utils/markdown'
const open = defineModel<boolean>({ default: false })
@@ -89,21 +91,26 @@ const statusView = computed(() => {
const summaryItems = computed(() => [
{
key: 'current',
label: '当前版本',
value: loading.value ? '...' : version.value?.version ? `v${version.value.version}` : '-',
title: version.value?.version,
},
{
key: 'latest',
label: '最新版本',
value: updateInfo.value?.latestVersion ? `v${updateInfo.value.latestVersion}` : '-',
title: updateInfo.value?.latestVersion,
releaseUrl: updateInfo.value?.releaseUrl,
},
{
key: 'build',
label: '构建',
value: displayValue(updateInfo.value?.buildTypeLabel),
title: updateInfo.value?.buildType,
},
{
key: 'deployment',
label: '部署',
value: displayValue(version.value?.deploymentModeLabel),
title: version.value?.deploymentMode,
@@ -120,6 +127,7 @@ const updateLogRows = computed(() =>
const showUpdateProgress = computed(
() => hasUpdate.value || updating.value || updateSuccess.value || updateLogRows.value.length > 0,
)
const renderedReleaseNotes = computed(() => renderMarkdown(updateInfo.value?.notes))
const streamStatusLabel = computed(() => {
if (updateStreaming.value) return '实时'
@@ -228,7 +236,7 @@ onUnmounted(() => {
</p>
</div>
<span
class="inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-[760]"
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-[760]"
:class="statusView.badge"
>
<component :is="statusView.icon" class="size-3.5" :class="statusView.iconClass" />
@@ -239,12 +247,24 @@ onUnmounted(() => {
<div class="grid gap-2.5 sm:grid-cols-4">
<div
v-for="item in summaryItems"
:key="item.label"
:key="item.key"
class="min-w-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3 py-2.5"
>
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">
{{ item.label }}
</p>
<div class="flex min-w-0 items-center justify-between gap-2">
<p class="m-0 truncate text-[11px] leading-none font-[760] text-(--cp-text-muted)">
{{ item.label }}
</p>
<a
v-if="item.releaseUrl"
:href="item.releaseUrl"
target="_blank"
rel="noreferrer"
class="inline-flex shrink-0 items-center gap-1 text-[11px] leading-none font-[720] text-(--cp-info-text) transition-colors hover:text-(--cp-info)"
>
发布页
<ExternalLink class="size-3" />
</a>
</div>
<p
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-[720] text-(--cp-text-primary)"
:title="item.title || item.value"
@@ -253,6 +273,13 @@ onUnmounted(() => {
</p>
</div>
</div>
<p
v-if="updateError || updateInfo?.warning"
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-danger-bg) px-3 py-2 text-[12px] leading-normal font-[720] text-(--cp-danger-text)"
>
{{ updateError || updateInfo?.warning }}
</p>
</section>
<section
@@ -302,7 +329,7 @@ onUnmounted(() => {
</section>
<section
v-if="updateInfo?.notes"
v-if="renderedReleaseNotes"
class="grid gap-2 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
>
<div class="flex items-center justify-between gap-3">
@@ -311,10 +338,8 @@ onUnmounted(() => {
{{ displayValue(updateInfo?.latestVersion) }}
</span>
</div>
<BaseScrollbar max-height="160px" view-class="pr-2">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.6] font-[620] text-(--cp-text-primary)"
>{{ updateInfo.notes }}</pre>
<BaseScrollbar class="-mx-4" max-height="160px" view-class="px-4 pr-5" track-inset="none">
<div class="release-notes" v-html="renderedReleaseNotes" />
</BaseScrollbar>
</section>
</div>
@@ -358,3 +383,89 @@ onUnmounted(() => {
</template>
</BaseModal>
</template>
<style scoped>
.release-notes {
color: var(--cp-text-primary);
font-size: 12px;
font-weight: 620;
line-height: 1.65;
overflow-wrap: anywhere;
}
.release-notes :deep(*) {
max-width: 100%;
}
.release-notes :deep(:first-child) {
margin-top: 0;
}
.release-notes :deep(:last-child) {
margin-bottom: 0;
}
.release-notes :deep(h1),
.release-notes :deep(h2),
.release-notes :deep(h3),
.release-notes :deep(h4) {
margin: 12px 0 6px;
color: var(--cp-text-primary);
font-size: 12px;
font-weight: 780;
line-height: 1.4;
}
.release-notes :deep(p) {
margin: 0 0 8px;
}
.release-notes :deep(ul),
.release-notes :deep(ol) {
margin: 0 0 8px;
padding-left: 18px;
}
.release-notes :deep(li) {
margin: 3px 0;
}
.release-notes :deep(a) {
color: var(--cp-info-text);
font-weight: 720;
text-decoration: none;
}
.release-notes :deep(a:hover) {
color: var(--cp-info);
}
.release-notes :deep(code) {
border-radius: 5px;
background: var(--cp-bg-muted);
color: var(--cp-text-primary);
font-family: var(--font-mono);
font-size: 11px;
padding: 1px 5px;
}
.release-notes :deep(pre) {
margin: 8px 0;
overflow-x: auto;
border-radius: var(--cp-input-radius-base);
background: var(--cp-bg-surface);
padding: 8px 10px;
}
.release-notes :deep(pre code) {
background: transparent;
padding: 0;
}
.release-notes :deep(blockquote) {
margin: 8px 0;
border-left: 3px solid var(--cp-divider-subtle);
color: var(--cp-text-secondary);
padding-left: 10px;
}
</style>
+26
View File
@@ -0,0 +1,26 @@
import DOMPurify from 'dompurify'
import { Marked, Renderer } from 'marked'
const renderer = new Renderer()
const defaultLinkRenderer = renderer.link.bind(renderer)
renderer.link = (token) => {
const html = defaultLinkRenderer(token)
return html.replace('<a ', '<a target="_blank" rel="noreferrer" ')
}
const marked = new Marked({
async: false,
breaks: false,
gfm: true,
renderer,
})
export function renderMarkdown(source?: string | null) {
const value = source?.trim()
if (!value) return ''
return DOMPurify.sanitize(marked.parse(value, { async: false }), {
ADD_ATTR: ['target', 'rel'],
})
}