mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix: refine docker update workflow
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 触发。
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+26
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user