From 663226af6b0ffaec4fc9a13844ffb5a998c0f6a8 Mon Sep 17 00:00:00 2001
From: zyycn
Date: Sat, 4 Jul 2026 02:58:16 +0800
Subject: [PATCH] fix: refine docker update workflow
Co-authored-by: Codex
---
README.md | 235 ++++++++++++------
backend/src/admin/system/updater/mod.rs | 22 +-
backend/tests/admin/system/mod.rs | 2 +
deploy/Dockerfile | 11 +-
deploy/README.md | 20 +-
deploy/config.example.yaml | 4 +-
deploy/docker-compose.yml | 13 +-
docs/architecture.md | 217 ++++++++++------
frontend/package.json | 2 +
frontend/pnpm-lock.yaml | 26 ++
.../layout/components/SystemUpdateModal.vue | 131 +++++++++-
frontend/src/utils/markdown.ts | 26 ++
12 files changed, 512 insertions(+), 197 deletions(-)
create mode 100644 frontend/src/utils/markdown.ts
diff --git a/README.md b/README.md
index e723409c4..0ec9d7c5e 100644
--- a/README.md
+++ b/README.md
@@ -7,126 +7,197 @@
-
+
+
+## 概览
+
+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 ' \
- -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: ''
```
-完整配置项见 `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 ` 鉴权。
+
+| 端点 | 说明 |
+| --- | --- |
+| `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 ' \
+ -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` 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
diff --git a/backend/src/admin/system/updater/mod.rs b/backend/src/admin/system/updater/mod.rs
index e32a62d34..efd37b167 100644
--- a/backend/src/admin/system/updater/mod.rs
+++ b/backend/src/admin/system/updater/mod.rs
@@ -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 {
))
}
+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 {
env::var(key)
.ok()
diff --git a/backend/tests/admin/system/mod.rs b/backend/tests/admin/system/mod.rs
index 0b79a8599..762f22b81 100644
--- a/backend/tests/admin/system/mod.rs
+++ b/backend/tests/admin/system/mod.rs
@@ -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 {
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
index 8e60b3de9..0b4164251 100644
--- a/deploy/Dockerfile
+++ b/deploy/Dockerfile
@@ -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
diff --git a/deploy/README.md b/deploy/README.md
index 0c7468561..7b0c8d698 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -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
```
diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml
index 9d4ae4ffa..5a949f029 100644
--- a/deploy/config.example.yaml
+++ b/deploy/config.example.yaml
@@ -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
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index f61074222..5bef2219c 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -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:
diff --git a/docs/architecture.md b/docs/architecture.md
index e26e3e2b8..f2869a140 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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
/v1/*"| Http["Axum Router"]
+ Browser["浏览器"] -->|"管理端 SPA"| Web["web/dist"]
Browser -->|"/api/admin/*"| Http
- subgraph App["codex-proxy-rs 单主进程"]
- Http --> Proxy["proxy
API Key 鉴权
OpenAI 兼容协议"]
+ subgraph App["codex-proxy-rs"]
+ Http --> Proxy["proxy
OpenAI 兼容入口
客户端 API Key 鉴权"]
Http --> Admin["admin
账号 / Key / 设置 / 监控 / 系统更新"]
Http --> Web
- Proxy --> Dispatch["dispatch
账号选择 / 限流 / 亲和性 / 失败回退"]
- Dispatch --> Protocol["upstream protocol
请求转换 / SSE 转换"]
- Protocol --> Transport["upstream transport
HTTP SSE / WebSocket"]
+ Proxy --> Dispatch["dispatch
账号选择
限流 / quota / 亲和性 / 回退"]
+ Dispatch --> Protocol["upstream protocol
请求转换
SSE / 错误映射"]
+ Protocol --> Transport["upstream transport
HTTP / SSE / WebSocket"]
Admin --> Runtime["runtime services
账号池 / 后台任务 / 更新状态"]
Dispatch --> Runtime
- Runtime --> Store[("SQLite
账号 / 会话 / 使用记录 / 设置")]
+ Runtime --> Store[("SQLite
账号 / Key / session / 用量 / 设置")]
Admin --> Store
end
Transport --> Upstream["ChatGPT / Codex Upstream"]
- Web --> Frontend["frontend/dist
Vue 3 管理端"]
-
- Release["GitHub Release
tar.gz + checksums"] --> Update["在线更新
下载 / 校验 / 替换 / 重启"]
- Update --> Admin
- GHCR["GHCR
linux/amd64 + linux/arm64"] --> Docker["Docker Compose
restart: unless-stopped"]
+ Release["GitHub Release
archives + checksums"] --> Updater["self update
下载 / 校验 / 替换 / 重启"]
+ Updater --> Admin
+ GHCR["GHCR image
linux/amd64 + linux/arm64"] --> Docker["Docker Compose
.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__linux_amd64.tar.gz
@@ -120,11 +170,13 @@ codex-proxy-rs__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 触发。
diff --git a/frontend/package.json b/frontend/package.json
index 36f8e5d54..8172dc7dd 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 7b9d5dc16..758722105 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -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: {}
diff --git a/frontend/src/layout/components/SystemUpdateModal.vue b/frontend/src/layout/components/SystemUpdateModal.vue
index 5e5ad47fb..f7c7e0db3 100644
--- a/frontend/src/layout/components/SystemUpdateModal.vue
+++ b/frontend/src/layout/components/SystemUpdateModal.vue
@@ -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({ 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(() => {
@@ -239,12 +247,24 @@ onUnmounted(() => {
+
+
+ {{ updateError || updateInfo?.warning }}
+
@@ -311,10 +338,8 @@ onUnmounted(() => {
{{ displayValue(updateInfo?.latestVersion) }}
-
- {{ updateInfo.notes }}
+
+
@@ -358,3 +383,89 @@ onUnmounted(() => {
+
+
diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts
new file mode 100644
index 000000000..3a6736657
--- /dev/null
+++ b/frontend/src/utils/markdown.ts
@@ -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('