diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c642f6a82..1dea14e58 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,6 +16,29 @@ concurrency:
jobs:
backend:
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:18-alpine
+ env:
+ POSTGRES_DB: codex_proxy
+ POSTGRES_USER: codex_proxy
+ POSTGRES_PASSWORD: codex_proxy
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U codex_proxy -d codex_proxy"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+ redis:
+ image: redis:8-alpine
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
steps:
- name: Checkout
uses: actions/checkout@v7
@@ -38,6 +61,9 @@ jobs:
working-directory: backend
run: cargo clippy --all-targets --all-features --locked -- -D warnings
+ - name: Architecture boundaries
+ run: backend/scripts/check_architecture.sh
+
- name: Rust tests
working-directory: backend
run: cargo test --test main --locked
diff --git a/README.md b/README.md
index 63ca35664..3b25776b7 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,8 @@
-
+
+
@@ -17,15 +18,16 @@
Codex Proxy RS 是一个单进程网关:
-- Rust/Axum 后端提供 Responses 兼容代理、管理端 API、SQLite 持久化和静态前端托管。
+- Rust/Axum 后端提供 Responses 兼容代理、管理端 API、PostgreSQL 持久化、Redis 运行态存储和静态前端托管。
- Vue 管理端负责账号导入、API Key、用量统计、请求记录、运行参数、模型映射和系统更新。
-- 运行数据默认写入仓库 `.runtime/`;Docker 部署也使用这套宿主机目录挂载。
+- 应用本地文件默认写入仓库 `.runtime/`;PostgreSQL 与 Redis 数据由各自服务管理。
## 环境
- Rust 1.95
- Node 24 或兼容版本,pnpm 11(只在开发前端或构建镜像时需要)
-- Docker / Docker Compose(部署或验证镜像时需要)
+- PostgreSQL 18、Redis 8
+- Docker / Docker Compose(推荐用于启动依赖、部署或验证镜像)
## 本地运行
@@ -35,11 +37,14 @@ Codex Proxy RS 是一个单进程网关:
cp deploy/config.example.yaml .runtime/config.yaml
```
-如果直接用本地二进制而不是 Docker,确保 `.runtime/config.yaml` 里的路径是宿主机路径:
+如果直接用本地二进制而不是 Docker,确保 `.runtime/config.yaml` 使用宿主机可访问的连接地址与日志路径:
```yaml
database:
- url: 'sqlite://.runtime/data/codex-proxy-rs.sqlite'
+ url: 'postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy'
+
+redis:
+ url: 'redis://127.0.0.1:6379/'
logging:
directory: .runtime/logs
@@ -59,12 +64,14 @@ CPR_CONFIG_FILE=.runtime/config.yaml cargo run --manifest-path backend/Cargo.tom
## Docker 部署
-Compose 默认把宿主机 `.runtime` 映射进容器:
+Compose 同时启动 PostgreSQL、Redis 和主服务。主服务默认把宿主机 `.runtime` 映射进容器:
- `.runtime/config.yaml` -> `/app/config.yaml`
-- `.runtime/data` -> `/app/data`
+- `.runtime/data` -> `/app/data`(installation id 与在线更新状态,不含数据库)
- `.runtime/logs` -> `/app/logs`
+PostgreSQL 与 Redis 分别使用 `postgres-data`、`redis-data` 命名卷。
+
初始化配置:
```bash
@@ -77,7 +84,10 @@ Docker 配置文件里路径保持容器路径:
```yaml
database:
- url: 'sqlite:///app/data/codex-proxy-rs.sqlite'
+ url: 'postgres://codex_proxy:codex_proxy@postgres:5432/codex_proxy'
+
+redis:
+ url: 'redis://redis:6379/'
logging:
directory: /app/logs
@@ -142,20 +152,19 @@ CPR_CONFIG_FILE=.runtime/config.yaml cargo run --manifest-path backend/Cargo.tom
管理端功能:
- 账号导入、OAuth 授权、连接测试、quota 刷新、token 刷新。
-- 客户端 API Key 管理。
+- 客户端 API Key 管理,完整 Key 可长期复制并导入 CCSwitch。
- Dashboard、请求记录、Token 用量、模型用量和账号额度。
- 运行参数、模型别名、账号选择策略。
- 版本检查、在线更新、更新日志、重启和回滚。
## 配置与运行数据
-配置文件只承载启动必需项。账号、API Key、模型映射、账号选择策略和多数运行参数由管理端写入 SQLite。
+配置文件只承载启动必需项。账号、API Key、模型映射、账号选择策略和多数运行参数由管理端写入 PostgreSQL;管理会话、刷新租约、会话亲和与模型缓存位于 Redis。
`.runtime` 约定:
```text
.runtime/config.yaml # 本地或 Docker 启动配置,路径按运行环境写
-.runtime/data/codex-proxy-rs.sqlite # SQLite 数据库
.runtime/data/installation_id # 上游 installation id
.runtime/data/update-state.json # 在线更新状态
.runtime/data/update.lock # 在线更新锁
@@ -184,7 +193,7 @@ release/publish 1.0.4
## 项目结构
```text
-backend/ Rust/Axum 后端、SQLite migration、集成测试
+backend/ Rust/Axum 后端、PostgreSQL migration、SQLite 导入工具、集成测试
frontend/ Vue 3 管理端
deploy/ Dockerfile、Compose、部署配置模板
docs/ 架构和维护文档
diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 909757c40..79fe04aef 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -47,6 +47,21 @@ dependencies = [
"libc",
]
+[[package]]
+name = "arc-swap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "arcstr"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
+
[[package]]
name = "argon2"
version = "0.5.3"
@@ -87,6 +102,17 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -171,6 +197,15 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "backon"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
+dependencies = [
+ "fastrand",
+]
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -332,6 +367,7 @@ dependencies = [
"ipnet",
"rand 0.10.1",
"rand_core 0.6.4",
+ "redis",
"reqwest",
"rustls",
"rustls-native-certs",
@@ -358,6 +394,20 @@ dependencies = [
"wiremock",
]
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "memchr",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
[[package]]
name = "compression-codecs"
version = "0.4.38"
@@ -755,6 +805,16 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1882,6 +1942,33 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+[[package]]
+name = "redis"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fa6f8e4b491d7a8ef3a9550a4d71969bd0064f46e32b8dbbcc7fc60dad94fed"
+dependencies = [
+ "arc-swap",
+ "arcstr",
+ "async-lock",
+ "backon",
+ "bytes",
+ "cfg-if",
+ "combine",
+ "futures-channel",
+ "futures-util",
+ "itoa",
+ "percent-encoding",
+ "pin-project-lite",
+ "ryu",
+ "sha1_smol",
+ "socket2",
+ "tokio",
+ "tokio-util",
+ "url",
+ "xxhash-rust",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2254,6 +2341,12 @@ dependencies = [
"digest 0.11.3",
]
+[[package]]
+name = "sha1_smol"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3577,6 +3670,12 @@ dependencies = [
"rustix",
]
+[[package]]
+name = "xxhash-rust"
+version = "0.8.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576"
+
[[package]]
name = "yaml-rust2"
version = "0.11.0"
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 4aca96cbd..98405de14 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -4,7 +4,6 @@ edition = "2021"
license = "MIT"
publish = false
rust-version = "1.95"
-build = "build/build.rs"
[dependencies]
argon2 = "0.5.3"
@@ -22,6 +21,7 @@ indexmap = { version = "2.0", features = ["serde"] }
ipnet = "2.12.0"
rand = "0.10.1"
rand_core = { version = "0.6.4", features = ["getrandom"] }
+redis = { version = "1.3.0", default-features = false, features = ["tokio-comp", "connection-manager", "script"] }
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] }
rustls = { version = "=0.23.36", default-features = false, features = ["ring", "std", "tls12"] }
rustls-native-certs = "0.8.4"
@@ -31,7 +31,7 @@ semver = "1.0.27"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["preserve_order"] }
sha2 = "0.10.9"
-sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "uuid"] }
+sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "postgres", "sqlite", "chrono", "uuid", "json"] }
subtle = "2.6.1"
tar = "0.4.44"
thiserror = "2.0.18"
diff --git a/backend/build/build.rs b/backend/build.rs
similarity index 100%
rename from backend/build/build.rs
rename to backend/build.rs
diff --git a/backend/scripts/check_architecture.sh b/backend/scripts/check_architecture.sh
new file mode 100755
index 000000000..5fdf124c5
--- /dev/null
+++ b/backend/scripts/check_architecture.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+fail=0
+
+deny() {
+ local description=$1
+ local pattern=$2
+ shift 2
+ if grep -rn --include='*.rs' -E "$pattern" "$@"; then
+ echo "ARCH VIOLATION: $description" >&2
+ fail=1
+ fi
+}
+
+domain_paths=(
+ src/infra src/upstream src/telemetry src/keys src/auth src/settings src/update
+ src/accounts src/models src/dispatch
+)
+
+deny "domain -> api/bootstrap" 'crate::(api|bootstrap)\b' "${domain_paths[@]}"
+deny "upstream -> domain" 'crate::(accounts|dispatch|telemetry|keys|auth|settings|update|models)\b' src/upstream
+deny "axum outside api/bootstrap" '\baxum(::|\b)' "${domain_paths[@]}"
+deny "sqlx in api" '\bsqlx(::|\b)' src/api
+deny "redis in api" '\bredis::' src/api
+deny "legacy module path" 'crate::(admin|proxy|runtime|web|http|config)::' src
+deny "Admin-prefixed domain type" '\bAdmin[A-Z][A-Za-z0-9]*\b' "${domain_paths[@]}"
+deny "Repository naming" '\b[A-Za-z0-9]*Repository\b' src
+deny "persistence repository naming" '\b[A-Za-z0-9_]*repository[A-Za-z0-9_]*\b' \
+ src/accounts src/auth src/dispatch src/keys src/models src/settings src/telemetry \
+ src/upstream/openai/fingerprint
+deny "Arc alias" '\bArc[[:space:]]+as[[:space:]]+' src
+
+banned=$(find src \( -name 'importing.rs' -o -name 'exporting.rs' -o -name 'testing.rs' \
+ -o -name 'util.rs' -o -name 'helper.rs' -o -name 'common.rs' -o -name 'misc.rs' \))
+if [ -n "$banned" ]; then
+ echo "ARCH VIOLATION: banned file names: $banned" >&2
+ fail=1
+fi
+
+while read -r file; do
+ lines=$(wc -l < "$file")
+ if [ "$lines" -gt 50 ]; then
+ echo "ARCH VIOLATION: $file has $lines lines (mod.rs <= 50)" >&2
+ fail=1
+ fi
+done < <(find src -name mod.rs)
+
+while read -r file; do
+ lines=$(wc -l < "$file")
+ if [ "$lines" -gt 800 ]; then
+ echo "ARCH VIOLATION: $file has $lines lines (> 800)" >&2
+ fail=1
+ fi
+done < <(find src -name '*.rs' ! -name mod.rs)
+
+exit "$fail"
diff --git a/backend/src/upstream/accounts/model.rs b/backend/src/accounts/account.rs
similarity index 100%
rename from backend/src/upstream/accounts/model.rs
rename to backend/src/accounts/account.rs
diff --git a/backend/src/accounts/cookies/mod.rs b/backend/src/accounts/cookies/mod.rs
new file mode 100644
index 000000000..f46bf4eca
--- /dev/null
+++ b/backend/src/accounts/cookies/mod.rs
@@ -0,0 +1,5 @@
+//! 账号 Cookie 存储。
+
+mod store;
+
+pub use store::*;
diff --git a/backend/src/upstream/accounts/cookies.rs b/backend/src/accounts/cookies/store.rs
similarity index 85%
rename from backend/src/upstream/accounts/cookies.rs
rename to backend/src/accounts/cookies/store.rs
index 9752ee167..02f569fdc 100644
--- a/backend/src/upstream/accounts/cookies.rs
+++ b/backend/src/accounts/cookies/store.rs
@@ -3,7 +3,7 @@
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, Duration, Utc};
-use sqlx::{Row, SqlitePool};
+use sqlx::{PgPool, Row};
use thiserror::Error;
use tokio::sync::RwLock;
use uuid::Uuid;
@@ -11,35 +11,31 @@ use uuid::Uuid;
const DEFAULT_COOKIE_DOMAIN: &str = "chatgpt.com";
const CAPTURABLE_COOKIES: &[&str] = &["cf_clearance"];
-/// SQLite Cookie 存储错误。
+/// PostgreSQL Cookie 存储错误。
#[derive(Debug, Error)]
-pub enum SqliteCookieStoreError {
+pub enum PgCookieStoreError {
/// 数据库错误。
- #[error("sqlite cookie store database error: {0}")]
+ #[error("PostgreSQL cookie store database error: {0}")]
Database(#[from] sqlx::Error),
}
-/// SQLite Cookie 存储结果。
-pub type SqliteCookieStoreResult = Result;
+/// PostgreSQL Cookie 存储结果。
+pub type PgCookieStoreResult = Result;
-/// SQLite Cookie 存储。
+/// PostgreSQL Cookie 存储。
#[derive(Clone)]
-pub struct SqliteCookieStore {
- pool: SqlitePool,
+pub struct PgCookieStore {
+ pool: PgPool,
}
-impl SqliteCookieStore {
+impl PgCookieStore {
/// 构造 Cookie 存储。
- pub fn new(pool: SqlitePool) -> Self {
+ pub fn new(pool: PgPool) -> Self {
Self { pool }
}
/// 捕获上游 `Set-Cookie` 响应头中允许持久化的 Cookie。
- pub async fn capture_set_cookie(
- &self,
- account_id: &str,
- raw: &str,
- ) -> SqliteCookieStoreResult<()> {
+ pub async fn capture_set_cookie(&self, account_id: &str, raw: &str) -> PgCookieStoreResult<()> {
let Some(parsed) = parse_set_cookie(raw) else {
return Ok(());
};
@@ -54,7 +50,7 @@ impl SqliteCookieStore {
&self,
account_id: &str,
request_domain: &str,
- ) -> SqliteCookieStoreResult