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(() => {
-

- {{ item.label }} -

+
+

+ {{ item.label }} +

+ + 发布页 + + +

{

+ +

+ {{ 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('