diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0a7daff3..428b7f983 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,15 +18,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: components: rustfmt,clippy - name: Cache Rust - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: backend -> target @@ -43,15 +43,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: version: 11.7.0 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm @@ -60,5 +60,20 @@ jobs: - name: Install frontend dependencies run: pnpm --dir frontend install --frozen-lockfile + - name: Frontend format + run: pnpm --dir frontend format:check + - name: Frontend build run: pnpm --dir frontend build + + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Validate Docker Compose + run: docker compose -f deploy/docker-compose.yml config + + - name: Build runtime image + run: docker build -f deploy/Dockerfile --target runtime -t codex-proxy-rs:ci . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ec217f88..2410a68c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,8 +12,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read concurrency: group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} @@ -37,7 +36,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} fetch-depth: 0 @@ -147,17 +146,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.release-info.outputs.tag }} - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: version: 10 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm @@ -172,7 +171,7 @@ jobs: working-directory: frontend - name: Upload frontend artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: frontend-dist path: frontend/dist @@ -189,12 +188,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.release-info.outputs.tag }} - name: Cache Cargo - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/.cargo/registry @@ -205,7 +204,7 @@ jobs: cargo-${{ runner.os }}-${{ matrix.target }}- - name: Download frontend artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: frontend-dist path: backend/web/dist @@ -228,7 +227,7 @@ jobs: CPR_BUILD_TYPE: release - name: Upload binary artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }} path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/ @@ -238,21 +237,25 @@ jobs: package-assets: needs: [release-info, build-frontend, build-binary] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.release-info.outputs.tag }} - name: Download frontend artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: frontend-dist path: dist/frontend - name: Download binary artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: codex-proxy-rs-* path: dist/binaries @@ -336,8 +339,13 @@ jobs: file.write(f"{sha256(archive)} {archive.name}\n") PY + - name: Generate release asset provenance + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4 + with: + subject-path: dist/release/* + - name: Upload release assets - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-assets path: dist/release @@ -347,27 +355,32 @@ jobs: publish: needs: [release-info, build-frontend, build-binary, package-assets] runs-on: ubuntu-latest + permissions: + contents: write + packages: write + id-token: write + attestations: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ needs.release-info.outputs.tag }} - name: Download frontend artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: frontend-dist path: dist/docker/web/dist - name: Download Docker binary artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: codex-proxy-rs-linux-* path: dist/docker-artifacts - name: Download release assets - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: release-assets path: dist/release @@ -409,20 +422,24 @@ jobs: PY - name: Setup QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push app image - uses: docker/build-push-action@v7 + id: build-and-push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . file: deploy/Dockerfile @@ -433,6 +450,8 @@ jobs: CPR_PREBUILT_DIR=dist/docker cache-from: type=gha,scope=runtime-prebuilt cache-to: type=gha,mode=max,scope=runtime-prebuilt + provenance: mode=max + sbom: true labels: | org.opencontainers.image.title=Codex Proxy RS org.opencontainers.image.version=${{ needs.release-info.outputs.version }} @@ -443,8 +462,38 @@ jobs: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }} + - name: Generate container provenance attestation + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.build-and-push.outputs.digest }} + push-to-registry: true + + - name: Sign app image + shell: bash + env: + DIGEST: ${{ steps.build-and-push.outputs.digest }} + IMAGE_TAGS: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }} + run: | + set -euo pipefail + if [ -z "${DIGEST}" ]; then + echo "missing image digest" + exit 1 + fi + + images=() + while IFS= read -r tag; do + [ -n "${tag}" ] || continue + images+=("${tag}@${DIGEST}") + done <<< "${IMAGE_TAGS}" + + cosign sign --yes "${images[@]}" + - name: Publish GitHub Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 with: tag_name: ${{ needs.release-info.outputs.tag }} generate_release_notes: true @@ -461,6 +510,8 @@ jobs: **Manual download:** Download the appropriate archive for your platform from the assets below. + Release archives are covered by GitHub artifact attestations. Docker images are published with SBOM/provenance attestations and keyless cosign signatures. + ## 📚 Documentation - [GitHub Repository](https://github.com/${{ github.repository }}) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 5286c55ef..e8262ca6f 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -21,13 +21,13 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Cache Rust - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: backend -> target @@ -43,15 +43,15 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: version: 11.7.0 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm @@ -62,3 +62,22 @@ jobs: - name: Run pnpm audit run: pnpm --dir frontend audit --prod --audit-level=high + + docker-security: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Build runtime image + run: docker build -f deploy/Dockerfile --target runtime -t codex-proxy-rs:security-scan . + + - name: Scan runtime image + uses: aquasecurity/trivy-action@915b19bbe73b92a6cf82a1bc12b087c9a19a5fe2 # v0.28.0 + with: + image-ref: codex-proxy-rs:security-scan + severity: HIGH,CRITICAL + vuln-type: os,library + ignore-unfixed: true + exit-code: '1' diff --git a/README.md b/README.md index 2df18d823..e723409c4 100644 --- a/README.md +++ b/README.md @@ -23,18 +23,18 @@ cargo run --manifest-path backend/Cargo.toml ``` -启动后监听 `http://0.0.0.0:8080`,配置从运行目录下的 `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 +# 编辑 deploy/config.yaml,设置 admin.default_password docker compose -f deploy/docker-compose.yml build docker compose -f deploy/docker-compose.yml up -d ``` -挂载关系:`deploy/config.yaml` → `/app/config.yaml`,`deploy/data` → `/app/data`,`deploy/logs` → `/app/logs`。 +默认只监听宿主机 `127.0.0.1:8080`。挂载关系:`deploy/config.yaml` → `/app/config.yaml`,数据和日志写入 Compose 命名卷 `cpr-data`、`cpr-logs`。 ## API @@ -90,6 +90,9 @@ database: logging: directory: .runtime/logs retention_days: 14 + +admin: + default_password: '' ``` 完整配置项见 `deploy/config.example.yaml`,包括 TLS、WebSocket 连接池、请求指纹、账号额度策略等。 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 055643a77..8ec1d0762 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -329,6 +329,7 @@ dependencies = [ "futures", "hex", "indexmap", + "ipnet", "rand 0.10.1", "rand_core 0.6.4", "reqwest", @@ -339,8 +340,7 @@ dependencies = [ "semver", "serde", "serde_json", - "serde_yml", - "sha2 0.11.0", + "sha2 0.10.9", "sqlx", "subtle", "tar", @@ -355,7 +355,6 @@ dependencies = [ "tracing-subscriber", "tungstenite", "uuid", - "validator", "wiremock", ] @@ -408,12 +407,6 @@ dependencies = [ "yaml-rust2", ] -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "const-random" version = "0.1.18" @@ -588,41 +581,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "data-encoding" version = "2.11.0" @@ -671,7 +629,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", "crypto-common 0.2.2", "ctutils", ] @@ -1343,12 +1300,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1566,19 +1517,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "noyalib" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e493c05128df7a83b9676b709d590e0ebc285c7ed3152bc679668e8c1e506af5" -dependencies = [ - "indexmap", - "memchr", - "rustc-hash", - "serde", - "smallvec", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1772,28 +1710,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -2299,16 +2215,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_yml" -version = "0.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909764a65f86829ccdb5eea9ab355843aa02c019a7bfd47465092953565caa05" -dependencies = [ - "noyalib", - "serde", -] - [[package]] name = "sha1" version = "0.10.6" @@ -2616,12 +2522,6 @@ dependencies = [ "unicode-properties", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" @@ -3169,36 +3069,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "validator" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" -dependencies = [ - "idna", - "once_cell", - "regex", - "serde", - "serde_derive", - "serde_json", - "url", - "validator_derive", -] - -[[package]] -name = "validator_derive" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" -dependencies = [ - "darling", - "once_cell", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "valuable" version = "0.1.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ff12f5d15..e22e6111c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -19,6 +19,7 @@ flate2 = "1.1.9" futures = "0.3.32" hex = "0.4.3" indexmap = { version = "2.0", features = ["serde"] } +ipnet = "2.12.0" rand = "0.10.1" rand_core = { version = "0.6.4", features = ["getrandom"] } reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] } @@ -29,7 +30,7 @@ secrecy = "0.10.3" semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" -sha2 = "0.11.0" +sha2 = "0.10.9" sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "uuid"] } subtle = "2.6.1" tar = "0.4.44" @@ -42,10 +43,8 @@ tracing-appender = "0.2.5" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] } uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] } -validator = { version = "0.20.0", features = ["derive"] } [dev-dependencies] -serde_yml = "0.0.13" tempfile = "3.27.0" tower = { version = "0.5.3", features = ["util"] } wiremock = "0.6.5" diff --git a/backend/src/admin/accounts/mod.rs b/backend/src/admin/accounts/mod.rs index e29ca573b..34caaa04d 100644 --- a/backend/src/admin/accounts/mod.rs +++ b/backend/src/admin/accounts/mod.rs @@ -1,4 +1,5 @@ //! 管理端账号 HTTP 模块。 +pub(crate) mod quota_view; pub mod routes; pub mod service; diff --git a/backend/src/admin/accounts/quota_view.rs b/backend/src/admin/accounts/quota_view.rs new file mode 100644 index 000000000..5c94be582 --- /dev/null +++ b/backend/src/admin/accounts/quota_view.rs @@ -0,0 +1,375 @@ +//! 管理端账号 quota 展示视图。 + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use serde_json::Value; + +use crate::infra::{ + format::format_percent, + time::{china_datetime, china_relative_time}, +}; + +const FIVE_HOUR_WINDOW_SECONDS: u64 = 18_000; +const WEEK_WINDOW_SECONDS: u64 = 604_800; +const MONTH_WINDOW_SECONDS: u64 = 2_592_000; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AdminAccountQuotaData { + refreshed_at_display: String, + windows: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AdminAccountQuotaWindowData { + key: String, + group: String, + window_seconds: Option, + label_display: String, + used_percent: Option, + used_percent_display: String, + reset_at_display: String, + window_used_display: String, +} + +impl Default for AdminAccountQuotaData { + fn default() -> Self { + Self { + refreshed_at_display: "-".to_string(), + windows: Vec::new(), + } + } +} + +impl AdminAccountQuotaData { + pub(crate) fn has_high_usage(&self) -> bool { + self.windows + .iter() + .any(|window| window.used_percent.is_some_and(|percent| percent >= 80.0)) + } +} + +pub(crate) fn quota_data( + quota_json: &str, + fetched_at: Option>, +) -> AdminAccountQuotaData { + let quota = serde_json::from_str::(quota_json).unwrap_or(Value::Null); + let windows = quota_windows("a); + + AdminAccountQuotaData { + refreshed_at_display: china_relative_time(fetched_at, Utc::now()), + windows, + } +} + +fn quota_windows(quota: &Value) -> Vec { + let mut windows = Vec::new(); + + let has_monthly_limit = push_monthly_quota_window(&mut windows, quota.get("monthly_limit")); + if let Some(snapshots) = quota.get("snapshots").and_then(Value::as_array) { + for snapshot in snapshots { + push_snapshot_quota_windows(&mut windows, snapshot, has_monthly_limit); + } + } + + windows.sort_by_key(quota_window_sort_key); + windows +} + +fn push_monthly_quota_window( + windows: &mut Vec, + monthly_limit: Option<&Value>, +) -> bool { + let Some(monthly_limit) = monthly_limit.filter(|value| !value.is_null()) else { + return false; + }; + let used_percent = monthly_limit + .get("used_percent") + .and_then(number_value) + .map(|value| value.clamp(0.0, 100.0)); + let reset_at = monthly_limit + .get("reset_at") + .and_then(Value::as_i64) + .and_then(|value| DateTime::::from_timestamp(value, 0)); + let window_seconds = monthly_limit + .get("window_minutes") + .and_then(Value::as_u64) + .and_then(|minutes| minutes.checked_mul(60)) + .or(Some(MONTH_WINDOW_SECONDS)); + if used_percent.is_none() && reset_at.is_none() { + return false; + } + + let key = monthly_limit + .get("key") + .and_then(Value::as_str) + .unwrap_or("monthly"); + windows.push(AdminAccountQuotaWindowData { + key: quota_key_segment(key), + group: "monthly".to_string(), + window_seconds, + label_display: "月限额".to_string(), + used_percent, + used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_percent), + reset_at_display: reset_at + .as_ref() + .map_or_else(|| "-".to_string(), china_datetime), + window_used_display: quota_window_used_display(reset_at, window_seconds), + }); + true +} + +fn push_snapshot_quota_windows( + windows: &mut Vec, + snapshot: &Value, + skip_core_monthly: bool, +) { + let source = snapshot + .get("source") + .and_then(Value::as_str) + .unwrap_or("quota"); + let source_key = snapshot_source_key(source, snapshot); + let label_prefix = snapshot_label(snapshot); + for role in ["primary", "secondary"] { + let Some(window) = snapshot.get(role).filter(|value| !value.is_null()) else { + continue; + }; + let window_seconds = window + .get("window_minutes") + .and_then(Value::as_u64) + .and_then(|minutes| minutes.checked_mul(60)); + if skip_core_monthly + && source == "core" + && window_seconds + .is_some_and(|seconds| quota_window_matches(seconds, MONTH_WINDOW_SECONDS)) + { + continue; + } + push_quota_window( + windows, + &source_key, + role, + label_prefix.as_deref(), + Some(window), + ); + } +} + +fn push_quota_window( + windows: &mut Vec, + source_key: &str, + role: &str, + label_prefix: Option<&str>, + window: Option<&Value>, +) { + let Some(window) = window.filter(|value| !value.is_null()) else { + return; + }; + let used_percent = window + .get("used_percent") + .and_then(number_value) + .map(|value| value.clamp(0.0, 100.0)); + let reset_at = window + .get("reset_at") + .and_then(Value::as_i64) + .and_then(|value| DateTime::::from_timestamp(value, 0)); + let window_seconds = window + .get("window_minutes") + .and_then(Value::as_u64) + .and_then(|minutes| minutes.checked_mul(60)); + let label_prefix = label_prefix + .map(str::trim) + .filter(|value| !value.is_empty()); + if reset_at.is_none() + && window_seconds.is_none() + && (used_percent.is_none() || label_prefix.is_none()) + { + return; + } + let base_label = quota_window_label_display(window_seconds); + let label_display = label_prefix + .map(|value| format!("{value} · {base_label}")) + .unwrap_or(base_label); + + windows.push(AdminAccountQuotaWindowData { + key: unique_quota_window_key(windows, source_key, role, window_seconds), + group: quota_window_group(window_seconds).to_string(), + window_seconds, + label_display, + used_percent, + used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_percent), + reset_at_display: reset_at + .as_ref() + .map_or_else(|| "-".to_string(), china_datetime), + window_used_display: quota_window_used_display(reset_at, window_seconds), + }); +} + +fn snapshot_source_key(source: &str, snapshot: &Value) -> String { + let label = snapshot + .get("limit_name") + .and_then(Value::as_str) + .or_else(|| snapshot.get("metered_feature").and_then(Value::as_str)) + .unwrap_or(source); + format!("{}-{}", quota_key_segment(source), quota_key_segment(label)) +} + +fn snapshot_label(snapshot: &Value) -> Option { + let source = snapshot.get("source").and_then(Value::as_str); + if source == Some("core") { + return None; + } + let label = snapshot + .get("limit_name") + .and_then(Value::as_str) + .or_else(|| snapshot.get("metered_feature").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty())?; + if label.eq_ignore_ascii_case("codex") { + return None; + } + if is_review_limit_label(Some(label)) { + return Some("代码审查".to_string()); + } + Some(label.to_string()) +} + +fn unique_quota_window_key( + windows: &[AdminAccountQuotaWindowData], + source_key: &str, + role: &str, + window_seconds: Option, +) -> String { + let bucket = quota_window_key_part(window_seconds).unwrap_or(role); + let key = format!("{source_key}-{bucket}"); + if windows.iter().any(|window| window.key == key) { + format!("{key}-{role}") + } else { + key + } +} + +fn quota_window_key_part(window_seconds: Option) -> Option<&'static str> { + match window_seconds { + Some(seconds) if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) => { + Some("five-hour") + } + Some(seconds) if quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => Some("weekly"), + Some(seconds) if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => Some("monthly"), + _ => None, + } +} + +fn quota_key_segment(value: &str) -> String { + let mut segment = String::with_capacity(value.len()); + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + segment.push(ch.to_ascii_lowercase()); + } else if !segment.ends_with('-') { + segment.push('-'); + } + } + let segment = segment.trim_matches('-'); + if segment.is_empty() { + "quota".to_string() + } else { + segment.to_string() + } +} + +fn quota_window_sort_key(window: &AdminAccountQuotaWindowData) -> (u8, u64, String) { + let group_order = match window.group.as_str() { + "monthly" => 0, + "shortTerm" => 1, + _ => 2, + }; + ( + group_order, + window.window_seconds.unwrap_or(0), + window.key.clone(), + ) +} + +fn quota_window_group(window_seconds: Option) -> &'static str { + match window_seconds { + Some(seconds) if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => "monthly", + Some(seconds) + if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) + || quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => + { + "shortTerm" + } + _ => "other", + } +} + +fn quota_window_matches(actual: u64, expected: u64) -> bool { + actual > 0 && actual.abs_diff(expected) <= expected / 20 +} + +fn quota_window_label_display(window_seconds: Option) -> String { + let Some(window_seconds) = window_seconds.filter(|seconds| *seconds > 0) else { + return "额度".to_string(); + }; + match window_seconds { + seconds if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) => { + "5小时限额".to_string() + } + seconds if quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => "周限额".to_string(), + seconds if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => "月限额".to_string(), + seconds if seconds % 86_400 == 0 => format!("{}天限额", seconds / 86_400), + seconds if seconds % 3_600 == 0 => format!("{}小时限额", seconds / 3_600), + seconds => format!("{}分钟限额", seconds.div_ceil(60)), + } +} + +fn quota_window_used_display( + reset_at: Option>, + window_seconds: Option, +) -> String { + let (Some(reset_at), Some(window_seconds)) = (reset_at, window_seconds) else { + return "-".to_string(); + }; + let remaining = reset_at + .signed_duration_since(Utc::now()) + .num_seconds() + .max(0) + .cast_unsigned(); + let used = window_seconds.saturating_sub(remaining); + format!( + "{} / {}", + format_duration_days(used), + format_duration_days(window_seconds) + ) +} + +fn format_duration_days(seconds: u64) -> String { + let days = seconds as f64 / 86_400.0; + if days >= 1.0 { + format!("{days:.1}d") + } else { + format!("{:.1}h", seconds as f64 / 3_600.0) + } +} + +fn number_value(value: &Value) -> Option { + value + .as_f64() + .or_else(|| value.as_str().and_then(|value| value.parse::().ok())) + .filter(|value| value.is_finite()) +} + +fn is_review_limit_label(value: Option<&str>) -> bool { + let normalized = value + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .replace(['-', ' '], "_"); + normalized == "review" + || normalized == "code_review" + || normalized == "codex_review" + || normalized == "codex_code_review" + || normalized.contains("code_review") + || normalized.contains("codex_review") +} diff --git a/backend/src/admin/accounts/routes.rs b/backend/src/admin/accounts/routes.rs index d6e80de85..5fb65a0d8 100644 --- a/backend/src/admin/accounts/routes.rs +++ b/backend/src/admin/accounts/routes.rs @@ -2,7 +2,7 @@ use axum::{ body::Body, - extract::{Query, State}, + extract::{Extension, Query, State}, http::{ header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, HeaderMap, StatusCode, @@ -14,24 +14,30 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use sqlx::{QueryBuilder, Row, Sqlite}; -use validator::{Validate, ValidationError}; use crate::{ - admin::auth::session::require_admin_auth, + admin::auth::session::AdminAuth, admin::response::{ AdminEnvelope, AdminError, AdminResponse, BatchDeleteData, CursorPageMeta, NumberedPageMeta, PageMeta, ADMIN_OK_CODE, ADMIN_OK_MESSAGE, }, + admin::update_payload::{parse_editable_update, EditableUpdateMessages}, admin::{ + accounts::quota_view::{quota_data, AdminAccountQuotaData}, accounts::service::{ AdminAccountError, AdminAccountHealthCheck, AdminAccountMetadata, AdminAccountRefreshOutcome, AdminAccountRefreshResult, AdminAccountUpdate, OAuthExchangeInput, }, - monitoring::{billing, service::AdminUsageRecord}, + monitoring::{ + account_usage_service::AdminUsageRecord, + billing, + usage_record_model::{UsageRecord, UsageRecordLevel}, + }, }, + http::middleware::request_id::RequestId, infra::{ format::{ format_cost, format_percent, format_plain_number, format_tokens, nonnegative_i64_to_u64, @@ -44,9 +50,7 @@ use crate::{ }; const ACCOUNT_STATS_PAGE_LIMIT: u32 = 200; -const FIVE_HOUR_WINDOW_SECONDS: u64 = 18_000; -const WEEK_WINDOW_SECONDS: u64 = 604_800; -const MONTH_WINDOW_SECONDS: u64 = 2_592_000; +const ACCOUNT_EXPORT_CONFIRMATION: &str = "export_sensitive_accounts"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -87,6 +91,7 @@ pub(crate) struct AccountIdQuery { #[serde(rename_all = "camelCase")] pub(crate) struct AccountExportQuery { ids: Option, + confirm: Option, } #[derive(Debug, Deserialize)] @@ -174,35 +179,6 @@ impl AdminAccountData { } } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AdminAccountQuotaData { - refreshed_at_display: String, - windows: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AdminAccountQuotaWindowData { - key: String, - group: String, - window_seconds: Option, - label_display: String, - used_percent: Option, - used_percent_display: String, - reset_at_display: String, - window_used_display: String, -} - -impl Default for AdminAccountQuotaData { - fn default() -> Self { - Self { - refreshed_at_display: "-".to_string(), - windows: Vec::new(), - } - } -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct AdminAccountUsageData { @@ -494,10 +470,9 @@ struct AccountOAuthAuthorizeData { /// `GET /api/admin/accounts` pub(crate) async fn accounts( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(params): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; let limit = clamp_limit(params.page_size.or(params.limit).unwrap_or(50)); let use_numbered_page = params.page.is_some() || params.page_size.is_some(); let stats = account_list_stats(&state).await; @@ -557,10 +532,9 @@ pub(crate) async fn accounts( /// `POST /api/admin/accounts` pub(crate) async fn create_account( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Json(payload): Json, ) -> Result { - require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -578,32 +552,65 @@ pub(crate) async fn create_account( /// `GET /api/admin/accounts/export` pub(crate) async fn export_accounts( State(state): State, + request_id: Option>, + _auth: AdminAuth, headers: HeaderMap, Query(query): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; - match state - .services - .admin_accounts - .export(account_export_ids(query.ids.as_deref())) - .await - { - Ok(result) => Ok(AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok(result), - )), - Err(error) => Err(account_error(&error)), + let request_id = request_id.map(|Extension(request_id)| request_id.as_str().to_string()); + let source = account_action_source(&headers); + let ids = account_export_ids(query.ids.as_deref()); + + if query.confirm.as_deref() != Some(ACCOUNT_EXPORT_CONFIRMATION) { + record_account_export_audit( + &state, + request_id.as_deref(), + &source, + &ids, + AccountExportAuditOutcome::MissingConfirmation, + ) + .await; + return Err(AdminError::bad_request( + "account export requires confirm=export_sensitive_accounts", + )); + } + + match state.services.admin_accounts.export(ids.clone()).await { + Ok(result) => { + record_account_export_audit( + &state, + request_id.as_deref(), + &source, + &ids, + AccountExportAuditOutcome::Success, + ) + .await; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(result), + )) + } + Err(error) => { + record_account_export_audit( + &state, + request_id.as_deref(), + &source, + &ids, + AccountExportAuditOutcome::from_error(&error), + ) + .await; + Err(account_error(&error)) + } } } /// `POST /api/admin/accounts/refresh` pub(crate) async fn refresh_account( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Json(payload): Json, ) -> Result { let account_id = payload.id; - require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -621,10 +628,9 @@ pub(crate) async fn refresh_account( /// `POST /api/admin/accounts/health-check` pub(crate) async fn health_check_accounts( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Json(payload): Json, ) -> Result { - require_admin_auth(&state, &headers).await?; let stagger_ms = payload.stagger_ms.unwrap_or(3_000); if !(500..=30_000).contains(&stagger_ms) && stagger_ms != 0 { return Err(AdminError::bad_request( @@ -655,11 +661,10 @@ pub(crate) async fn health_check_accounts( /// `GET /api/admin/accounts/quota` pub(crate) async fn account_quota( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(query): Query, ) -> Result { let account_id = query.id; - require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -700,10 +705,9 @@ pub(crate) async fn account_quota( /// `POST /api/admin/accounts/import` pub(crate) async fn import_accounts( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Json(payload): Json, ) -> Result { - require_admin_auth(&state, &headers).await?; match state.services.admin_accounts.import(payload).await { Ok(result) => Ok(AdminResponse::new( StatusCode::OK, @@ -720,9 +724,8 @@ pub(crate) async fn import_accounts( /// `POST /api/admin/accounts/oauth/authorize` pub(crate) async fn oauth_authorize_account( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, ) -> Result { - require_admin_auth(&state, &headers).await?; match state.services.admin_accounts.oauth_authorize().await { Ok(result) => Ok(AdminResponse::new( StatusCode::OK, @@ -740,10 +743,9 @@ pub(crate) async fn oauth_authorize_account( /// `POST /api/admin/accounts/oauth/exchange` pub(crate) async fn oauth_exchange_account( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Json(payload): Json, ) -> Result { - require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -771,10 +773,9 @@ pub(crate) async fn oauth_exchange_account( pub(crate) async fn test_account_connection( State(state): State, Query(query): Query, - headers: HeaderMap, + _auth: AdminAuth, ) -> Result { let account_id = query.id; - require_admin_auth(&state, &headers).await?; let model = query .model_id @@ -804,10 +805,9 @@ pub(crate) async fn test_account_connection( pub(crate) async fn account_models( State(state): State, Query(query): Query, - headers: HeaderMap, + _auth: AdminAuth, ) -> Result { let account_id = query.id; - require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -833,10 +833,9 @@ pub(crate) async fn account_models( /// `POST /api/admin/accounts/delete` pub(crate) async fn batch_delete_accounts( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Json(payload): Json, ) -> Result { - require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -857,11 +856,9 @@ pub(crate) async fn batch_delete_accounts( /// `POST /api/admin/accounts/update` pub(crate) async fn update_account( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Json(payload): Json, ) -> Result { - require_admin_auth(&state, &headers).await?; - let ParsedAccountUpdate { id, update } = parse_account_update(&payload)?; match state @@ -962,10 +959,7 @@ async fn list_all_account_metadata(state: &AppState) -> Vec bool { - quota - .windows - .iter() - .any(|window| window.used_percent.is_some_and(|percent| percent >= 80.0)) + quota.has_high_usage() } fn account_summary_needs_attention(status: AccountStatus) -> bool { @@ -1165,356 +1159,21 @@ fn model_usage_data(usage: AccountModelUsageRecord) -> AdminAccountModelUsageDat } } -fn quota_data(quota_json: &str, fetched_at: Option>) -> AdminAccountQuotaData { - let quota = serde_json::from_str::(quota_json).unwrap_or(Value::Null); - let windows = quota_windows("a); - - AdminAccountQuotaData { - refreshed_at_display: china_relative_time(fetched_at, Utc::now()), - windows, - } -} - -fn quota_windows(quota: &Value) -> Vec { - let mut windows = Vec::new(); - - let has_monthly_limit = push_monthly_quota_window(&mut windows, quota.get("monthly_limit")); - if let Some(snapshots) = quota.get("snapshots").and_then(Value::as_array) { - for snapshot in snapshots { - push_snapshot_quota_windows(&mut windows, snapshot, has_monthly_limit); - } - } - - windows.sort_by_key(quota_window_sort_key); - windows -} - -fn push_monthly_quota_window( - windows: &mut Vec, - monthly_limit: Option<&Value>, -) -> bool { - let Some(monthly_limit) = monthly_limit.filter(|value| !value.is_null()) else { - return false; - }; - let used_percent = monthly_limit - .get("used_percent") - .and_then(number_value) - .map(|value| value.clamp(0.0, 100.0)); - let reset_at = monthly_limit - .get("reset_at") - .and_then(Value::as_i64) - .and_then(|value| DateTime::::from_timestamp(value, 0)); - let window_seconds = monthly_limit - .get("window_minutes") - .and_then(Value::as_u64) - .and_then(|minutes| minutes.checked_mul(60)) - .or(Some(MONTH_WINDOW_SECONDS)); - if used_percent.is_none() && reset_at.is_none() { - return false; - } - - let key = monthly_limit - .get("key") - .and_then(Value::as_str) - .unwrap_or("monthly"); - windows.push(AdminAccountQuotaWindowData { - key: quota_key_segment(key), - group: "monthly".to_string(), - window_seconds, - label_display: "月限额".to_string(), - used_percent, - used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_percent), - reset_at_display: reset_at - .as_ref() - .map_or_else(|| "-".to_string(), china_datetime), - window_used_display: quota_window_used_display(reset_at, window_seconds), - }); - true -} - -fn push_snapshot_quota_windows( - windows: &mut Vec, - snapshot: &Value, - skip_core_monthly: bool, -) { - let source = snapshot - .get("source") - .and_then(Value::as_str) - .unwrap_or("quota"); - let source_key = snapshot_source_key(source, snapshot); - let label_prefix = snapshot_label(snapshot); - for role in ["primary", "secondary"] { - let Some(window) = snapshot.get(role).filter(|value| !value.is_null()) else { - continue; - }; - let window_seconds = window - .get("window_minutes") - .and_then(Value::as_u64) - .and_then(|minutes| minutes.checked_mul(60)); - if skip_core_monthly - && source == "core" - && window_seconds - .is_some_and(|seconds| quota_window_matches(seconds, MONTH_WINDOW_SECONDS)) - { - continue; - } - push_quota_window( - windows, - &source_key, - role, - label_prefix.as_deref(), - Some(window), - ); - } -} - -fn push_quota_window( - windows: &mut Vec, - source_key: &str, - role: &str, - label_prefix: Option<&str>, - window: Option<&Value>, -) { - let Some(window) = window.filter(|value| !value.is_null()) else { - return; - }; - let used_percent = window - .get("used_percent") - .and_then(number_value) - .map(|value| value.clamp(0.0, 100.0)); - let reset_at = window - .get("reset_at") - .and_then(Value::as_i64) - .and_then(|value| DateTime::::from_timestamp(value, 0)); - let window_seconds = window - .get("window_minutes") - .and_then(Value::as_u64) - .and_then(|minutes| minutes.checked_mul(60)); - let label_prefix = label_prefix - .map(str::trim) - .filter(|value| !value.is_empty()); - if reset_at.is_none() - && window_seconds.is_none() - && (used_percent.is_none() || label_prefix.is_none()) - { - return; - } - let base_label = quota_window_label_display(window_seconds); - let label_display = label_prefix - .map(|value| format!("{value} · {base_label}")) - .unwrap_or(base_label); - - windows.push(AdminAccountQuotaWindowData { - key: unique_quota_window_key(windows, source_key, role, window_seconds), - group: quota_window_group(window_seconds).to_string(), - window_seconds, - label_display, - used_percent, - used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_percent), - reset_at_display: reset_at - .as_ref() - .map_or_else(|| "-".to_string(), china_datetime), - window_used_display: quota_window_used_display(reset_at, window_seconds), - }); -} - -fn snapshot_source_key(source: &str, snapshot: &Value) -> String { - let label = snapshot - .get("limit_name") - .and_then(Value::as_str) - .or_else(|| snapshot.get("metered_feature").and_then(Value::as_str)) - .unwrap_or(source); - format!("{}-{}", quota_key_segment(source), quota_key_segment(label)) -} - -fn snapshot_label(snapshot: &Value) -> Option { - let source = snapshot.get("source").and_then(Value::as_str); - if source == Some("core") { - return None; - } - let label = snapshot - .get("limit_name") - .and_then(Value::as_str) - .or_else(|| snapshot.get("metered_feature").and_then(Value::as_str)) - .map(str::trim) - .filter(|value| !value.is_empty())?; - if label.eq_ignore_ascii_case("codex") { - return None; - } - if is_review_limit_label(Some(label)) { - return Some("代码审查".to_string()); - } - Some(label.to_string()) -} - -fn unique_quota_window_key( - windows: &[AdminAccountQuotaWindowData], - source_key: &str, - role: &str, - window_seconds: Option, -) -> String { - let bucket = quota_window_key_part(window_seconds).unwrap_or(role); - let key = format!("{source_key}-{bucket}"); - if windows.iter().any(|window| window.key == key) { - format!("{key}-{role}") - } else { - key - } -} - -fn quota_window_key_part(window_seconds: Option) -> Option<&'static str> { - match window_seconds { - Some(seconds) if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) => { - Some("five-hour") - } - Some(seconds) if quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => Some("weekly"), - Some(seconds) if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => Some("monthly"), - _ => None, - } -} - -fn quota_key_segment(value: &str) -> String { - let mut segment = String::with_capacity(value.len()); - for ch in value.chars() { - if ch.is_ascii_alphanumeric() { - segment.push(ch.to_ascii_lowercase()); - } else if !segment.ends_with('-') { - segment.push('-'); - } - } - let segment = segment.trim_matches('-'); - if segment.is_empty() { - "quota".to_string() - } else { - segment.to_string() - } -} - -fn quota_window_sort_key(window: &AdminAccountQuotaWindowData) -> (u8, u64, String) { - let group_order = match window.group.as_str() { - "monthly" => 0, - "shortTerm" => 1, - _ => 2, - }; - ( - group_order, - window.window_seconds.unwrap_or(0), - window.key.clone(), - ) -} - -fn quota_window_group(window_seconds: Option) -> &'static str { - match window_seconds { - Some(seconds) if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => "monthly", - Some(seconds) - if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) - || quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => - { - "shortTerm" - } - _ => "other", - } -} - -fn quota_window_matches(actual: u64, expected: u64) -> bool { - actual > 0 && actual.abs_diff(expected) <= expected / 20 -} - -fn quota_window_label_display(window_seconds: Option) -> String { - let Some(window_seconds) = window_seconds.filter(|seconds| *seconds > 0) else { - return "额度".to_string(); - }; - match window_seconds { - seconds if quota_window_matches(seconds, FIVE_HOUR_WINDOW_SECONDS) => { - "5小时限额".to_string() - } - seconds if quota_window_matches(seconds, WEEK_WINDOW_SECONDS) => "周限额".to_string(), - seconds if quota_window_matches(seconds, MONTH_WINDOW_SECONDS) => "月限额".to_string(), - seconds if seconds % 86_400 == 0 => format!("{}天限额", seconds / 86_400), - seconds if seconds % 3_600 == 0 => format!("{}小时限额", seconds / 3_600), - seconds => format!("{}分钟限额", seconds.div_ceil(60)), - } -} - -fn quota_window_used_display( - reset_at: Option>, - window_seconds: Option, -) -> String { - let (Some(reset_at), Some(window_seconds)) = (reset_at, window_seconds) else { - return "-".to_string(); - }; - let remaining = reset_at - .signed_duration_since(Utc::now()) - .num_seconds() - .max(0) - .cast_unsigned(); - let used = window_seconds.saturating_sub(remaining); - format!( - "{} / {}", - format_duration_days(used), - format_duration_days(window_seconds) - ) -} - -fn format_duration_days(seconds: u64) -> String { - let days = seconds as f64 / 86_400.0; - if days >= 1.0 { - format!("{days:.1}d") - } else { - format!("{:.1}h", seconds as f64 / 3_600.0) - } -} - -fn number_value(value: &Value) -> Option { - value - .as_f64() - .or_else(|| value.as_str().and_then(|value| value.parse::().ok())) - .filter(|value| value.is_finite()) -} - -fn is_review_limit_label(value: Option<&str>) -> bool { - let normalized = value - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .replace(['-', ' '], "_"); - normalized == "review" - || normalized == "code_review" - || normalized == "codex_review" - || normalized == "codex_code_review" - || normalized.contains("code_review") - || normalized.contains("codex_review") -} - struct ParsedAccountUpdate { id: String, update: AdminAccountUpdate, } -#[derive(Debug, Deserialize, Validate)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct AccountUpdateRequest { - #[validate(custom(function = "validate_non_empty_trimmed"))] - id: String, - label: Option>, - #[validate(custom(function = "validate_non_empty_trimmed"))] - status: Option, -} - fn parse_account_update(payload: &Value) -> Result { - let object = payload - .as_object() - .ok_or_else(|| AdminError::bad_request("Account update request must be an object"))?; - for field in object.keys() { - if !matches!(field.as_str(), "id" | "label" | "status") { - return Err(AdminError::bad_request(format!("{field} is not editable"))); - } - } - let payload = serde_json::from_value::(payload.clone()) - .map_err(|_| AdminError::bad_request("Invalid account update request"))?; - payload - .validate() - .map_err(|_| AdminError::bad_request("Invalid account update request"))?; + let payload = parse_editable_update( + payload, + EditableUpdateMessages { + object_required: "Account update request must be an object", + invalid: "Invalid account update request", + empty_update: "Account update request must include editable fields", + unknown_field_editable: true, + }, + )?; let update = AdminAccountUpdate { label: payload.label.map(|label| { label.and_then(|value| { @@ -1535,13 +1194,6 @@ fn parse_account_update(payload: &Value) -> Result Result<(), ValidationError> { - if value.trim().is_empty() { - return Err(ValidationError::new("required")); - } - Ok(()) -} - fn account_error(error: &AdminAccountError) -> AdminError { match error { AdminAccountError::InvalidStatus(_) @@ -1576,6 +1228,96 @@ fn account_not_found() -> AdminError { AdminError::not_found("Account not found") } +enum AccountExportAuditOutcome { + Success, + MissingConfirmation, + EmptyIds, + NotFound, + Failed, +} + +impl AccountExportAuditOutcome { + fn from_error(error: &AdminAccountError) -> Self { + match error { + AdminAccountError::EmptyIds => Self::EmptyIds, + AdminAccountError::NotFound => Self::NotFound, + _ => Self::Failed, + } + } + + fn level(&self) -> UsageRecordLevel { + match self { + Self::Success => UsageRecordLevel::Warn, + Self::MissingConfirmation | Self::EmptyIds | Self::NotFound => UsageRecordLevel::Warn, + Self::Failed => UsageRecordLevel::Error, + } + } + + fn message(&self) -> &'static str { + match self { + Self::Success => "Admin account export succeeded", + Self::MissingConfirmation => "Admin account export missing confirmation", + Self::EmptyIds => "Admin account export missing account ids", + Self::NotFound => "Admin account export account not found", + Self::Failed => "Admin account export failed", + } + } + + fn status_code(&self) -> i64 { + match self { + Self::Success => i64::from(StatusCode::OK.as_u16()), + Self::MissingConfirmation | Self::EmptyIds => { + i64::from(StatusCode::BAD_REQUEST.as_u16()) + } + Self::NotFound => i64::from(StatusCode::NOT_FOUND.as_u16()), + Self::Failed => i64::from(StatusCode::INTERNAL_SERVER_ERROR.as_u16()), + } + } + + fn failure_class(&self) -> Option<&'static str> { + match self { + Self::Success => None, + Self::MissingConfirmation => Some("missing_confirmation"), + Self::EmptyIds => Some("empty_ids"), + Self::NotFound => Some("account_not_found"), + Self::Failed => Some("export_failed"), + } + } +} + +async fn record_account_export_audit( + state: &AppState, + request_id: Option<&str>, + source: &str, + ids: &[String], + outcome: AccountExportAuditOutcome, +) { + let mut record = UsageRecord::new("admin_account_export", outcome.level(), outcome.message()); + record.request_id = request_id.map(ToString::to_string); + record.route = Some("/api/admin/accounts/export".to_string()); + record.status_code = Some(outcome.status_code()); + record.failure_class = outcome.failure_class().map(ToString::to_string); + record.metadata = json!({ + "source": source, + "accountIds": ids, + "accountCount": ids.len(), + }); + + if let Err(error) = state.services.usage_records.record_audit(record).await { + tracing::warn!(%error, "failed to record account export audit event"); + } +} + +fn account_action_source(headers: &HeaderMap) -> String { + headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown") + .to_string() +} + fn account_export_ids(value: Option<&str>) -> Vec { value .into_iter() diff --git a/backend/src/admin/accounts/service/exporting.rs b/backend/src/admin/accounts/service/exporting.rs index 4d1e4fa8f..05e3cdc44 100644 --- a/backend/src/admin/accounts/service/exporting.rs +++ b/backend/src/admin/accounts/service/exporting.rs @@ -9,15 +9,12 @@ use crate::{ upstream::accounts::{model::AccountStatus, store::StoredAccount}, }; -const EXPORT_PAGE_LIMIT: u32 = 200; - impl AdminAccountService { pub async fn export(&self, ids: Vec) -> Result { - let accounts = if ids.is_empty() { - self.export_all_accounts().await? - } else { - self.export_accounts_by_id(ids).await? - }; + if ids.is_empty() { + return Err(AdminAccountError::EmptyIds); + } + let accounts = self.export_accounts_by_id(ids).await?; Ok(ExportedAccounts { source_format: "cpr", @@ -25,23 +22,6 @@ impl AdminAccountService { }) } - async fn export_all_accounts(&self) -> Result, AdminAccountError> { - let mut accounts = Vec::new(); - let mut cursor = None; - loop { - let page = self - .store - .list(cursor, EXPORT_PAGE_LIMIT) - .await - .map_err(|_| AdminAccountError::Export)?; - accounts.extend(page.items); - let Some(next_cursor) = page.next_cursor else { - return Ok(accounts); - }; - cursor = Some(next_cursor); - } - } - async fn export_accounts_by_id( &self, ids: Vec, diff --git a/backend/src/admin/accounts/service/lifecycle.rs b/backend/src/admin/accounts/service/lifecycle.rs index dba5cd761..5cc43d27e 100644 --- a/backend/src/admin/accounts/service/lifecycle.rs +++ b/backend/src/admin/accounts/service/lifecycle.rs @@ -9,7 +9,10 @@ use secrecy::{ExposeSecret, SecretString}; use tokio::time::sleep; use crate::{ - infra::json::{NumberedPage, Page}, + infra::{ + json::{NumberedPage, Page}, + time::elapsed_millis_i64, + }, upstream::accounts::store::AccountStore, }; @@ -483,7 +486,3 @@ fn manual_refresh_failure_is_permanent( | crate::upstream::accounts::token_refresh::RefreshFailure::Transport => false, } } - -fn elapsed_millis_i64(started_at: std::time::Instant) -> i64 { - i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX) -} diff --git a/backend/src/admin/auth/service.rs b/backend/src/admin/auth/service.rs index 71d83f932..92ed4365c 100644 --- a/backend/src/admin/auth/service.rs +++ b/backend/src/admin/auth/service.rs @@ -1,12 +1,19 @@ //! 管理员会话存储与认证领域逻辑。 +use std::{collections::HashMap, sync::Arc}; + use chrono::{DateTime, Duration, Utc}; use sqlx::SqlitePool; use thiserror::Error; +use tokio::sync::Mutex; use uuid::Uuid; use crate::infra::identity::{hash_admin_password, verify_admin_password}; +const LOGIN_FAILURE_WINDOW: Duration = Duration::minutes(15); +const LOGIN_LOCK_DURATION: Duration = Duration::minutes(15); +const LOGIN_MAX_FAILURES: u32 = 5; + /// 管理员登录成功后的会话。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct AdminLoginSession { @@ -40,6 +47,9 @@ pub enum AdminSessionError { /// 删除管理员会话失败。 #[error("failed to delete admin session")] DeleteSession, + /// 管理员登录尝试过于频繁。 + #[error("too many admin login attempts")] + LoginThrottled, } /// 管理员会话服务。 @@ -48,6 +58,14 @@ pub struct AdminSessionService { store: SqliteAdminSessionStore, default_username: String, session_ttl_minutes: u64, + login_failures: Arc>>, +} + +#[derive(Debug, Clone)] +struct LoginFailureState { + failures: u32, + first_failed_at: DateTime, + locked_until: Option>, } impl AdminSessionService { @@ -61,6 +79,7 @@ impl AdminSessionService { store, default_username, session_ttl_minutes, + login_failures: Arc::new(Mutex::new(HashMap::new())), } } @@ -88,11 +107,16 @@ impl AdminSessionService { /// 登录并创建管理员会话。 pub async fn login( &self, + source: &str, username: Option<&str>, password: &str, ) -> Result, AdminSessionError> { + let source = login_source_key(source); + self.ensure_login_allowed(&source).await?; + let username = username.unwrap_or(&self.default_username); if username != self.default_username.as_str() { + self.record_failed_login(&source).await; return Ok(None); } let Some(admin) = self @@ -101,13 +125,16 @@ impl AdminSessionService { .await .map_err(|_| AdminSessionError::LoadAdmin)? else { + self.record_failed_login(&source).await; return Ok(None); }; let password_matches = verify_admin_password(password, &admin.password_hash) .map_err(|_| AdminSessionError::VerifyPassword)?; if !password_matches { + self.record_failed_login(&source).await; return Ok(None); } + self.clear_failed_login(&source).await; let session_id = format!("sess_{}", Uuid::new_v4().simple()); let ttl_minutes = self.session_ttl_minutes.min(i64::MAX as u64) as i64; let expires_at = Utc::now() + Duration::minutes(ttl_minutes); @@ -128,6 +155,67 @@ impl AdminSessionService { .await .map_err(|_| AdminSessionError::DeleteSession) } + + async fn ensure_login_allowed(&self, source: &str) -> Result<(), AdminSessionError> { + let now = Utc::now(); + let mut failures = self.login_failures.lock().await; + retain_active_login_failures(&mut failures, now); + if failures + .get(source) + .and_then(|state| state.locked_until) + .is_some_and(|locked_until| locked_until > now) + { + return Err(AdminSessionError::LoginThrottled); + } + Ok(()) + } + + async fn record_failed_login(&self, source: &str) { + let now = Utc::now(); + let mut failures = self.login_failures.lock().await; + retain_active_login_failures(&mut failures, now); + let state = failures + .entry(source.to_string()) + .or_insert_with(|| LoginFailureState { + failures: 0, + first_failed_at: now, + locked_until: None, + }); + if now - state.first_failed_at > LOGIN_FAILURE_WINDOW { + state.failures = 0; + state.first_failed_at = now; + state.locked_until = None; + } + state.failures += 1; + if state.failures >= LOGIN_MAX_FAILURES { + state.locked_until = Some(now + LOGIN_LOCK_DURATION); + } + } + + async fn clear_failed_login(&self, source: &str) { + self.login_failures.lock().await.remove(source); + } +} + +fn login_source_key(source: &str) -> String { + let source = source.trim(); + if source.is_empty() { + "unknown".to_string() + } else { + source.to_string() + } +} + +fn retain_active_login_failures( + failures: &mut HashMap, + now: DateTime, +) { + failures.retain(|_, state| { + state + .locked_until + .is_some_and(|locked_until| locked_until > now) + || now - state.first_failed_at <= LOGIN_FAILURE_WINDOW + }); } // --------------------------------------------------------------------------- diff --git a/backend/src/admin/auth/session.rs b/backend/src/admin/auth/session.rs index cf7357d53..91b83063e 100644 --- a/backend/src/admin/auth/session.rs +++ b/backend/src/admin/auth/session.rs @@ -1,20 +1,42 @@ //! 管理端会话处理器。 use axum::{ - extract::State, - http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, + extract::{Extension, FromRequestParts, State}, + http::{header::SET_COOKIE, request::Parts, HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Json, }; use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::{ - admin::response::{AdminEnvelope, AdminError, AdminResponse}, + admin::{ + auth::service::AdminSessionError, + monitoring::usage_record_model::{UsageRecord, UsageRecordLevel}, + response::{AdminEnvelope, AdminError, AdminResponse}, + }, + http::middleware::request_id::RequestId, infra::time::china_rfc3339, runtime::state::AppState, }; const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session"; +const ADMIN_SESSION_COOKIE_ATTRS: &str = "Path=/; Secure; HttpOnly; SameSite=Lax"; + +/// 已通过管理员会话或管理员 API Key 鉴权的请求。 +pub(crate) struct AdminAuth; + +impl FromRequestParts for AdminAuth { + type Rejection = AdminError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + require_admin_auth(state, &parts.headers).await?; + Ok(Self) + } +} #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -38,15 +60,71 @@ struct AdminSessionStatusData { /// `POST /api/admin/login` pub(crate) async fn login( State(state): State, + request_id: Option>, + headers: HeaderMap, Json(payload): Json, ) -> Result { - let session = state + let source = admin_login_source(&headers); + let request_id = request_id.map(|Extension(request_id)| request_id.as_str().to_string()); + let username_provided = payload + .username + .as_deref() + .is_some_and(|username| !username.trim().is_empty()); + let login_result = state .services .admin_sessions - .login(payload.username.as_deref(), payload.password.as_str()) - .await - .map_err(|_| AdminError::internal("Failed to create admin session"))? - .ok_or_else(AdminError::invalid_admin_credentials)?; + .login( + &source, + payload.username.as_deref(), + payload.password.as_str(), + ) + .await; + let session = match login_result { + Ok(Some(session)) => { + record_admin_login_audit( + &state, + request_id.as_deref(), + &source, + username_provided, + AdminLoginAuditOutcome::Success, + ) + .await; + session + } + Ok(None) => { + record_admin_login_audit( + &state, + request_id.as_deref(), + &source, + username_provided, + AdminLoginAuditOutcome::InvalidCredentials, + ) + .await; + return Err(AdminError::invalid_admin_credentials()); + } + Err(AdminSessionError::LoginThrottled) => { + record_admin_login_audit( + &state, + request_id.as_deref(), + &source, + username_provided, + AdminLoginAuditOutcome::Throttled, + ) + .await; + return Err(AdminError::too_many_login_attempts()); + } + Err(_) => { + record_admin_login_audit( + &state, + request_id.as_deref(), + &source, + username_provided, + AdminLoginAuditOutcome::InternalError, + ) + .await; + return Err(AdminError::internal("Failed to create admin session")); + } + }; let mut response = AdminResponse::new( StatusCode::OK, @@ -56,7 +134,7 @@ pub(crate) async fn login( ) .into_response(); let cookie = format!( - "{ADMIN_SESSION_COOKIE}={}; Path=/; HttpOnly; SameSite=Lax", + "{ADMIN_SESSION_COOKIE}={}; {ADMIN_SESSION_COOKIE_ATTRS}", session.session_id ); response.headers_mut().insert( @@ -67,6 +145,72 @@ pub(crate) async fn login( Ok(response) } +enum AdminLoginAuditOutcome { + Success, + InvalidCredentials, + Throttled, + InternalError, +} + +impl AdminLoginAuditOutcome { + fn level(&self) -> UsageRecordLevel { + match self { + Self::Success => UsageRecordLevel::Info, + Self::InvalidCredentials | Self::Throttled => UsageRecordLevel::Warn, + Self::InternalError => UsageRecordLevel::Error, + } + } + + fn message(&self) -> &'static str { + match self { + Self::Success => "Admin login succeeded", + Self::InvalidCredentials => "Admin login failed", + Self::Throttled => "Admin login throttled", + Self::InternalError => "Admin login failed internally", + } + } + + fn status_code(&self) -> i64 { + match self { + Self::Success => i64::from(StatusCode::OK.as_u16()), + Self::InvalidCredentials => i64::from(StatusCode::UNAUTHORIZED.as_u16()), + Self::Throttled => i64::from(StatusCode::TOO_MANY_REQUESTS.as_u16()), + Self::InternalError => i64::from(StatusCode::INTERNAL_SERVER_ERROR.as_u16()), + } + } + + fn failure_class(&self) -> Option<&'static str> { + match self { + Self::Success => None, + Self::InvalidCredentials => Some("invalid_credentials"), + Self::Throttled => Some("login_throttled"), + Self::InternalError => Some("session_error"), + } + } +} + +async fn record_admin_login_audit( + state: &AppState, + request_id: Option<&str>, + source: &str, + username_provided: bool, + outcome: AdminLoginAuditOutcome, +) { + let mut record = UsageRecord::new("admin_auth", outcome.level(), outcome.message()); + record.request_id = request_id.map(ToString::to_string); + record.route = Some("/api/admin/login".to_string()); + record.status_code = Some(outcome.status_code()); + record.failure_class = outcome.failure_class().map(ToString::to_string); + record.metadata = json!({ + "source": source, + "usernameProvided": username_provided, + }); + + if let Err(error) = state.services.usage_records.record_audit(record).await { + tracing::warn!(%error, "failed to record admin login audit event"); + } +} + /// `GET /api/admin/auth/status` pub(crate) async fn session_status( State(state): State, @@ -106,7 +250,7 @@ pub(crate) async fn logout( ) .into_response(); - let cookie = format!("{ADMIN_SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"); + let cookie = format!("{ADMIN_SESSION_COOKIE}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0"); response.headers_mut().insert( SET_COOKIE, HeaderValue::from_str(&cookie) @@ -161,3 +305,13 @@ fn admin_api_key_header(headers: &HeaderMap) -> Option { let value = headers.get("x-api-key")?.to_str().ok()?.trim(); (!value.is_empty()).then(|| value.to_string()) } + +fn admin_login_source(headers: &HeaderMap) -> String { + headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown") + .to_string() +} diff --git a/backend/src/admin/keys/routes.rs b/backend/src/admin/keys/routes.rs index afb64527f..a9d118b95 100644 --- a/backend/src/admin/keys/routes.rs +++ b/backend/src/admin/keys/routes.rs @@ -2,20 +2,20 @@ use axum::{ extract::{Query, State}, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use serde_json::Value; -use validator::{Validate, ValidationError}; use crate::{ - admin::auth::session::require_admin_auth, + admin::auth::session::AdminAuth, admin::keys::service::{AdminClientApiKey, AdminClientKeyError}, admin::response::{ AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse, BatchDeleteData, }, + admin::update_payload::{parse_editable_update, EditableUpdateMessages}, infra::{ json::{clamp_limit, Page}, time::{china_relative_time_str, china_rfc3339_str}, @@ -88,16 +88,6 @@ fn client_key_not_found() -> AdminError { AdminError::not_found("Client API key not found") } -#[derive(Debug, Deserialize, Validate)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ClientApiKeyUpdateRequest { - #[validate(custom(function = "validate_non_empty_trimmed"))] - id: String, - label: Option>, - #[validate(custom(function = "validate_non_empty_trimmed"))] - status: Option, -} - struct ParsedClientApiKeyUpdate { id: String, label: Option>, @@ -105,21 +95,15 @@ struct ParsedClientApiKeyUpdate { } fn parse_client_api_key_update(payload: &Value) -> Result { - if !payload.is_object() { - return Err(AdminError::bad_request( - "Client API key update request must be an object", - )); - } - let update = serde_json::from_value::(payload.clone()) - .map_err(|_| AdminError::bad_request("Invalid client API key update request"))?; - update - .validate() - .map_err(|_| AdminError::bad_request("Invalid client API key update request"))?; - if update.label.is_none() && update.status.is_none() { - return Err(AdminError::bad_request( - "Client API key update request must include label or status", - )); - } + let update = parse_editable_update( + payload, + EditableUpdateMessages { + object_required: "Client API key update request must be an object", + invalid: "Invalid client API key update request", + empty_update: "Client API key update request must include label or status", + unknown_field_editable: false, + }, + )?; Ok(ParsedClientApiKeyUpdate { id: update.id, label: update.label, @@ -127,20 +111,12 @@ fn parse_client_api_key_update(payload: &Value) -> Result Result<(), ValidationError> { - if value.trim().is_empty() { - return Err(ValidationError::new("required")); - } - Ok(()) -} - /// `GET /api/admin/keys` pub(crate) async fn api_keys( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(query): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; let limit = clamp_limit(query.limit.unwrap_or(50)); match state .services @@ -165,10 +141,9 @@ pub(crate) async fn api_keys( /// `POST /api/admin/keys` pub(crate) async fn create_api_key( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Json(payload): Json, ) -> Result { - require_admin_auth(&state, &headers).await?; match state.services.admin_client_keys.create(&payload.name).await { Ok(created) => Ok(AdminResponse::new( StatusCode::OK, @@ -181,11 +156,9 @@ pub(crate) async fn create_api_key( /// `POST /api/admin/keys/update` pub(crate) async fn update_api_key( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Json(payload): Json, ) -> Result { - require_admin_auth(&state, &headers).await?; - let update = parse_client_api_key_update(&payload)?; if let Some(label) = update.label { match state @@ -225,10 +198,9 @@ pub(crate) async fn update_api_key( /// `POST /api/admin/keys/delete` pub(crate) async fn batch_delete_api_keys( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Json(payload): Json, ) -> Result { - require_admin_auth(&state, &headers).await?; match state .services .admin_client_keys diff --git a/backend/src/admin/mod.rs b/backend/src/admin/mod.rs index 91d74de4f..4708c8030 100644 --- a/backend/src/admin/mod.rs +++ b/backend/src/admin/mod.rs @@ -16,3 +16,4 @@ pub mod router; pub mod settings; /// 系统版本与在线更新处理器。 pub mod system; +pub(crate) mod update_payload; diff --git a/backend/src/admin/monitoring/service.rs b/backend/src/admin/monitoring/account_usage_service.rs similarity index 87% rename from backend/src/admin/monitoring/service.rs rename to backend/src/admin/monitoring/account_usage_service.rs index ca19c9836..f76b7dc1a 100644 --- a/backend/src/admin/monitoring/service.rs +++ b/backend/src/admin/monitoring/account_usage_service.rs @@ -2,10 +2,11 @@ use chrono::{DateTime, Utc}; use thiserror::Error; -use crate::admin::monitoring::usage_store::{ - SqliteUsageStore, UsageListRecord, UsageSummary, UsageTimeBucketRecord, +use crate::admin::monitoring::{ + account_usage_store::{SqliteUsageStore, UsageListRecord, UsageSummary, UsageTimeBucketRecord}, + billing, }; -use crate::infra::json::Page; +use crate::infra::{format::nonnegative_i64_to_u64, json::Page}; /// 管理端用量统计服务。 #[derive(Clone)] @@ -121,7 +122,7 @@ pub struct AdminUsageSummary { } /// 管理端时间桶用量记录。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct AdminUsageTimeBucketRecord { pub bucket_start: DateTime, pub model: String, @@ -137,6 +138,7 @@ pub struct AdminUsageTimeBucketRecord { pub latency_count: i64, pub max_latency_ms: i64, pub min_latency_ms: i64, + pub cost_usd: Option, } impl From for AdminUsageSummary { @@ -160,6 +162,7 @@ impl From for AdminUsageSummary { impl From for AdminUsageTimeBucketRecord { fn from(record: UsageTimeBucketRecord) -> Self { + let cost_usd = time_bucket_cost_usd(&record); Self { bucket_start: record.bucket_start, model: record.model, @@ -175,10 +178,33 @@ impl From for AdminUsageTimeBucketRecord { latency_count: record.latency_count, max_latency_ms: record.max_latency_ms, min_latency_ms: record.min_latency_ms, + cost_usd, } } } +fn time_bucket_cost_usd(record: &UsageTimeBucketRecord) -> Option { + let input_tokens = nonnegative_i64_to_u64(record.input_tokens); + let output_tokens = nonnegative_i64_to_u64(record.output_tokens); + let cached_tokens = nonnegative_i64_to_u64(record.cached_tokens); + if input_tokens == 0 && output_tokens == 0 && cached_tokens == 0 { + return None; + } + + let model = record.model.trim(); + if model.is_empty() { + return None; + } + + Some(billing::calculate_cost( + input_tokens, + output_tokens, + cached_tokens, + model, + record.service_tier.as_deref(), + )) +} + impl From for AdminUsageRecord { fn from(usage: UsageListRecord) -> Self { Self { diff --git a/backend/src/admin/monitoring/usage_store.rs b/backend/src/admin/monitoring/account_usage_store.rs similarity index 86% rename from backend/src/admin/monitoring/usage_store.rs rename to backend/src/admin/monitoring/account_usage_store.rs index cefe8a398..3893dca97 100644 --- a/backend/src/admin/monitoring/usage_store.rs +++ b/backend/src/admin/monitoring/account_usage_store.rs @@ -1,12 +1,17 @@ //! SQLite 用量存储。 use chrono::{DateTime, Utc}; -use sqlx::{sqlite::SqliteRow, Row, SqlitePool}; +use sqlx::{sqlite::SqliteRow, QueryBuilder, Row, Sqlite, SqlitePool}; use thiserror::Error; -use crate::infra::json::{decode_cursor, encode_cursor, Page}; +use crate::infra::{ + json::{decode_cursor, encode_cursor, Page}, + time::{ + parse_optional_rfc3339_utc as parse_optional_rfc3339, parse_rfc3339_utc as parse_rfc3339, + }, +}; -const LIST_USAGE_AFTER_CURSOR_SQL: &str = r" +const LIST_USAGE_SELECT_SQL: &str = r" select au.account_id, a.email, @@ -32,41 +37,7 @@ select au.limit_window_seconds, au.last_used_at from account_usage au -left join accounts a on a.id = au.account_id -where au.last_used_at < ? - or (au.last_used_at = ? and au.account_id < ?) -order by au.last_used_at desc, au.account_id desc -limit ?"; - -const LIST_USAGE_SQL: &str = r" -select - au.account_id, - a.email, - a.label, - a.plan_type, - au.request_count, - au.empty_response_count, - au.input_tokens, - au.output_tokens, - au.cached_tokens, - au.reasoning_tokens, - au.total_tokens, - au.image_input_tokens, - au.image_output_tokens, - au.image_request_count, - au.image_request_failed_count, - au.window_request_count, - au.window_input_tokens, - au.window_output_tokens, - au.window_cached_tokens, - au.window_started_at, - au.window_reset_at, - au.limit_window_seconds, - au.last_used_at -from account_usage au -left join accounts a on a.id = au.account_id -order by au.last_used_at desc, au.account_id desc -limit ?"; +left join accounts a on a.id = au.account_id"; const USAGE_SUMMARY_SQL: &str = r" select @@ -254,24 +225,24 @@ impl SqliteUsageStore { limit: u32, ) -> SqliteUsageStoreResult> { let limit = limit.clamp(1, 200); + let fetch_limit = i64::from(limit) + 1; + let mut builder = QueryBuilder::::new(LIST_USAGE_SELECT_SQL); if let Some(cursor) = cursor { let (last_used_at, account_id) = decode_cursor(&cursor).ok_or(SqliteUsageStoreError::InvalidCursor)?; - let rows = sqlx::query(LIST_USAGE_AFTER_CURSOR_SQL) - .bind(&last_used_at) - .bind(&last_used_at) - .bind(&account_id) - .bind(limit + 1) - .fetch_all(&self.pool) - .await?; - Ok(to_page(&rows, limit)) - } else { - let rows = sqlx::query(LIST_USAGE_SQL) - .bind(limit + 1) - .fetch_all(&self.pool) - .await?; - Ok(to_page(&rows, limit)) + builder.push("\nwhere au.last_used_at < "); + builder.push_bind(last_used_at.clone()); + builder.push("\n or (au.last_used_at = "); + builder.push_bind(last_used_at); + builder.push(" and au.account_id < "); + builder.push_bind(account_id); + builder.push(")"); } + builder.push("\norder by au.last_used_at desc, au.account_id desc\nlimit "); + builder.push_bind(fetch_limit); + + let rows = builder.build().fetch_all(&self.pool).await?; + Ok(to_page(&rows, limit)) } /// 汇总账号用量。 @@ -347,8 +318,7 @@ fn usage_list_from_row(row: &SqliteRow) -> SqliteUsageStoreResult SqliteUsageStoreResult { let service_tier = row.get::("service_tier").trim().to_string(); Ok(UsageTimeBucketRecord { - bucket_start: DateTime::parse_from_rfc3339(row.get::<&str, _>("bucket_start"))? - .with_timezone(&Utc), + bucket_start: parse_rfc3339(row.get::<&str, _>("bucket_start"))?, model: row.get("model"), service_tier: (!service_tier.is_empty()).then_some(service_tier), request_count: row.get("request_count"), @@ -365,12 +335,6 @@ fn usage_time_bucket_from_row(row: &SqliteRow) -> SqliteUsageStoreResult) -> SqliteUsageStoreResult>> { - value - .map(|value| Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc))) - .transpose() -} - fn to_page(rows: &[SqliteRow], limit: u32) -> Page { let has_more = rows.len() > limit as usize; let mut items = Vec::with_capacity(limit as usize); diff --git a/backend/src/admin/monitoring/dashboard.rs b/backend/src/admin/monitoring/dashboard.rs index 82bb63096..a3dcf0311 100644 --- a/backend/src/admin/monitoring/dashboard.rs +++ b/backend/src/admin/monitoring/dashboard.rs @@ -4,7 +4,7 @@ use std::{cmp::Reverse, collections::HashMap}; use axum::{ extract::{Query, State}, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::IntoResponse, }; use chrono::{DateTime, Duration, Utc}; @@ -13,15 +13,16 @@ use serde_json::Value; use crate::{ admin::{ - auth::session::require_admin_auth, + auth::session::AdminAuth, monitoring::{ - billing, + account_usage_service::{ + AdminUsageRecord, AdminUsageSummary, AdminUsageTimeBucketRecord, + }, diagnostics::{ fingerprint_diagnostics, AccountCapacityDiagnostics, AccountPoolDiagnostics, }, - service::{AdminUsageRecord, AdminUsageSummary, AdminUsageTimeBucketRecord}, - usage_record_store::AdminUsageRecordFilter, - usage_records::{account_email_map, usage_record_items, UsageRecordData}, + usage_record_routes::{usage_record_items, UsageRecordData}, + usage_record_service::AdminUsageRecordFilter, }, response::{AdminEnvelope, AdminError, AdminResponse}, }, @@ -250,10 +251,8 @@ impl UsageWindow { /// `GET /api/admin/dashboard/summary` pub(crate) async fn dashboard_summary( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, ) -> Result { - require_admin_auth(&state, &headers).await?; - let accounts = state .services .accounts @@ -293,7 +292,12 @@ pub(crate) async fn dashboard_summary( let quota_used_by_account = account_quota_used_percent_by_id(&state, &usage_records).await; let settings = state.services.settings.current(); let recent_usage_records = recent_events.into_iter().take(10).collect::>(); - let account_emails = account_email_map(&state, &recent_usage_records).await?; + let account_emails = state + .services + .usage_records + .account_email_map(&recent_usage_records) + .await + .map_err(|error| AdminError::usage_record_accounts_failed(error.to_string()))?; let dashboard_usage_records = usage_record_items(recent_usage_records, &account_emails); Ok(AdminResponse::new( @@ -315,10 +319,9 @@ pub(crate) async fn dashboard_summary( /// `GET /api/admin/dashboard/trend?kind=usage|latency|errors` pub(crate) async fn dashboard_trend( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(query): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; let time_buckets = dashboard_time_buckets(&state, Utc::now()).await; Ok(AdminResponse::new( @@ -736,7 +739,7 @@ fn cost_window( let mut has_usage = false; for record in records { if record.bucket_start >= start && record.bucket_start < end { - if let Some(cost) = bucket_cost(record) { + if let Some(cost) = record.cost_usd { total += cost; has_usage = true; } @@ -749,7 +752,7 @@ fn total_cost(records: &[AdminUsageTimeBucketRecord]) -> f64 { let mut total = 0.0; let mut has_usage = false; for record in records { - if let Some(cost) = bucket_cost(record) { + if let Some(cost) = record.cost_usd { total += cost; has_usage = true; } @@ -761,29 +764,6 @@ fn total_cost(records: &[AdminUsageTimeBucketRecord]) -> f64 { 0.0 } -fn bucket_cost(record: &AdminUsageTimeBucketRecord) -> Option { - let input_tokens = nonnegative_i64_to_u64(record.input_tokens); - let output_tokens = nonnegative_i64_to_u64(record.output_tokens); - let cached_tokens = nonnegative_i64_to_u64(record.cached_tokens); - if input_tokens == 0 && output_tokens == 0 && cached_tokens == 0 { - return None; - } - - let model = record.model.trim(); - if model.is_empty() { - return None; - } - let service_tier = record.service_tier.as_deref(); - - Some(billing::calculate_cost( - input_tokens, - output_tokens, - cached_tokens, - model, - service_tier, - )) -} - fn apply_bucket(window: &mut UsageWindow, record: &AdminUsageTimeBucketRecord) { window.requests += nonnegative_i64_to_u64(record.request_count); window.input_tokens += nonnegative_i64_to_u64(record.input_tokens); diff --git a/backend/src/admin/monitoring/mod.rs b/backend/src/admin/monitoring/mod.rs index 5f5931500..49f3ebd0a 100644 --- a/backend/src/admin/monitoring/mod.rs +++ b/backend/src/admin/monitoring/mod.rs @@ -1,10 +1,11 @@ //! 管理面监控模块:日志、用量统计和 Dashboard 聚合。 +pub mod account_usage_service; +pub mod account_usage_store; pub(crate) mod billing; pub mod dashboard; pub mod diagnostics; -pub mod service; -pub mod usage_record; +pub mod usage_record_model; +pub mod usage_record_routes; +pub mod usage_record_service; pub mod usage_record_store; -pub mod usage_records; -pub mod usage_store; diff --git a/backend/src/admin/monitoring/usage_record.rs b/backend/src/admin/monitoring/usage_record_model.rs similarity index 83% rename from backend/src/admin/monitoring/usage_record.rs rename to backend/src/admin/monitoring/usage_record_model.rs index 835173db7..877433d29 100644 --- a/backend/src/admin/monitoring/usage_record.rs +++ b/backend/src/admin/monitoring/usage_record_model.rs @@ -100,9 +100,29 @@ pub(in crate::admin) fn metadata_service_tier(metadata: &Value) -> Option<&str> .filter(|value| !value.is_empty()) } +pub(in crate::admin) fn metadata_string(metadata: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + metadata + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) +} + +pub(in crate::admin) fn metadata_i64(metadata: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| metadata.get(*key).and_then(Value::as_i64)) +} + +pub(in crate::admin) fn metadata_nonnegative_i64(metadata: &Value, keys: &[&str]) -> Option { + metadata_i64(metadata, keys).filter(|value| *value >= 0) +} + /// 响应事件记录(供 dispatch 模块使用)。 pub struct ResponseUsageRecord<'a> { - pub usage_records: &'a crate::admin::monitoring::usage_record_store::AdminUsageRecordService, + pub usage_records: &'a crate::admin::monitoring::usage_record_service::AdminUsageRecordService, pub request_id: &'a str, pub account_id: &'a str, pub route: &'a str, diff --git a/backend/src/admin/monitoring/usage_records.rs b/backend/src/admin/monitoring/usage_record_routes.rs similarity index 80% rename from backend/src/admin/monitoring/usage_records.rs rename to backend/src/admin/monitoring/usage_record_routes.rs index 8a0751295..263f3f20e 100644 --- a/backend/src/admin/monitoring/usage_records.rs +++ b/backend/src/admin/monitoring/usage_record_routes.rs @@ -4,25 +4,27 @@ use std::collections::HashMap; use axum::{ extract::{Query, State}, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::IntoResponse, }; use serde::{Deserialize, Serialize}; use serde_json::Value; -use sqlx::{QueryBuilder, Row, Sqlite}; use crate::{ admin::monitoring::{ - billing, - usage_record::{metadata_service_tier, UsageRecord, UsageRecordLevel}, + usage_record_model::{ + metadata_nonnegative_i64, metadata_string, UsageRecord, UsageRecordLevel, + }, + usage_record_service::{ + usage_record_cost_details, AdminUsageRecordError, AdminUsageRecordFilter, + }, usage_record_store::{ - AdminUsageRecordError, AdminUsageRecordFilter, UsageRecordBreakdown, - UsageRecordEndpointSource, UsageRecordModelSource, UsageRecordSummary, - UsageRecordTrendPoint, + UsageRecordBreakdown, UsageRecordEndpointSource, UsageRecordModelSource, + UsageRecordSummary, UsageRecordTrendPoint, }, }, admin::{ - auth::session::require_admin_auth, + auth::session::AdminAuth, response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse}, }, infra::{ @@ -200,10 +202,9 @@ struct UsageRecordTrendPointData { /// `GET /api/admin/usage/records` pub(crate) async fn usage_records( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(query): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; let limit = clamp_limit(query.page_size.or(query.limit).unwrap_or(50)); let page = query.page; let use_numbered_page = page.is_some() || query.page_size.is_some(); @@ -218,7 +219,12 @@ pub(crate) async fn usage_records( .await { Ok(page) => { - let account_emails = account_email_map(&state, &page.items).await?; + let account_emails = state + .services + .usage_records + .account_email_map(&page.items) + .await + .map_err(|error| log_error(&error))?; let page = NumberedPage { items: usage_record_items(page.items, &account_emails), total: page.total, @@ -241,7 +247,12 @@ pub(crate) async fn usage_records( .await { Ok(page) => { - let account_emails = account_email_map(&state, &page.items).await?; + let account_emails = state + .services + .usage_records + .account_email_map(&page.items) + .await + .map_err(|error| log_error(&error))?; let page = Page { items: usage_record_items(page.items, &account_emails), next_cursor: page.next_cursor, @@ -258,10 +269,9 @@ pub(crate) async fn usage_records( /// `GET /api/admin/usage/records/summary` pub(crate) async fn usage_records_summary( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(query): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; let filter = filter_from_query(query)?; match state.services.usage_records.summary(filter).await { Ok(summary) => Ok(AdminResponse::new( @@ -275,10 +285,9 @@ pub(crate) async fn usage_records_summary( /// `GET /api/admin/usage/records/insights/models` pub(crate) async fn usage_records_model_distribution( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(query): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; let source = model_source_from_query(query.source)?; let filter = filter_from_query(query.records)?; match state @@ -298,10 +307,9 @@ pub(crate) async fn usage_records_model_distribution( /// `GET /api/admin/usage/records/insights/endpoints` pub(crate) async fn usage_records_endpoint_distribution( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(query): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; let source = endpoint_source_from_query(query.source)?; let filter = filter_from_query(query.records)?; match state @@ -321,10 +329,9 @@ pub(crate) async fn usage_records_endpoint_distribution( /// `GET /api/admin/usage/records/insights/token-trend` pub(crate) async fn usage_records_token_trend( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(query): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; let filter = filter_from_query(query)?; match state.services.usage_records.token_trend(filter).await { Ok(points) => Ok(AdminResponse::new( @@ -343,10 +350,9 @@ pub(crate) async fn usage_records_token_trend( /// `GET /api/admin/usage/records/insights/latency-trend` pub(crate) async fn usage_records_latency_trend( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(query): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; let filter = filter_from_query(query)?; match state.services.usage_records.latency_trend(filter).await { Ok(points) => Ok(AdminResponse::new( @@ -365,13 +371,17 @@ pub(crate) async fn usage_records_latency_trend( /// `GET /api/admin/usage/records/detail` pub(crate) async fn usage_record_detail( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, Query(query): Query, ) -> Result { - require_admin_auth(&state, &headers).await?; match state.services.usage_records.get(&query.id).await { Ok(Some(log)) => { - let account_emails = account_email_map(&state, std::slice::from_ref(&log)).await?; + let account_emails = state + .services + .usage_records + .account_email_map(std::slice::from_ref(&log)) + .await + .map_err(|error| log_error(&error))?; Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(usage_record_data(log, &account_emails)), @@ -385,9 +395,8 @@ pub(crate) async fn usage_record_detail( /// `POST /api/admin/usage/records/delete` pub(crate) async fn clear_usage_records( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, ) -> Result { - require_admin_auth(&state, &headers).await?; match state.services.usage_records.clear().await { Ok(cleared) => Ok(AdminResponse::new( StatusCode::OK, @@ -462,6 +471,9 @@ fn log_error(error: &AdminUsageRecordError) -> AdminError { | AdminUsageRecordError::Clear | AdminUsageRecordError::Append | AdminUsageRecordError::Trim => AdminError::internal(error.to_string()), + AdminUsageRecordError::Accounts => { + AdminError::usage_record_accounts_failed(error.to_string()) + } } } @@ -484,50 +496,6 @@ fn non_empty(value: Option) -> Option { .filter(|value| !value.is_empty()) } -pub(crate) async fn account_email_map( - state: &AppState, - items: &[UsageRecord], -) -> Result, AdminError> { - let mut account_ids = items - .iter() - .filter_map(|item| item.account_id.as_deref()) - .map(str::to_string) - .collect::>(); - account_ids.sort_unstable(); - account_ids.dedup(); - - if account_ids.is_empty() { - return Ok(HashMap::new()); - } - - let mut builder = QueryBuilder::::new("select id, email from accounts where id in ("); - let mut separated = builder.separated(", "); - for account_id in &account_ids { - separated.push_bind(account_id); - } - separated.push_unseparated(")"); - - let rows = builder - .build() - .fetch_all(state.services.background_tasks.accounts.pool()) - .await - .map_err(|error| { - AdminError::usage_record_accounts_failed(format!( - "Failed to load usage record accounts: {error}" - )) - })?; - - Ok(rows - .into_iter() - .filter_map(|row| { - row.get::, _>("email") - .map(|email| email.trim().to_string()) - .filter(|email| !email.is_empty()) - .map(|email| (row.get::("id"), email)) - }) - .collect()) -} - pub(crate) fn usage_record_items( items: Vec, account_emails: &HashMap, @@ -555,7 +523,7 @@ fn usage_record_data( metadata_string(&record.metadata, &["reasoningEffort", "reasoning_effort"]); let token_details = usage_token_details(&record.metadata); let cost_details = usage_cost_details(&record, upstream_model.as_deref(), &token_details); - let first_token_latency_ms = metadata_i64( + let first_token_latency_ms = metadata_nonnegative_i64( &record.metadata, &[ "firstTokenMs", @@ -626,51 +594,37 @@ fn usage_cost_details( upstream_model: Option<&str>, tokens: &UsageRecordTokenDetailsData, ) -> Option { - if tokens.input_tokens == 0 && tokens.output_tokens == 0 && tokens.cached_tokens == 0 { - return None; - } - - let model = upstream_model - .or(record.model.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty())?; - let service_tier = metadata_service_tier(&record.metadata); - let breakdown = billing::calculate_cost_breakdown( + let cost = usage_record_cost_details( + record, + upstream_model, tokens.input_tokens, tokens.output_tokens, tokens.cached_tokens, - model, - service_tier, - ); - let original_cost = breakdown.input_cost + breakdown.output_cost + breakdown.cache_read_cost; + )?; Some(UsageRecordCostDetailsData { - input_cost: breakdown.input_cost, - output_cost: breakdown.output_cost, - cache_read_cost: breakdown.cache_read_cost, - total_cost: breakdown.total_cost, - billed_cost: breakdown.total_cost, - original_cost, - input_price_per_mtoken: breakdown.input_price_per_mtoken, - output_price_per_mtoken: breakdown.output_price_per_mtoken, - cache_read_price_per_mtoken: breakdown.cache_read_price_per_mtoken, - service_tier: breakdown.service_tier.clone(), - service_tier_display: breakdown - .service_tier - .as_deref() - .map(format_service_tier) - .unwrap_or_else(|| "Default".to_string()), - multiplier: breakdown.tier_multiplier, - input_cost_display: format_precise_cost(breakdown.input_cost), - output_cost_display: format_precise_cost(breakdown.output_cost), - cache_read_cost_display: format_precise_cost(breakdown.cache_read_cost), - total_cost_display: format_precise_cost(breakdown.total_cost), - billed_cost_display: format_precise_cost(breakdown.total_cost), - original_cost_display: format_precise_cost(original_cost), - input_price_display: format_token_price(breakdown.input_price_per_mtoken), - output_price_display: format_token_price(breakdown.output_price_per_mtoken), - cache_read_price_display: format_token_price(breakdown.cache_read_price_per_mtoken), - multiplier_display: format_multiplier(breakdown.tier_multiplier), + input_cost: cost.input_cost, + output_cost: cost.output_cost, + cache_read_cost: cost.cache_read_cost, + total_cost: cost.total_cost, + billed_cost: cost.billed_cost, + original_cost: cost.original_cost, + input_price_per_mtoken: cost.input_price_per_mtoken, + output_price_per_mtoken: cost.output_price_per_mtoken, + cache_read_price_per_mtoken: cost.cache_read_price_per_mtoken, + service_tier: cost.service_tier, + service_tier_display: cost.service_tier_display, + multiplier: cost.multiplier, + input_cost_display: format_precise_cost(cost.input_cost), + output_cost_display: format_precise_cost(cost.output_cost), + cache_read_cost_display: format_precise_cost(cost.cache_read_cost), + total_cost_display: format_precise_cost(cost.total_cost), + billed_cost_display: format_precise_cost(cost.billed_cost), + original_cost_display: format_precise_cost(cost.original_cost), + input_price_display: format_token_price(cost.input_price_per_mtoken), + output_price_display: format_token_price(cost.output_price_per_mtoken), + cache_read_price_display: format_token_price(cost.cache_read_price_per_mtoken), + multiplier_display: format_multiplier(cost.multiplier), }) } @@ -688,32 +642,6 @@ fn metadata_u64_at(value: &Value, key: &str) -> Option { .and_then(|value| value.as_u64().or_else(|| value.as_i64()?.try_into().ok())) } -fn metadata_string(metadata: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| { - metadata - .get(*key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - }) -} - -fn metadata_i64(metadata: &Value, keys: &[&str]) -> Option { - keys.iter() - .find_map(|key| metadata.get(*key).and_then(Value::as_i64)) - .filter(|value| *value >= 0) -} - -fn format_service_tier(value: &str) -> String { - match value { - "priority" | "fast" => "Fast".to_string(), - "flex" => "Flex".to_string(), - "default" => "Default".to_string(), - other => other.to_string(), - } -} - impl From for UsageRecordSummaryData { fn from(summary: UsageRecordSummary) -> Self { Self { diff --git a/backend/src/admin/monitoring/usage_record_service.rs b/backend/src/admin/monitoring/usage_record_service.rs new file mode 100644 index 000000000..38620e336 --- /dev/null +++ b/backend/src/admin/monitoring/usage_record_service.rs @@ -0,0 +1,374 @@ +//! 管理端使用记录服务。 + +use std::{collections::HashMap, sync::Arc}; + +use chrono::{DateTime, Utc}; +use thiserror::Error; + +use crate::{ + admin::monitoring::{ + billing, + usage_record_model::metadata_service_tier, + usage_record_model::{UsageRecord, UsageRecordLevel}, + usage_record_store::{ + SqliteUsageRecordStore, UsageRecordBreakdown, UsageRecordEndpointSource, + UsageRecordFilter, UsageRecordModelSource, UsageRecordSummary, UsageRecordTrendPoint, + }, + }, + infra::json::{NumberedPage, Page}, +}; + +/// 管理端使用记录查询过滤器。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AdminUsageRecordFilter { + /// 事件类别。 + pub kind: Option, + /// 事件等级。 + pub level: Option, + /// 请求 ID。 + pub request_id: Option, + /// 账号 ID。 + pub account_id: Option, + /// 路由。 + pub route: Option, + /// 模型。 + pub model: Option, + /// HTTP 状态码。 + pub status_code: Option, + /// 上游传输方式。 + pub transport: Option, + /// 同一请求内的上游尝试序号。 + pub attempt_index: Option, + /// 上游 HTTP 状态码。 + pub upstream_status_code: Option, + /// 失败分类。 + pub failure_class: Option, + /// 上游响应 ID。 + pub response_id: Option, + /// 上游请求 ID。 + pub upstream_request_id: Option, + /// 搜索关键词。 + pub search: Option, + /// 起始时间。 + pub start_time: Option>, + /// 结束时间。 + pub end_time: Option>, +} + +impl From for UsageRecordFilter { + fn from(filter: AdminUsageRecordFilter) -> Self { + Self { + kind: filter.kind, + level: filter.level, + request_id: filter.request_id, + account_id: filter.account_id, + route: filter.route, + model: filter.model, + status_code: filter.status_code, + transport: filter.transport, + attempt_index: filter.attempt_index, + upstream_status_code: filter.upstream_status_code, + failure_class: filter.failure_class, + response_id: filter.response_id, + upstream_request_id: filter.upstream_request_id, + search: filter.search, + start_time: filter.start_time, + end_time: filter.end_time, + } + } +} + +/// 清空使用记录结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminClearUsageRecords { + /// 清理数量。 + pub cleared: u64, +} + +/// 管理端使用记录费用明细。 +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AdminUsageRecordCostDetails { + pub input_cost: f64, + pub output_cost: f64, + pub cache_read_cost: f64, + pub total_cost: f64, + pub billed_cost: f64, + pub original_cost: f64, + pub input_price_per_mtoken: f64, + pub output_price_per_mtoken: f64, + pub cache_read_price_per_mtoken: f64, + pub service_tier: Option, + pub service_tier_display: String, + pub multiplier: f64, +} + +/// 管理端使用记录错误。 +#[derive(Debug, Error)] +pub enum AdminUsageRecordError { + /// 列表失败。 + #[error("failed to list usage records")] + List, + /// 读取失败。 + #[error("failed to get usage record")] + Get, + /// 清空失败。 + #[error("failed to clear usage records")] + Clear, + /// 写入失败。 + #[error("failed to append usage record")] + Append, + /// 裁剪失败。 + #[error("failed to trim usage records")] + Trim, + /// 账号关联信息读取失败。 + #[error("failed to load usage record accounts")] + Accounts, +} + +/// 管理端使用记录服务。 +#[derive(Clone)] +pub struct AdminUsageRecordService { + store: SqliteUsageRecordStore, + settings: Arc>, +} + +#[derive(Debug, Clone, Copy)] +struct AdminUsageRecordSettings { + enabled: bool, + capacity: u32, + capture_body: bool, +} + +impl AdminUsageRecordService { + /// 构造管理端使用记录服务。 + pub fn new( + store: SqliteUsageRecordStore, + enabled: bool, + capacity: u32, + capture_body: bool, + ) -> Self { + Self { + store, + settings: Arc::new(tokio::sync::RwLock::new(AdminUsageRecordSettings { + enabled, + capacity, + capture_body, + })), + } + } + + /// 分页查询日志。 + pub async fn list( + &self, + cursor: Option, + limit: u32, + filter: AdminUsageRecordFilter, + ) -> Result, AdminUsageRecordError> { + self.store + .list(filter.into(), cursor, limit) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 按页码查询日志。 + pub async fn list_page( + &self, + page: u32, + page_size: u32, + filter: AdminUsageRecordFilter, + ) -> Result, AdminUsageRecordError> { + self.store + .list_page(filter.into(), page, page_size) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 按 ID 读取日志。 + pub async fn get(&self, id: &str) -> Result, AdminUsageRecordError> { + self.store + .get(id) + .await + .map_err(|_| AdminUsageRecordError::Get) + } + + /// 读取使用记录关联账号的邮箱映射。 + pub async fn account_email_map( + &self, + items: &[UsageRecord], + ) -> Result, AdminUsageRecordError> { + self.store + .account_email_map(items) + .await + .map_err(|_| AdminUsageRecordError::Accounts) + } + + /// 汇总使用记录。 + pub async fn summary( + &self, + filter: AdminUsageRecordFilter, + ) -> Result { + self.store + .summary(filter.into()) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 按模型来源聚合使用记录分布。 + pub async fn model_distribution( + &self, + filter: AdminUsageRecordFilter, + source: UsageRecordModelSource, + ) -> Result, AdminUsageRecordError> { + self.store + .model_distribution(filter.into(), source) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 按端点来源聚合使用记录分布。 + pub async fn endpoint_distribution( + &self, + filter: AdminUsageRecordFilter, + source: UsageRecordEndpointSource, + ) -> Result, AdminUsageRecordError> { + self.store + .endpoint_distribution(filter.into(), source) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 聚合 Token 趋势。 + pub async fn token_trend( + &self, + filter: AdminUsageRecordFilter, + ) -> Result, AdminUsageRecordError> { + self.store + .trend(filter.into()) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 聚合延迟趋势。 + pub async fn latency_trend( + &self, + filter: AdminUsageRecordFilter, + ) -> Result, AdminUsageRecordError> { + self.store + .trend(filter.into()) + .await + .map_err(|_| AdminUsageRecordError::List) + } + + /// 清空日志。 + pub async fn clear(&self) -> Result { + self.store + .clear() + .await + .map(|cleared| AdminClearUsageRecords { cleared }) + .map_err(|_| AdminUsageRecordError::Clear) + } + + /// 记录使用记录。 + pub async fn record(&self, event: UsageRecord) -> Result<(), AdminUsageRecordError> { + let settings = *self.settings.read().await; + if !settings.enabled && event.level != UsageRecordLevel::Error { + return Ok(()); + } + self.append_with_settings(event, settings).await + } + + /// 记录安全审计事件,不受普通使用记录开关影响。 + pub async fn record_audit(&self, event: UsageRecord) -> Result<(), AdminUsageRecordError> { + let settings = *self.settings.read().await; + self.append_with_settings(event, settings).await + } + + async fn append_with_settings( + &self, + mut event: UsageRecord, + settings: AdminUsageRecordSettings, + ) -> Result<(), AdminUsageRecordError> { + apply_capture_body_policy(&mut event, settings.capture_body); + self.store + .append(&event) + .await + .map_err(|_| AdminUsageRecordError::Append)?; + self.store + .trim_to_capacity(settings.capacity) + .await + .map_err(|_| AdminUsageRecordError::Trim)?; + Ok(()) + } +} + +fn apply_capture_body_policy(event: &mut UsageRecord, capture_body: bool) { + if capture_body { + return; + } + let Some(metadata) = event.metadata.as_object_mut() else { + return; + }; + for key in [ + "body", + "rawBody", + "requestBody", + "responseBody", + "upstreamBody", + ] { + metadata.remove(key); + } +} + +pub(crate) fn usage_record_cost_details( + record: &UsageRecord, + upstream_model: Option<&str>, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, +) -> Option { + if input_tokens == 0 && output_tokens == 0 && cached_tokens == 0 { + return None; + } + + let model = upstream_model + .or(record.model.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let service_tier = metadata_service_tier(&record.metadata); + let breakdown = billing::calculate_cost_breakdown( + input_tokens, + output_tokens, + cached_tokens, + model, + service_tier, + ); + let original_cost = breakdown.input_cost + breakdown.output_cost + breakdown.cache_read_cost; + + Some(AdminUsageRecordCostDetails { + input_cost: breakdown.input_cost, + output_cost: breakdown.output_cost, + cache_read_cost: breakdown.cache_read_cost, + total_cost: breakdown.total_cost, + billed_cost: breakdown.total_cost, + original_cost, + input_price_per_mtoken: breakdown.input_price_per_mtoken, + output_price_per_mtoken: breakdown.output_price_per_mtoken, + cache_read_price_per_mtoken: breakdown.cache_read_price_per_mtoken, + service_tier: breakdown.service_tier.clone(), + service_tier_display: breakdown + .service_tier + .as_deref() + .map(format_service_tier) + .unwrap_or_else(|| "Default".to_string()), + multiplier: breakdown.tier_multiplier, + }) +} + +fn format_service_tier(value: &str) -> String { + match value { + "priority" | "fast" => "Fast".to_string(), + "flex" => "Flex".to_string(), + "default" => "Default".to_string(), + other => other.to_string(), + } +} diff --git a/backend/src/admin/monitoring/usage_record_store.rs b/backend/src/admin/monitoring/usage_record_store.rs index 145093279..56da84367 100644 --- a/backend/src/admin/monitoring/usage_record_store.rs +++ b/backend/src/admin/monitoring/usage_record_store.rs @@ -1,6 +1,6 @@ //! 使用记录存储实现(SQLite)。 -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use chrono::{DateTime, Utc}; use serde_json::Value; @@ -9,10 +9,15 @@ use thiserror::Error; use crate::admin::monitoring::{ billing, - usage_record::{metadata_service_tier, UsageRecord, UsageRecordLevel}, + usage_record_model::{ + metadata_i64, metadata_service_tier, metadata_string, UsageRecord, UsageRecordLevel, + }, +}; +use crate::infra::{ + format::optional_nonnegative_i64_to_u64, + json::{decode_cursor, encode_cursor, page_offset, NumberedPage, Page}, + time::{china_quarter_hour_start, parse_rfc3339_utc as parse_rfc3339}, }; -use crate::infra::json::{decode_cursor, encode_cursor, page_offset, NumberedPage, Page}; -use crate::infra::time::china_quarter_hour_start; /// 默认保留最新使用记录数量。 pub const DEFAULT_USAGE_RECORD_CAPACITY: u32 = 2_000; @@ -44,6 +49,7 @@ pub type SqliteUsageRecordStoreResult = Result SqliteUsageRecordStoreResult> { let fetch_limit = i64::from(limit) + 1; - let mut builder = QueryBuilder::::new( - "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from usage_records", - ); + let mut builder = QueryBuilder::::new(USAGE_RECORD_SELECT_SQL); push_filter(&mut builder, &filter, cursor.as_deref())?; builder.push(" order by created_at desc, id desc limit "); builder.push_bind(fetch_limit); @@ -148,9 +152,7 @@ impl SqliteUsageRecordStore { let page_size = page_size.clamp(1, 200); let total = count_usage_records(&self.pool, &filter).await?; let offset = page_offset(page, page_size); - let mut builder = QueryBuilder::::new( - "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from usage_records", - ); + let mut builder = QueryBuilder::::new(USAGE_RECORD_SELECT_SQL); push_filter(&mut builder, &filter, None)?; builder.push(" order by created_at desc, id desc limit "); builder.push_bind(i64::from(page_size)); @@ -173,15 +175,21 @@ impl SqliteUsageRecordStore { /// 按 ID 读取使用记录。 pub async fn get(&self, id: &str) -> SqliteUsageRecordStoreResult> { - let row = sqlx::query( - "select id, request_id, kind, level, account_id, route, model, status_code, transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at from usage_records where id = ?", - ) - .bind(id) - .fetch_optional(&self.pool) - .await?; + let mut builder = QueryBuilder::::new(USAGE_RECORD_SELECT_SQL); + builder.push(" where id = "); + builder.push_bind(id); + let row = builder.build().fetch_optional(&self.pool).await?; row.map(|row| usage_record_from_row(&row)).transpose() } + /// 读取使用记录关联账号的邮箱映射。 + pub async fn account_email_map( + &self, + items: &[UsageRecord], + ) -> SqliteUsageRecordStoreResult> { + usage_record_account_email_map(&self.pool, items).await + } + /// 汇总事件使用记录。 pub async fn summary( &self, @@ -208,16 +216,8 @@ impl SqliteUsageRecordStore { usage_endpoint_distribution(&self.pool, &filter, source, 8).await } - /// 聚合 Token 使用趋势。 - pub async fn token_trend( - &self, - filter: UsageRecordFilter, - ) -> SqliteUsageRecordStoreResult> { - usage_trend(&self.pool, &filter).await - } - - /// 聚合延迟趋势。 - pub async fn latency_trend( + /// 聚合使用记录趋势。 + pub async fn trend( &self, filter: UsageRecordFilter, ) -> SqliteUsageRecordStoreResult> { @@ -284,21 +284,6 @@ fn usage_record_summary_fields(event: &UsageRecord) -> UsageRecordSummaryFields } } -fn metadata_string(metadata: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| { - metadata - .get(*key) - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(ToString::to_string) - }) -} - -fn metadata_i64(metadata: &Value, keys: &[&str]) -> Option { - keys.iter() - .find_map(|key| metadata.get(*key).and_then(Value::as_i64)) -} - async fn append_event(pool: &SqlitePool, event: &UsageRecord) -> SqliteUsageRecordStoreResult<()> { let summary = usage_record_summary_fields(event); sqlx::query( @@ -473,13 +458,17 @@ async fn usage_summary( push_filter(&mut builder, filter, None)?; let row = builder.build().fetch_one(pool).await?; - let input_tokens = nonnegative_i64(row.get::, _>("input_tokens")); - let output_tokens = nonnegative_i64(row.get::, _>("output_tokens")); - let cached_tokens = nonnegative_i64(row.get::, _>("cached_tokens")); + let input_tokens = optional_nonnegative_i64_to_u64(row.get::, _>("input_tokens")); + let output_tokens = optional_nonnegative_i64_to_u64(row.get::, _>("output_tokens")); + let cached_tokens = optional_nonnegative_i64_to_u64(row.get::, _>("cached_tokens")); Ok(UsageRecordSummary { - total_requests: nonnegative_i64(row.get::, _>("total_requests")), - error_requests: nonnegative_i64(row.get::, _>("error_requests")), + total_requests: optional_nonnegative_i64_to_u64( + row.get::, _>("total_requests"), + ), + error_requests: optional_nonnegative_i64_to_u64( + row.get::, _>("error_requests"), + ), input_tokens, output_tokens, cached_tokens, @@ -488,8 +477,39 @@ async fn usage_summary( }) } -fn nonnegative_i64(value: Option) -> u64 { - value.unwrap_or_default().max(0).cast_unsigned() +async fn usage_record_account_email_map( + pool: &SqlitePool, + items: &[UsageRecord], +) -> SqliteUsageRecordStoreResult> { + let mut account_ids = items + .iter() + .filter_map(|item| item.account_id.as_deref()) + .map(str::to_string) + .collect::>(); + account_ids.sort_unstable(); + account_ids.dedup(); + + if account_ids.is_empty() { + return Ok(HashMap::new()); + } + + let mut builder = QueryBuilder::::new("select id, email from accounts where id in ("); + let mut separated = builder.separated(", "); + for account_id in &account_ids { + separated.push_bind(account_id); + } + separated.push_unseparated(")"); + + let rows = builder.build().fetch_all(pool).await?; + Ok(rows + .into_iter() + .filter_map(|row| { + row.get::, _>("email") + .map(|email| email.trim().to_string()) + .filter(|email| !email.is_empty()) + .map(|email| (row.get::("id"), email)) + }) + .collect()) } async fn usage_model_distribution( @@ -590,9 +610,12 @@ async fn usage_breakdown( let mut items = Vec::::new(); for row in rows { let name: String = row.get(group_alias); - let input_tokens = nonnegative_i64(row.get::, _>("input_tokens")); - let output_tokens = nonnegative_i64(row.get::, _>("output_tokens")); - let cached_tokens = nonnegative_i64(row.get::, _>("cached_tokens")); + let input_tokens = + optional_nonnegative_i64_to_u64(row.get::, _>("input_tokens")); + let output_tokens = + optional_nonnegative_i64_to_u64(row.get::, _>("output_tokens")); + let cached_tokens = + optional_nonnegative_i64_to_u64(row.get::, _>("cached_tokens")); let cost = usage_breakdown_cost( input_tokens, output_tokens, @@ -600,7 +623,8 @@ async fn usage_breakdown( &row.get::("billing_model"), row.get::, _>("service_tier").as_deref(), ); - let request_count = nonnegative_i64(row.get::, _>("request_count")); + let request_count = + optional_nonnegative_i64_to_u64(row.get::, _>("request_count")); let average_latency_ms = row.get::, _>("average_latency_ms"); if let Some(item) = items.iter_mut().find(|item| item.name == name) { @@ -678,11 +702,14 @@ async fn usage_trend( for row in rows { let date: String = row.get("date"); - let input_tokens = nonnegative_i64(row.get::, _>("input_tokens")); - let output_tokens = nonnegative_i64(row.get::, _>("output_tokens")); + let input_tokens = + optional_nonnegative_i64_to_u64(row.get::, _>("input_tokens")); + let output_tokens = + optional_nonnegative_i64_to_u64(row.get::, _>("output_tokens")); let cache_creation_tokens = - nonnegative_i64(row.get::, _>("cache_creation_tokens")); - let cached_tokens = nonnegative_i64(row.get::, _>("cached_tokens")); + optional_nonnegative_i64_to_u64(row.get::, _>("cache_creation_tokens")); + let cached_tokens = + optional_nonnegative_i64_to_u64(row.get::, _>("cached_tokens")); let cost = usage_breakdown_cost( input_tokens, output_tokens, @@ -932,48 +959,6 @@ fn level_from_db(value: &str) -> SqliteUsageRecordStoreResult } } -fn parse_rfc3339(value: &str) -> SqliteUsageRecordStoreResult> { - Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc)) -} - -/// 管理端使用记录服务相关类型。 -/// 使用记录查询过滤器。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AdminUsageRecordFilter { - /// 事件类别。 - pub kind: Option, - /// 事件等级。 - pub level: Option, - /// 请求 ID。 - pub request_id: Option, - /// 账号 ID。 - pub account_id: Option, - /// 路由。 - pub route: Option, - /// 模型。 - pub model: Option, - /// HTTP 状态码。 - pub status_code: Option, - /// 上游传输方式。 - pub transport: Option, - /// 同一请求内的上游尝试序号。 - pub attempt_index: Option, - /// 上游 HTTP 状态码。 - pub upstream_status_code: Option, - /// 失败分类。 - pub failure_class: Option, - /// 上游响应 ID。 - pub response_id: Option, - /// 上游请求 ID。 - pub upstream_request_id: Option, - /// 搜索关键词。 - pub search: Option, - /// 起始时间。 - pub start_time: Option>, - /// 结束时间。 - pub end_time: Option>, -} - /// 使用记录汇总。 #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct UsageRecordSummary { @@ -1062,222 +1047,3 @@ pub struct UsageRecordTrendPoint { /// 平均耗时。 pub average_latency_ms: Option, } - -impl From for UsageRecordFilter { - fn from(filter: AdminUsageRecordFilter) -> Self { - Self { - kind: filter.kind, - level: filter.level, - request_id: filter.request_id, - account_id: filter.account_id, - route: filter.route, - model: filter.model, - status_code: filter.status_code, - transport: filter.transport, - attempt_index: filter.attempt_index, - upstream_status_code: filter.upstream_status_code, - failure_class: filter.failure_class, - response_id: filter.response_id, - upstream_request_id: filter.upstream_request_id, - search: filter.search, - start_time: filter.start_time, - end_time: filter.end_time, - } - } -} - -/// 清空使用记录结果。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdminClearUsageRecords { - /// 清理数量。 - pub cleared: u64, -} - -/// 管理端使用记录错误。 -#[derive(Debug, Error)] -pub enum AdminUsageRecordError { - /// 列表失败。 - #[error("failed to list usage records")] - List, - /// 读取失败。 - #[error("failed to get usage record")] - Get, - /// 清空失败。 - #[error("failed to clear usage records")] - Clear, - /// 写入失败。 - #[error("failed to append usage record")] - Append, - /// 裁剪失败。 - #[error("failed to trim usage records")] - Trim, -} - -/// 管理端使用记录服务。 -#[derive(Clone)] -pub struct AdminUsageRecordService { - store: SqliteUsageRecordStore, - settings: std::sync::Arc>, -} - -#[derive(Debug, Clone, Copy)] -struct AdminUsageRecordSettings { - enabled: bool, - capacity: u32, - capture_body: bool, -} - -impl AdminUsageRecordService { - /// 构造管理端使用记录服务。 - pub fn new( - store: SqliteUsageRecordStore, - enabled: bool, - capacity: u32, - capture_body: bool, - ) -> Self { - Self { - store, - settings: std::sync::Arc::new(tokio::sync::RwLock::new(AdminUsageRecordSettings { - enabled, - capacity, - capture_body, - })), - } - } - - /// 分页查询日志。 - pub async fn list( - &self, - cursor: Option, - limit: u32, - filter: AdminUsageRecordFilter, - ) -> Result, AdminUsageRecordError> { - self.store - .list(filter.into(), cursor, limit) - .await - .map_err(|_| AdminUsageRecordError::List) - } - - /// 按页码查询日志。 - pub async fn list_page( - &self, - page: u32, - page_size: u32, - filter: AdminUsageRecordFilter, - ) -> Result, AdminUsageRecordError> { - self.store - .list_page(filter.into(), page, page_size) - .await - .map_err(|_| AdminUsageRecordError::List) - } - - /// 按 ID 读取日志。 - pub async fn get(&self, id: &str) -> Result, AdminUsageRecordError> { - self.store - .get(id) - .await - .map_err(|_| AdminUsageRecordError::Get) - } - - /// 汇总使用记录。 - pub async fn summary( - &self, - filter: AdminUsageRecordFilter, - ) -> Result { - self.store - .summary(filter.into()) - .await - .map_err(|_| AdminUsageRecordError::List) - } - - /// 按模型来源聚合使用记录分布。 - pub async fn model_distribution( - &self, - filter: AdminUsageRecordFilter, - source: UsageRecordModelSource, - ) -> Result, AdminUsageRecordError> { - self.store - .model_distribution(filter.into(), source) - .await - .map_err(|_| AdminUsageRecordError::List) - } - - /// 按端点来源聚合使用记录分布。 - pub async fn endpoint_distribution( - &self, - filter: AdminUsageRecordFilter, - source: UsageRecordEndpointSource, - ) -> Result, AdminUsageRecordError> { - self.store - .endpoint_distribution(filter.into(), source) - .await - .map_err(|_| AdminUsageRecordError::List) - } - - /// 聚合 Token 趋势。 - pub async fn token_trend( - &self, - filter: AdminUsageRecordFilter, - ) -> Result, AdminUsageRecordError> { - self.store - .token_trend(filter.into()) - .await - .map_err(|_| AdminUsageRecordError::List) - } - - /// 聚合延迟趋势。 - pub async fn latency_trend( - &self, - filter: AdminUsageRecordFilter, - ) -> Result, AdminUsageRecordError> { - self.store - .latency_trend(filter.into()) - .await - .map_err(|_| AdminUsageRecordError::List) - } - - /// 清空日志。 - pub async fn clear(&self) -> Result { - self.store - .clear() - .await - .map(|cleared| AdminClearUsageRecords { cleared }) - .map_err(|_| AdminUsageRecordError::Clear) - } - - /// 记录使用记录。 - pub async fn record(&self, mut event: UsageRecord) -> Result<(), AdminUsageRecordError> { - let settings = *self.settings.read().await; - if !settings.enabled && event.level != UsageRecordLevel::Error { - return Ok(()); - } - apply_capture_body_policy(&mut event, settings.capture_body); - self.store - .append(&event) - .await - .map_err(|_| AdminUsageRecordError::Append)?; - self.store - .trim_to_capacity(settings.capacity) - .await - .map_err(|_| AdminUsageRecordError::Trim)?; - Ok(()) - } -} - -fn apply_capture_body_policy(event: &mut UsageRecord, capture_body: bool) { - if capture_body { - return; - } - let Some(metadata) = event.metadata.as_object_mut() else { - return; - }; - for key in [ - "body", - "rawBody", - "requestBody", - "responseBody", - "upstreamBody", - ] { - metadata.remove(key); - } -} diff --git a/backend/src/admin/response.rs b/backend/src/admin/response.rs index e4032d1e2..94eab9486 100644 --- a/backend/src/admin/response.rs +++ b/backend/src/admin/response.rs @@ -19,6 +19,7 @@ const ADMIN_INVALID_ENDPOINT_SOURCE_CODE: u32 = 40004; const ADMIN_SESSION_REQUIRED_CODE: u32 = 40101; const ADMIN_INVALID_CREDENTIALS_CODE: u32 = 40102; const ADMIN_INVALID_API_KEY_CODE: u32 = 40103; +const ADMIN_TOO_MANY_LOGIN_ATTEMPTS_CODE: u32 = 42901; const ADMIN_NOT_FOUND_CODE: u32 = 40401; const ADMIN_CONFLICT_CODE: u32 = 40901; const ADMIN_SETTINGS_PERSIST_CODE: u32 = 50000; @@ -98,6 +99,14 @@ impl AdminError { ) } + pub fn too_many_login_attempts() -> Self { + Self::new( + StatusCode::TOO_MANY_REQUESTS, + ADMIN_TOO_MANY_LOGIN_ATTEMPTS_CODE, + "Too many admin login attempts", + ) + } + pub fn not_found(message: impl Into) -> Self { Self::new(StatusCode::NOT_FOUND, ADMIN_NOT_FOUND_CODE, message) } diff --git a/backend/src/admin/router.rs b/backend/src/admin/router.rs index 8506fcd3b..74cfdba60 100644 --- a/backend/src/admin/router.rs +++ b/backend/src/admin/router.rs @@ -17,7 +17,7 @@ use super::{ keys::routes::{api_keys, batch_delete_api_keys, create_api_key, update_api_key}, monitoring::{ dashboard::{dashboard_summary, dashboard_trend}, - usage_records::{ + usage_record_routes::{ clear_usage_records, usage_record_detail, usage_records, usage_records_endpoint_distribution, usage_records_latency_trend, usage_records_model_distribution, usage_records_summary, usage_records_token_trend, diff --git a/backend/src/admin/settings/routes.rs b/backend/src/admin/settings/routes.rs index 677a0797f..8da1fdae4 100644 --- a/backend/src/admin/settings/routes.rs +++ b/backend/src/admin/settings/routes.rs @@ -1,17 +1,12 @@ //! 管理端设置处理器。 -use axum::{ - body::Bytes, - extract::State, - http::{HeaderMap, StatusCode}, - response::IntoResponse, -}; +use axum::{body::Bytes, extract::State, http::StatusCode, response::IntoResponse}; use serde::Serialize; use serde_json::Value; use std::collections::BTreeMap; use crate::{ - admin::auth::session::require_admin_auth, + admin::auth::session::AdminAuth, admin::response::{AdminEnvelope, AdminError, AdminResponse}, config::{ settings::{AdminSettingsPatch, RuntimeSettingsError, SettingsServiceError}, @@ -59,9 +54,8 @@ impl AdminSettingsData { /// `GET /api/admin/settings` pub(crate) async fn settings( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, ) -> Result { - require_admin_auth(&state, &headers).await?; let config = state.services.settings.current(); Ok(AdminResponse::new( StatusCode::OK, @@ -72,11 +66,9 @@ pub(crate) async fn settings( /// `POST /api/admin/settings` pub(crate) async fn update_settings( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, body: Bytes, ) -> Result { - require_admin_auth(&state, &headers).await?; - let patch = parse_settings_patch(&body)?; let config = state .services @@ -100,9 +92,8 @@ pub(crate) async fn update_settings( /// `GET /api/admin/settings/admin-api-key` pub(crate) async fn admin_api_key_status( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, ) -> Result { - require_admin_auth(&state, &headers).await?; let status = state .services .settings @@ -119,9 +110,8 @@ pub(crate) async fn admin_api_key_status( /// `POST /api/admin/settings/admin-api-key/regenerate` pub(crate) async fn regenerate_admin_api_key( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, ) -> Result { - require_admin_auth(&state, &headers).await?; let key = state .services .settings @@ -138,9 +128,8 @@ pub(crate) async fn regenerate_admin_api_key( /// `DELETE /api/admin/settings/admin-api-key` pub(crate) async fn delete_admin_api_key( State(state): State, - headers: HeaderMap, + _auth: AdminAuth, ) -> Result { - require_admin_auth(&state, &headers).await?; state .services .settings diff --git a/backend/src/admin/system/mod.rs b/backend/src/admin/system/mod.rs index b70252b4a..23005fb71 100644 --- a/backend/src/admin/system/mod.rs +++ b/backend/src/admin/system/mod.rs @@ -1,3 +1,5 @@ //! 系统版本与在线更新处理器。 pub mod routes; +mod state; +mod updater; diff --git a/backend/src/admin/system/routes.rs b/backend/src/admin/system/routes.rs index 79e6e5d31..a58a51766 100644 --- a/backend/src/admin/system/routes.rs +++ b/backend/src/admin/system/routes.rs @@ -1,1532 +1,5 @@ -//! 管理端系统版本与在线更新路由。 +//! 管理端系统路由入口。 -use std::{ - convert::Infallible, - env, fs, io, - io::{Read, Write}, - path::{Component, Path, PathBuf}, - sync::OnceLock, - time::{Duration, Instant}, +pub(crate) use super::updater::{ + check_updates, perform_update, restart, rollback, update_event_stream, update_status, version, }; - -use axum::{ - extract::{Query, State}, - http::{HeaderMap, StatusCode}, - response::{ - sse::{Event, KeepAlive, Sse}, - IntoResponse, - }, -}; -use chrono::Utc; -use flate2::read::GzDecoder; -use futures::Stream; -use semver::Version; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use tar::Archive; -use tokio::sync::{broadcast, Mutex}; - -use crate::{ - admin::auth::session::require_admin_auth, - admin::response::{AdminEnvelope, AdminError, AdminResponse}, - runtime::state::AppState, -}; - -const APP_BINARY_NAME: &str = "codex-proxy-rs"; -const DEFAULT_WEB_DIST_DIR: &str = "/app/web/dist"; -const GITHUB_API_BASE: &str = "https://api.github.com/repos"; -const UPDATE_CACHE_TTL: Duration = Duration::from_secs(20 * 60); -const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; - -static SYSTEM_OPERATION_LOCK: Mutex<()> = Mutex::const_new(()); -static RELEASE_CACHE: Mutex> = Mutex::const_new(None); -static UPDATE_EVENT_SENDER: OnceLock> = OnceLock::new(); - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct CheckUpdateQuery { - force: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct VersionData { - version: String, - git_sha: String, - build_time: String, - deployment_mode: String, - deployment_mode_label: String, - update_channel: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdateInfoData { - current_version: String, - latest_version: String, - has_update: bool, - deployment_mode: String, - deployment_mode_label: String, - build_type: String, - build_type_label: String, - release_url: Option, - notes: Option, - cached: bool, - update_supported: bool, - unsupported_reason: Option, - warning: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdateStartedData { - operation_id: String, - deployment_mode: String, - message: String, - need_restart: bool, - target_version: String, -} - -#[derive(Debug, Clone)] -struct CachedUpdateInfo { - key: String, - info: UpdateInfoData, - cached_at: Instant, -} - -#[derive(Debug, Clone, Deserialize)] -struct GitHubRelease { - tag_name: String, - name: Option, - body: Option, - html_url: Option, - prerelease: bool, - #[serde(default)] - assets: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -struct GitHubAsset { - name: String, - browser_download_url: String, - size: u64, -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "lowercase")] -enum UpdateLogLevel { - Info, - Success, - Warning, - Error, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SystemUpdateEvent { - id: String, - operation_id: Option, - level: UpdateLogLevel, - step: Option, - message: String, - at: String, -} - -#[derive(Debug, Deserialize, Serialize, Default)] -#[serde(rename_all = "camelCase")] -struct SystemUpdateStatusData { - previous_version: Option, - current_version: Option, - #[serde(default)] - operation: SystemOperationState, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -enum SystemOperationKind { - Update, - Rollback, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -enum SystemOperationStatus { - #[default] - Idle, - Running, - Succeeded, - Failed, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct SystemOperationState { - operation_id: Option, - kind: Option, - status: SystemOperationStatus, - target_version: Option, - message: Option, - error: Option, - started_at: Option, - finished_at: Option, -} - -impl Default for SystemOperationState { - fn default() -> Self { - Self { - operation_id: None, - kind: None, - status: SystemOperationStatus::Idle, - target_version: None, - message: None, - error: None, - started_at: None, - finished_at: None, - } - } -} - -#[derive(Debug)] -struct OperationLock { - path: PathBuf, -} - -#[derive(Debug)] -struct ExtractedRelease { - binary_path: PathBuf, - web_dist_dir: Option, -} - -#[derive(Debug, Clone, Copy)] -struct DownloadProgress<'a> { - operation_id: &'a str, - total_size: u64, -} - -#[derive(Debug, Clone)] -struct SystemUpdateConfig { - version: String, - git_sha: String, - build_time: String, - deployment_mode: String, - build_type: String, - update_channel: String, - update_repository: Option, - github_api_base: String, - executable_path: Option, - web_dist_dir: PathBuf, - update_state_file: PathBuf, - update_lock_file: PathBuf, -} - -/// `GET /api/admin/system/version` -pub(crate) async fn version( - State(state): State, - headers: HeaderMap, -) -> Result { - require_admin_auth(&state, &headers).await?; - let config = SystemUpdateConfig::from_env(); - Ok(AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok(config.version_data()), - )) -} - -/// `GET /api/admin/system/check-updates` -pub(crate) async fn check_updates( - State(state): State, - headers: HeaderMap, - Query(query): Query, -) -> Result { - require_admin_auth(&state, &headers).await?; - let config = SystemUpdateConfig::from_env(); - let info = check_latest_release(&config, query.force.unwrap_or(false)).await; - Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(info))) -} - -/// `GET /api/admin/system/update-events` -pub(crate) async fn update_event_stream( - State(state): State, - headers: HeaderMap, -) -> Result>>, AdminError> { - require_admin_auth(&state, &headers).await?; - - let receiver = update_event_sender().subscribe(); - let stream = futures::stream::unfold(receiver, |mut receiver| async move { - loop { - match receiver.recv().await { - Ok(message) => { - let id = message.id.clone(); - let data = serde_json::to_string(&message).unwrap_or_else(|_| "{}".to_string()); - let event = Event::default().event("update").id(id).data(data); - return Some((Ok(event), receiver)); - } - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => return None, - } - } - }); - - Ok(Sse::new(stream).keep_alive(KeepAlive::default())) -} - -/// `POST /api/admin/system/update` -pub(crate) async fn perform_update( - State(state): State, - headers: HeaderMap, -) -> Result { - require_admin_auth(&state, &headers).await?; - let _guard = SYSTEM_OPERATION_LOCK - .try_lock() - .map_err(|_| AdminError::conflict("System update already running"))?; - let config = SystemUpdateConfig::from_env(); - if let Some(reason) = config.update_support_error() { - emit_update_event( - UpdateLogLevel::Error, - None, - Some("preflight"), - reason.clone(), - ); - return Err(AdminError::conflict(reason)); - } - - emit_update_event( - UpdateLogLevel::Info, - None, - Some("release"), - "正在获取最新 Release 信息", - ); - let release = fetch_latest_release( - &config.github_api_base, - config - .update_repository - .as_deref() - .ok_or_else(|| AdminError::conflict("Update checks require CPR_UPDATE_REPOSITORY"))?, - ) - .await - .map_err(|error| { - emit_update_event(UpdateLogLevel::Error, None, Some("release"), error.clone()); - AdminError::bad_gateway(error) - })?; - let info = update_info_from_release(&config, release.clone()); - if !info.has_update { - emit_update_event( - UpdateLogLevel::Warning, - None, - Some("release"), - "当前版本已是最新", - ); - return Err(AdminError::conflict("Already up to date")); - } - let target_version = info.latest_version.clone(); - let operation_id = operation_id("update"); - let lock = OperationLock::acquire(&config.update_lock_file)?; - set_operation_running( - &config.update_state_file, - &operation_id, - SystemOperationKind::Update, - Some(&target_version), - )?; - - emit_update_event( - UpdateLogLevel::Info, - Some(&operation_id), - Some("prepare"), - format!("准备更新到 v{target_version}"), - ); - let result = perform_release_update(&config, &release, &target_version, &operation_id).await; - if let Err(error) = &result { - emit_update_event( - UpdateLogLevel::Error, - Some(&operation_id), - Some("failed"), - error.to_string(), - ); - } else { - emit_update_event( - UpdateLogLevel::Success, - Some(&operation_id), - Some("done"), - "更新文件已替换,等待服务重启生效", - ); - } - finish_operation( - &config.update_state_file, - &operation_id, - SystemOperationKind::Update, - result.as_ref().ok().map(|_| target_version.clone()), - result.as_ref().err().map(ToString::to_string), - ); - drop(lock); - result?; - - Ok(AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok(UpdateStartedData { - operation_id, - deployment_mode: config.deployment_mode, - message: "更新完成,请重启服务。".to_string(), - need_restart: true, - target_version, - }), - )) -} - -/// `GET /api/admin/system/update-status` -pub(crate) async fn update_status( - State(state): State, - headers: HeaderMap, -) -> Result { - require_admin_auth(&state, &headers).await?; - let config = SystemUpdateConfig::from_env(); - let status = read_state(&config.update_state_file)?; - Ok(AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok(status), - )) -} - -/// `POST /api/admin/system/rollback` -pub(crate) async fn rollback( - State(state): State, - headers: HeaderMap, -) -> Result { - require_admin_auth(&state, &headers).await?; - let _guard = SYSTEM_OPERATION_LOCK - .try_lock() - .map_err(|_| AdminError::conflict("系统操作正在执行中"))?; - let config = SystemUpdateConfig::from_env(); - if let Some(reason) = config.update_support_error() { - return Err(AdminError::conflict(reason)); - } - - let operation_id = operation_id("rollback"); - let lock = OperationLock::acquire(&config.update_lock_file)?; - set_operation_running( - &config.update_state_file, - &operation_id, - SystemOperationKind::Rollback, - None, - )?; - let result = rollback_release_update(&config).await; - finish_operation( - &config.update_state_file, - &operation_id, - SystemOperationKind::Rollback, - None, - result.as_ref().err().map(ToString::to_string), - ); - drop(lock); - result?; - - Ok(AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok(serde_json::json!({ - "message": "回滚完成,请重启服务。", - "needRestart": true, - "operationId": operation_id - })), - )) -} - -/// `POST /api/admin/system/restart` -pub(crate) async fn restart( - State(state): State, - headers: HeaderMap, -) -> Result { - require_admin_auth(&state, &headers).await?; - if env_string("CPR_ENABLE_SELF_RESTART").as_deref() != Some("true") { - return Err(AdminError::conflict( - "自重启未启用,请设置 CPR_ENABLE_SELF_RESTART=true", - )); - } - - tokio::spawn(async { - tokio::time::sleep(Duration::from_millis(500)).await; - std::process::exit(0); - }); - - Ok(AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok(serde_json::json!({ - "message": "已安排重启", - "operationId": operation_id("restart") - })), - )) -} - -impl SystemUpdateConfig { - fn from_env() -> Self { - let update_repository = env_string("CPR_UPDATE_REPOSITORY"); - let state_file = env_string("CPR_UPDATE_STATE_FILE") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/app/data/update-state.json")); - let update_lock_file = env_string("CPR_UPDATE_LOCK_FILE") - .map(PathBuf::from) - .unwrap_or_else(|| state_file.with_extension("lock")); - Self { - version: build_version(), - git_sha: build_git_sha(), - build_time: build_time(), - deployment_mode: env_string("CPR_DEPLOYMENT_MODE") - .unwrap_or_else(|| "source".to_string()), - build_type: build_type(), - update_channel: env_string("CPR_UPDATE_CHANNEL") - .unwrap_or_else(|| "stable".to_string()), - update_repository, - github_api_base: env_string("CPR_GITHUB_API_BASE") - .unwrap_or_else(|| GITHUB_API_BASE.to_string()), - executable_path: env_string("CPR_UPDATE_EXE_PATH").map(PathBuf::from), - web_dist_dir: env_string("CPR_WEB_DIST_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(DEFAULT_WEB_DIST_DIR)), - update_state_file: state_file, - update_lock_file, - } - } - - fn version_data(&self) -> VersionData { - VersionData { - version: self.version.clone(), - git_sha: self.git_sha.clone(), - build_time: self.build_time.clone(), - deployment_mode: self.deployment_mode.clone(), - deployment_mode_label: deployment_mode_label(&self.deployment_mode).to_string(), - update_channel: self.update_channel.clone(), - } - } - - fn update_support_error(&self) -> Option { - if self.build_type != "release" { - return Some("一键更新需要正式构建包".to_string()); - } - if self.update_repository.is_none() { - return Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_string()); - } - None - } - - fn release_cache_key(&self) -> String { - format!( - "{}|{}|{}|{}|{}", - self.update_repository.as_deref().unwrap_or_default(), - self.version, - self.deployment_mode, - self.build_type, - self.update_channel, - ) - } - - fn executable_path(&self) -> Result { - if let Some(path) = &self.executable_path { - return Ok(path.clone()); - } - env::current_exe() - .and_then(fs::canonicalize) - .map_err(|error| AdminError::internal(format!("Failed to resolve executable: {error}"))) - } -} - -async fn check_latest_release(config: &SystemUpdateConfig, force: bool) -> UpdateInfoData { - let Some(repository) = config.update_repository.as_deref() else { - return UpdateInfoData { - current_version: config.version.clone(), - latest_version: config.version.clone(), - has_update: false, - deployment_mode: config.deployment_mode.clone(), - deployment_mode_label: deployment_mode_label(&config.deployment_mode).to_string(), - build_type: config.build_type.clone(), - build_type_label: build_type_label(&config.build_type).to_string(), - release_url: None, - notes: None, - cached: false, - update_supported: false, - unsupported_reason: Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_string()), - warning: None, - }; - }; - let cache_key = config.release_cache_key(); - - if !force { - if let Some(info) = cached_release_info(&cache_key).await { - return info; - } - } - - match fetch_latest_release(&config.github_api_base, repository).await { - Ok(release) => { - let info = update_info_from_release(config, release); - cache_release_info(cache_key, &info).await; - info - } - Err(error) => cached_release_info(&cache_key) - .await - .unwrap_or_else(|| UpdateInfoData { - current_version: config.version.clone(), - latest_version: config.version.clone(), - has_update: false, - deployment_mode: config.deployment_mode.clone(), - deployment_mode_label: deployment_mode_label(&config.deployment_mode).to_string(), - build_type: config.build_type.clone(), - build_type_label: build_type_label(&config.build_type).to_string(), - release_url: None, - notes: None, - cached: false, - update_supported: false, - unsupported_reason: config.update_support_error(), - warning: Some(error), - }), - } -} - -async fn cached_release_info(cache_key: &str) -> Option { - let cache = RELEASE_CACHE.lock().await; - let cached = cache.as_ref()?; - if cached.key != cache_key || cached.cached_at.elapsed() > UPDATE_CACHE_TTL { - return None; - } - let mut info = cached.info.clone(); - info.cached = true; - Some(info) -} - -async fn cache_release_info(cache_key: String, info: &UpdateInfoData) { - let mut cache = RELEASE_CACHE.lock().await; - *cache = Some(CachedUpdateInfo { - key: cache_key, - info: info.clone(), - cached_at: Instant::now(), - }); -} - -async fn fetch_latest_release(api_base: &str, repository: &str) -> Result { - let url = format!( - "{}/{repository}/releases/latest", - api_base.trim_end_matches('/') - ); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(|error| error.to_string())?; - let response = client - .get(url) - .header(reqwest::header::ACCEPT, "application/vnd.github+json") - .header(reqwest::header::USER_AGENT, APP_BINARY_NAME) - .send() - .await - .map_err(|error| error.to_string())?; - let status = response.status(); - if !status.is_success() { - return Err(format!("GitHub release check failed with {status}")); - } - response - .json::() - .await - .map_err(|error| error.to_string()) -} - -fn update_info_from_release(config: &SystemUpdateConfig, release: GitHubRelease) -> UpdateInfoData { - let latest_version = normalize_version_tag(&release.tag_name); - let has_update = release_allowed(config, &release) - && version_is_newer(&config.version, &latest_version).unwrap_or(false); - let unsupported_reason = config.update_support_error(); - let update_supported = unsupported_reason.is_none(); - let notes = release.body.or(release.name); - UpdateInfoData { - current_version: config.version.clone(), - latest_version, - has_update, - deployment_mode: config.deployment_mode.clone(), - deployment_mode_label: deployment_mode_label(&config.deployment_mode).to_string(), - build_type: config.build_type.clone(), - build_type_label: build_type_label(&config.build_type).to_string(), - release_url: release.html_url, - notes, - cached: false, - update_supported, - unsupported_reason, - warning: None, - } -} - -fn release_allowed(config: &SystemUpdateConfig, release: &GitHubRelease) -> bool { - config.update_channel != "stable" || !release.prerelease -} - -fn version_is_newer(current: &str, latest: &str) -> Option { - let current = Version::parse(&normalize_version_tag(current)).ok()?; - let latest = Version::parse(&normalize_version_tag(latest)).ok()?; - Some(latest > current) -} - -async fn perform_release_update( - config: &SystemUpdateConfig, - release: &GitHubRelease, - version: &str, - operation_id: &str, -) -> Result<(), AdminError> { - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("asset"), - "正在选择匹配当前平台的更新包", - ); - let archive = select_release_archive(release, version)?; - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("asset"), - format!( - "已选择更新包 {} ({})", - archive.name, - format_bytes(archive.size) - ), - ); - - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("verify"), - "正在校验下载地址", - ); - validate_download_url(&archive.browser_download_url)?; - if archive.size > MAX_DOWNLOAD_SIZE { - return Err(AdminError::bad_request("Release archive is too large")); - } - let checksum = release - .assets - .iter() - .find(|asset| asset.name == "checksums.txt"); - if let Some(asset) = checksum { - validate_download_url(&asset.browser_download_url)?; - } - - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - 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)) - .and_then(|dir| tempfile_dir_in(&dir, ".codex-proxy-rs-update-")) - .map_err(|error| { - AdminError::internal(format!("Failed to create update temp dir: {error}")) - })?; - let archive_path = temp_dir.join(&archive.name); - - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("download"), - "开始下载更新包", - ); - download_file( - &archive.browser_download_url, - &archive_path, - MAX_DOWNLOAD_SIZE, - Some(DownloadProgress { - operation_id, - total_size: archive.size, - }), - ) - .await?; - emit_update_event( - UpdateLogLevel::Success, - Some(operation_id), - Some("download"), - "更新包下载完成", - ); - - if let Some(checksum) = checksum { - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("checksum"), - "正在校验 checksum", - ); - verify_checksum(&archive_path, &archive.name, &checksum.browser_download_url).await?; - emit_update_event( - UpdateLogLevel::Success, - Some(operation_id), - Some("checksum"), - "checksum 校验通过", - ); - } - - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("extract"), - "正在解压更新包", - ); - let extracted = extract_release_archive(&archive_path, &temp_dir)?; - emit_update_event( - UpdateLogLevel::Success, - Some(operation_id), - Some("extract"), - "更新包解压完成", - ); - - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("replace"), - "正在替换应用文件", - ); - replace_release_files(&exe_path, &config.web_dist_dir, extracted)?; - emit_update_event( - UpdateLogLevel::Success, - Some(operation_id), - Some("replace"), - "应用文件替换完成", - ); - let _ = fs::remove_dir_all(temp_dir); - Ok(()) -} - -async fn rollback_release_update(config: &SystemUpdateConfig) -> Result<(), AdminError> { - let exe_path = config.executable_path()?; - let backup_path = backup_path_for(&exe_path); - if !backup_path.exists() { - return Err(AdminError::conflict("No binary backup found for rollback")); - } - fs::rename(&backup_path, &exe_path) - .map_err(|error| AdminError::internal(format!("Binary rollback failed: {error}")))?; - - let web_backup = backup_path_for(&config.web_dist_dir); - if web_backup.exists() { - if config.web_dist_dir.exists() { - fs::remove_dir_all(&config.web_dist_dir).map_err(|error| { - AdminError::internal(format!("Web rollback cleanup failed: {error}")) - })?; - } - fs::rename(&web_backup, &config.web_dist_dir) - .map_err(|error| AdminError::internal(format!("Web rollback failed: {error}")))?; - } - Ok(()) -} - -fn select_release_archive<'a>( - release: &'a GitHubRelease, - version: &str, -) -> Result<&'a GitHubAsset, AdminError> { - let os_aliases = platform_os_aliases(); - let arch_aliases = platform_arch_aliases(); - let normalized = normalize_version_tag(version); - release - .assets - .iter() - .find(|asset| { - let name = asset.name.as_str(); - name.contains(APP_BINARY_NAME) - && name.contains(&normalized) - && asset_matches_current_platform(name, os_aliases, arch_aliases) - && !name.ends_with(".txt") - }) - .or_else(|| { - release.assets.iter().find(|asset| { - let name = asset.name.as_str(); - asset_matches_current_platform(name, os_aliases, arch_aliases) - && !name.ends_with(".txt") - }) - }) - .ok_or_else(|| { - AdminError::conflict(format!( - "No compatible release archive found for {}/{}", - env::consts::OS, - env::consts::ARCH - )) - }) -} - -fn platform_os_aliases() -> &'static [&'static str] { - match env::consts::OS { - "macos" => &["macos", "darwin"], - "windows" => &["windows", "win32"], - "linux" => &["linux"], - _ => &[env::consts::OS], - } -} - -fn platform_arch_aliases() -> &'static [&'static str] { - match env::consts::ARCH { - "x86_64" => &["x86_64", "amd64"], - "aarch64" => &["aarch64", "arm64"], - _ => &[env::consts::ARCH], - } -} - -fn asset_matches_current_platform(name: &str, os_aliases: &[&str], arch_aliases: &[&str]) -> bool { - os_aliases.iter().any(|os| name.contains(os)) - && arch_aliases.iter().any(|arch| name.contains(arch)) -} - -async fn download_file( - url: &str, - dest: &Path, - max_size: u64, - progress: Option>, -) -> Result<(), AdminError> { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(120)) - .build() - .map_err(|error| AdminError::internal(format!("Failed to create HTTP client: {error}")))?; - let mut response = client - .get(url) - .send() - .await - .map_err(|error| AdminError::bad_gateway(format!("Download failed: {error}")))?; - if !response.status().is_success() { - return Err(AdminError::bad_gateway(format!( - "Download failed with {}", - response.status() - ))); - } - let mut file = fs::File::create(dest).map_err(|error| { - AdminError::internal(format!("Failed to create download file: {error}")) - })?; - let mut downloaded = 0_u64; - let mut next_progress = 10_u64; - while let Some(chunk) = response - .chunk() - .await - .map_err(|error| AdminError::bad_gateway(format!("Download stream failed: {error}")))? - { - downloaded += chunk.len() as u64; - if downloaded > max_size { - return Err(AdminError::bad_request("Download exceeds max allowed size")); - } - file.write_all(&chunk) - .map_err(|error| AdminError::internal(format!("Failed to write download: {error}")))?; - if let Some(progress) = progress { - let percent = download_percent(downloaded, progress.total_size); - if progress.total_size > 0 - && (percent >= next_progress || downloaded >= progress.total_size) - { - emit_update_event( - UpdateLogLevel::Info, - Some(progress.operation_id), - Some("download"), - format!( - "已下载 {} / {} ({percent}%)", - format_bytes(downloaded), - format_bytes(progress.total_size) - ), - ); - next_progress = percent.saturating_add(10); - } - } - } - Ok(()) -} - -async fn verify_checksum( - file_path: &Path, - file_name: &str, - checksum_url: &str, -) -> Result<(), AdminError> { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(|error| AdminError::internal(format!("Failed to create HTTP client: {error}")))?; - let body = client - .get(checksum_url) - .send() - .await - .map_err(|error| AdminError::bad_gateway(format!("Checksum download failed: {error}")))? - .text() - .await - .map_err(|error| AdminError::bad_gateway(format!("Checksum read failed: {error}")))?; - - let expected = body.lines().find_map(|line| { - let mut parts = line.split_whitespace(); - let hash = parts.next()?; - let name = parts.next()?; - (Path::new(name).file_name()?.to_string_lossy() == file_name).then(|| hash.to_string()) - }); - let expected = expected.ok_or_else(|| AdminError::bad_gateway("Checksum not found"))?; - let actual = sha256_file(file_path)?; - if expected.eq_ignore_ascii_case(&actual) { - Ok(()) - } else { - Err(AdminError::bad_gateway("Checksum mismatch")) - } -} - -fn sha256_file(path: &Path) -> Result { - let mut file = fs::File::open(path) - .map_err(|error| AdminError::internal(format!("Failed to open checksum file: {error}")))?; - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 8192]; - loop { - let read = file.read(&mut buffer).map_err(|error| { - AdminError::internal(format!("Failed to read checksum file: {error}")) - })?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - } - Ok(hex::encode(hasher.finalize())) -} - -fn extract_release_archive( - archive_path: &Path, - temp_dir: &Path, -) -> Result { - let file = fs::File::open(archive_path) - .map_err(|error| AdminError::internal(format!("Failed to open archive: {error}")))?; - let decoder = GzDecoder::new(file); - let mut archive = Archive::new(decoder); - let extract_dir = temp_dir.join("extracted"); - fs::create_dir_all(&extract_dir) - .map_err(|error| AdminError::internal(format!("Failed to create extract dir: {error}")))?; - let binary_path = temp_dir.join(APP_BINARY_NAME); - let web_dist_dir = temp_dir.join("web-dist"); - let mut found_binary = false; - let mut found_web = false; - - for entry in archive - .entries() - .map_err(|error| AdminError::internal(format!("Failed to read archive: {error}")))? - { - let mut entry = entry - .map_err(|error| AdminError::internal(format!("Invalid archive entry: {error}")))?; - if !entry.header().entry_type().is_file() { - continue; - } - let path = entry - .path() - .map_err(|error| AdminError::internal(format!("Invalid archive path: {error}")))? - .to_path_buf(); - if unsafe_archive_path(&path) { - return Err(AdminError::bad_request("Unsafe archive path")); - } - - if path.file_name().is_some_and(|name| name == APP_BINARY_NAME) { - entry.unpack(&binary_path).map_err(|error| { - AdminError::internal(format!("Failed to extract binary: {error}")) - })?; - found_binary = true; - continue; - } - - if let Some(relative) = web_dist_relative_path(&path) { - let target = web_dist_dir.join(relative); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|error| { - AdminError::internal(format!("Failed to create web asset dir: {error}")) - })?; - } - entry.unpack(&target).map_err(|error| { - AdminError::internal(format!("Failed to extract web asset: {error}")) - })?; - found_web = true; - } - } - - if !found_binary { - return Err(AdminError::bad_request( - "Release archive does not contain codex-proxy-rs", - )); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755)) - .map_err(|error| AdminError::internal(format!("Failed to chmod binary: {error}")))?; - } - - Ok(ExtractedRelease { - binary_path, - web_dist_dir: found_web.then_some(web_dist_dir), - }) -} - -fn replace_release_files( - exe_path: &Path, - web_dist_dir: &Path, - extracted: ExtractedRelease, -) -> Result<(), AdminError> { - let web_backup = backup_path_for(web_dist_dir); - let web_replaced = if let Some(new_web) = extracted.web_dist_dir { - replace_dir(web_dist_dir, &web_backup, &new_web)?; - true - } else { - false - }; - - let binary_backup = backup_path_for(exe_path); - if binary_backup.exists() { - fs::remove_file(&binary_backup).map_err(|error| { - AdminError::internal(format!("Failed to remove old binary backup: {error}")) - })?; - } - if let Err(error) = move_file(exe_path, &binary_backup) { - if web_replaced { - let _ = restore_dir(web_dist_dir, &web_backup); - } - return Err(AdminError::internal(format!( - "Binary backup failed: {error}" - ))); - } - if let Err(error) = move_file(&extracted.binary_path, exe_path) { - let _ = fs::remove_file(exe_path); - let _ = move_file(&binary_backup, exe_path); - if web_replaced { - let _ = restore_dir(web_dist_dir, &web_backup); - } - return Err(AdminError::internal(format!( - "Binary replace failed: {error}" - ))); - } - Ok(()) -} - -fn replace_dir(current: &Path, backup: &Path, replacement: &Path) -> Result<(), AdminError> { - if backup.exists() { - fs::remove_dir_all(backup).map_err(|error| { - AdminError::internal(format!("Failed to remove old web backup: {error}")) - })?; - } - if current.exists() { - move_dir(current, backup).map_err(|error| { - AdminError::internal(format!("Failed to backup web assets: {error}")) - })?; - } - if let Err(error) = move_dir(replacement, current) { - if backup.exists() { - let _ = restore_dir(current, backup); - } - return Err(AdminError::internal(format!( - "Failed to replace web assets: {error}" - ))); - } - Ok(()) -} - -fn restore_dir(current: &Path, backup: &Path) -> io::Result<()> { - if current.exists() { - fs::remove_dir_all(current)?; - } - if backup.exists() { - move_dir(backup, current)?; - } - Ok(()) -} - -fn move_file(from: &Path, to: &Path) -> io::Result<()> { - match fs::rename(from, to) { - Ok(()) => Ok(()), - Err(error) if is_cross_device_link(&error) => { - fs::copy(from, to)?; - fs::remove_file(from) - } - Err(error) => Err(error), - } -} - -fn move_dir(from: &Path, to: &Path) -> io::Result<()> { - match fs::rename(from, to) { - Ok(()) => Ok(()), - Err(error) if is_cross_device_link(&error) => { - copy_dir_all(from, to)?; - fs::remove_dir_all(from) - } - Err(error) => Err(error), - } -} - -fn copy_dir_all(from: &Path, to: &Path) -> io::Result<()> { - fs::create_dir_all(to)?; - for entry in fs::read_dir(from)? { - let entry = entry?; - let file_type = entry.file_type()?; - let target = to.join(entry.file_name()); - if file_type.is_dir() { - copy_dir_all(&entry.path(), &target)?; - } else if file_type.is_file() { - fs::copy(entry.path(), &target)?; - fs::set_permissions(&target, entry.metadata()?.permissions())?; - } else { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - format!("unsupported file type in {}", entry.path().display()), - )); - } - } - Ok(()) -} - -fn is_cross_device_link(error: &io::Error) -> bool { - error.kind() == io::ErrorKind::CrossesDevices -} - -fn backup_path_for(path: &Path) -> PathBuf { - let mut backup = path.as_os_str().to_os_string(); - backup.push(".backup"); - PathBuf::from(backup) -} - -fn web_dist_relative_path(path: &Path) -> Option { - let components = path - .components() - .filter_map(|component| match component { - Component::Normal(value) => Some(value.to_string_lossy().to_string()), - _ => None, - }) - .collect::>(); - for index in 0..components.len().saturating_sub(1) { - if components[index] == "web" - && components - .get(index + 1) - .is_some_and(|value| value == "dist") - { - return Some(components[index + 2..].iter().collect()); - } - if components[index] == "dist" { - return Some(components[index + 1..].iter().collect()); - } - } - None -} - -fn unsafe_archive_path(path: &Path) -> bool { - path.is_absolute() - || path.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::Prefix(_) | Component::RootDir - ) - }) -} - -fn validate_download_url(raw_url: &str) -> Result<(), AdminError> { - let url = reqwest::Url::parse(raw_url) - .map_err(|error| AdminError::bad_request(format!("Invalid download URL: {error}")))?; - if url.scheme() == "http" - && env_string("CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS").as_deref() == Some("true") - { - return Ok(()); - } - if url.scheme() != "https" { - return Err(AdminError::bad_request( - "Only HTTPS release downloads are allowed", - )); - } - let host = url - .host_str() - .ok_or_else(|| AdminError::bad_request("Download URL is missing host"))?; - let allowed = host == "github.com" - || host.ends_with(".github.com") - || host == "objects.githubusercontent.com" - || host.ends_with(".objects.githubusercontent.com"); - if allowed { - Ok(()) - } else { - Err(AdminError::bad_request(format!( - "Download host is not allowed: {host}" - ))) - } -} - -fn tempfile_dir_in(parent: &Path, prefix: &str) -> io::Result { - for attempt in 0..100 { - let path = parent.join(format!( - "{prefix}{}-{attempt}", - Utc::now().timestamp_nanos_opt().unwrap_or_default() - )); - match fs::create_dir(&path) { - Ok(()) => return Ok(path), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - } - } - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "failed to create unique temp dir", - )) -} - -fn read_state(path: &Path) -> Result { - if !path.exists() { - return Ok(SystemUpdateStatusData::default()); - } - let data = fs::read_to_string(path) - .map_err(|error| AdminError::internal(format!("Failed to read update state: {error}")))?; - serde_json::from_str(&data) - .map_err(|error| AdminError::internal(format!("Invalid update state: {error}"))) -} - -fn write_state(path: &Path, state: &SystemUpdateStatusData) -> Result<(), AdminError> { - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent).map_err(|error| { - AdminError::internal(format!("Failed to create update state directory: {error}")) - })?; - } - let data = serde_json::to_string_pretty(state) - .map_err(|error| AdminError::internal(format!("Failed to encode update state: {error}")))?; - fs::write(path, data) - .map_err(|error| AdminError::internal(format!("Failed to write update state: {error}"))) -} - -fn set_operation_running( - path: &Path, - operation_id: &str, - kind: SystemOperationKind, - version: Option<&str>, -) -> Result<(), AdminError> { - let mut state = read_state(path)?; - state.operation = SystemOperationState { - operation_id: Some(operation_id.to_string()), - kind: Some(kind), - status: SystemOperationStatus::Running, - target_version: version.map(ToString::to_string), - message: Some("operation running".to_string()), - error: None, - started_at: Some(Utc::now().to_rfc3339()), - finished_at: None, - }; - write_state(path, &state) -} - -fn finish_operation( - path: &Path, - operation_id: &str, - kind: SystemOperationKind, - version: Option, - error: Option, -) { - let mut state = match read_state(path) { - Ok(state) => state, - Err(error) => { - eprintln!("failed to read update state: {error}"); - return; - } - }; - if state.operation.operation_id.as_deref() != Some(operation_id) { - return; - } - - if let Some(error) = error { - state.operation.status = SystemOperationStatus::Failed; - state.operation.message = Some("operation failed".to_string()); - state.operation.error = Some(error); - } else { - state.operation.status = SystemOperationStatus::Succeeded; - state.operation.message = Some("operation succeeded".to_string()); - state.operation.error = None; - if kind == SystemOperationKind::Update { - state.previous_version = state.current_version.take(); - state.current_version = version.clone(); - } - state.operation.target_version = version; - } - state.operation.finished_at = Some(Utc::now().to_rfc3339()); - - if let Err(error) = write_state(path, &state) { - eprintln!("failed to write update operation state: {error}"); - } -} - -fn env_string(key: &str) -> Option { - env::var(key) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn runtime_build_info_override(key: &str) -> Option { - if cfg!(debug_assertions) { - env_string(key) - } else { - None - } -} - -fn build_version() -> String { - runtime_build_info_override("CPR_VERSION").unwrap_or_else(|| { - option_env!("CPR_VERSION") - .unwrap_or(env!("CARGO_PKG_VERSION")) - .to_string() - }) -} - -fn build_git_sha() -> String { - runtime_build_info_override("CPR_GIT_SHA") - .unwrap_or_else(|| option_env!("CPR_GIT_SHA").unwrap_or("unknown").to_string()) -} - -fn build_time() -> String { - runtime_build_info_override("CPR_BUILD_TIME").unwrap_or_else(|| { - option_env!("CPR_BUILD_TIME") - .unwrap_or("unknown") - .to_string() - }) -} - -fn build_type() -> String { - runtime_build_info_override("CPR_BUILD_TYPE").unwrap_or_else(|| { - option_env!("CPR_BUILD_TYPE") - .unwrap_or("source") - .to_string() - }) -} - -fn update_event_sender() -> &'static broadcast::Sender { - UPDATE_EVENT_SENDER.get_or_init(|| { - let (sender, _receiver) = broadcast::channel(256); - sender - }) -} - -fn emit_update_event( - level: UpdateLogLevel, - operation_id: Option<&str>, - step: Option<&str>, - message: impl Into, -) { - let now = Utc::now(); - let event = SystemUpdateEvent { - id: format!( - "update-log-{}", - now.timestamp_nanos_opt() - .unwrap_or_else(|| now.timestamp_millis()) - ), - operation_id: operation_id.map(ToString::to_string), - level, - step: step.map(ToString::to_string), - message: message.into(), - at: now.to_rfc3339(), - }; - let _ = update_event_sender().send(event); -} - -fn download_percent(downloaded: u64, total_size: u64) -> u64 { - if total_size == 0 { - return 0; - } - downloaded - .saturating_mul(100) - .saturating_div(total_size) - .min(100) -} - -fn format_bytes(bytes: u64) -> String { - const KIB: u64 = 1024; - const MIB: u64 = KIB * 1024; - if bytes >= MIB { - return format!("{:.1} MiB", bytes as f64 / MIB as f64); - } - if bytes >= KIB { - return format!("{:.1} KiB", bytes as f64 / KIB as f64); - } - format!("{bytes} B") -} - -fn deployment_mode_label(value: &str) -> &str { - match value { - "docker" => "Docker", - "source" => "源码部署", - "binary" => "二进制部署", - _ => value, - } -} - -fn build_type_label(value: &str) -> &str { - match value { - "release" => "正式构建", - "source" => "源码构建", - "dev" => "开发构建", - _ => value, - } -} - -fn normalize_version_tag(version: &str) -> String { - version.trim().trim_start_matches('v').to_string() -} - -fn operation_id(kind: &str) -> String { - format!("sysop-{kind}-{}", Utc::now().timestamp_millis()) -} - -impl OperationLock { - fn acquire(path: &Path) -> Result { - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent).map_err(|error| { - AdminError::internal(format!("Failed to create update lock directory: {error}")) - })?; - } - - match Self::try_create(path) { - Ok(()) => Ok(Self { - path: path.to_path_buf(), - }), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { - if stale_lock(path)? { - fs::remove_file(path).map_err(|error| { - AdminError::internal(format!("Failed to remove stale update lock: {error}")) - })?; - Self::try_create(path).map_err(|error| { - AdminError::internal(format!("Failed to create update lock: {error}")) - })?; - return Ok(Self { - path: path.to_path_buf(), - }); - } - Err(AdminError::conflict("System update already running")) - } - Err(error) => Err(AdminError::internal(format!( - "Failed to create update lock: {error}" - ))), - } - } - - fn try_create(path: &Path) -> io::Result<()> { - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(path)?; - writeln!( - file, - "pid={}\ncreated_at={}", - std::process::id(), - Utc::now().to_rfc3339() - ) - } -} - -impl Drop for OperationLock { - fn drop(&mut self) { - if let Err(error) = fs::remove_file(&self.path) { - if error.kind() != io::ErrorKind::NotFound { - eprintln!("failed to remove update lock: {error}"); - } - } - } -} - -fn stale_lock(path: &Path) -> Result { - let metadata = fs::metadata(path) - .map_err(|error| AdminError::internal(format!("Failed to read update lock: {error}")))?; - let modified = metadata.modified().map_err(|error| { - AdminError::internal(format!("Failed to read update lock timestamp: {error}")) - })?; - modified - .elapsed() - .map(|age| age > Duration::from_secs(30 * 60)) - .map_err(|error| { - AdminError::internal(format!("Failed to calculate update lock age: {error}")) - }) -} diff --git a/backend/src/admin/system/state.rs b/backend/src/admin/system/state.rs new file mode 100644 index 000000000..13b70b627 --- /dev/null +++ b/backend/src/admin/system/state.rs @@ -0,0 +1,234 @@ +//! 系统自更新状态与文件锁。 + +use std::{ + fs, io, + io::Write, + path::{Path, PathBuf}, + time::Duration, +}; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::admin::response::AdminError; + +#[derive(Debug, Deserialize, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SystemUpdateStatusData { + pub previous_version: Option, + pub current_version: Option, + #[serde(default)] + pub operation: SystemOperationState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum SystemOperationKind { + Update, + Rollback, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +enum SystemOperationStatus { + #[default] + Idle, + Running, + Succeeded, + Failed, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SystemOperationState { + operation_id: Option, + kind: Option, + status: SystemOperationStatus, + target_version: Option, + message: Option, + error: Option, + started_at: Option, + finished_at: Option, +} + +impl Default for SystemOperationState { + fn default() -> Self { + Self { + operation_id: None, + kind: None, + status: SystemOperationStatus::Idle, + target_version: None, + message: None, + error: None, + started_at: None, + finished_at: None, + } + } +} + +#[derive(Debug)] +pub(crate) struct OperationLock { + path: PathBuf, +} + +pub(crate) fn read_state(path: &Path) -> Result { + if !path.exists() { + return Ok(SystemUpdateStatusData::default()); + } + let data = + fs::read_to_string(path).map_err(internal_error_with("Failed to read update state"))?; + serde_json::from_str(&data).map_err(internal_error_with("Invalid update state")) +} + +fn write_state(path: &Path, state: &SystemUpdateStatusData) -> Result<(), AdminError> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).map_err(internal_error_with( + "Failed to create update state directory", + ))?; + } + let data = serde_json::to_string_pretty(state) + .map_err(internal_error_with("Failed to encode update state"))?; + fs::write(path, data).map_err(internal_error_with("Failed to write update state")) +} + +pub(crate) fn set_operation_running( + path: &Path, + operation_id: &str, + kind: SystemOperationKind, + version: Option<&str>, +) -> Result<(), AdminError> { + let mut state = read_state(path)?; + state.operation = SystemOperationState { + operation_id: Some(operation_id.to_string()), + kind: Some(kind), + status: SystemOperationStatus::Running, + target_version: version.map(ToString::to_string), + message: Some("operation running".to_string()), + error: None, + started_at: Some(Utc::now().to_rfc3339()), + finished_at: None, + }; + write_state(path, &state) +} + +pub(crate) fn finish_operation( + path: &Path, + operation_id: &str, + kind: SystemOperationKind, + version: Option, + error: Option, +) { + let mut state = match read_state(path) { + Ok(state) => state, + Err(error) => { + eprintln!("failed to read update state: {error}"); + return; + } + }; + if state.operation.operation_id.as_deref() != Some(operation_id) { + return; + } + + if let Some(error) = error { + state.operation.status = SystemOperationStatus::Failed; + state.operation.message = Some("operation failed".to_string()); + state.operation.error = Some(error); + } else { + state.operation.status = SystemOperationStatus::Succeeded; + state.operation.message = Some("operation succeeded".to_string()); + state.operation.error = None; + if kind == SystemOperationKind::Update { + state.previous_version = state.current_version.take(); + state.current_version = version.clone(); + } + state.operation.target_version = version; + } + state.operation.finished_at = Some(Utc::now().to_rfc3339()); + + if let Err(error) = write_state(path, &state) { + eprintln!("failed to write update operation state: {error}"); + } +} + +pub(crate) fn operation_id(kind: &str) -> String { + format!("sysop-{kind}-{}", Utc::now().timestamp_millis()) +} + +impl OperationLock { + pub(crate) fn acquire(path: &Path) -> Result { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).map_err(internal_error_with( + "Failed to create update lock directory", + ))?; + } + + match Self::try_create(path) { + Ok(()) => Ok(Self { + path: path.to_path_buf(), + }), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + if stale_lock(path)? { + fs::remove_file(path) + .map_err(internal_error_with("Failed to remove stale update lock"))?; + Self::try_create(path) + .map_err(internal_error_with("Failed to create update lock"))?; + return Ok(Self { + path: path.to_path_buf(), + }); + } + Err(AdminError::conflict("System update already running")) + } + Err(error) => Err(internal_error("Failed to create update lock", error)), + } + } + + fn try_create(path: &Path) -> io::Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)?; + writeln!( + file, + "pid={}\ncreated_at={}", + std::process::id(), + Utc::now().to_rfc3339() + ) + } +} + +impl Drop for OperationLock { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.path) { + if error.kind() != io::ErrorKind::NotFound { + eprintln!("failed to remove update lock: {error}"); + } + } + } +} + +fn stale_lock(path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(internal_error_with("Failed to read update lock"))?; + let modified = metadata + .modified() + .map_err(internal_error_with("Failed to read update lock timestamp"))?; + modified + .elapsed() + .map(|age| age > Duration::from_secs(30 * 60)) + .map_err(internal_error_with("Failed to calculate update lock age")) +} + +fn internal_error(context: &str, error: impl std::fmt::Display) -> AdminError { + AdminError::internal(format!("{context}: {error}")) +} + +fn internal_error_with( + context: &'static str, +) -> impl FnOnce(E) -> AdminError { + move |error| internal_error(context, error) +} diff --git a/backend/src/admin/system/updater.rs b/backend/src/admin/system/updater.rs new file mode 100644 index 000000000..e32a62d34 --- /dev/null +++ b/backend/src/admin/system/updater.rs @@ -0,0 +1,665 @@ +//! 管理端系统版本与在线更新路由。 + +use std::{ + convert::Infallible, + env, fmt, fs, io, + path::{Path, PathBuf}, + sync::OnceLock, + time::Duration, +}; + +use axum::{ + extract::Query, + http::StatusCode, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, + }, +}; +use chrono::Utc; +use futures::Stream; +use serde::{Deserialize, Serialize}; +use tokio::sync::{broadcast, Mutex}; + +use crate::{ + admin::auth::session::AdminAuth, + admin::response::{AdminEnvelope, AdminError, AdminResponse}, + runtime::shutdown::request_shutdown, +}; + +use super::state::{ + finish_operation, operation_id, read_state, set_operation_running, OperationLock, + SystemOperationKind, +}; + +mod download; +use download::{ + download_file, validate_download_url, validate_github_api_base, verify_checksum, + DownloadProgress, +}; +mod archive; +use archive::{extract_release_archive, replace_release_files, rollback_release_update}; +mod release; +use release::{ + check_latest_release, fetch_latest_release, select_release_archive, update_info_from_release, + GitHubRelease, +}; + +const APP_BINARY_NAME: &str = "codex-proxy-rs"; +const DEFAULT_WEB_DIST_DIR: &str = "/app/web/dist"; +const GITHUB_API_BASE: &str = "https://api.github.com/repos"; +const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; + +static SYSTEM_OPERATION_LOCK: Mutex<()> = Mutex::const_new(()); +static UPDATE_EVENT_SENDER: OnceLock> = OnceLock::new(); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CheckUpdateQuery { + force: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct VersionData { + version: String, + git_sha: String, + build_time: String, + deployment_mode: String, + deployment_mode_label: String, + update_channel: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct UpdateStartedData { + operation_id: String, + deployment_mode: String, + message: String, + need_restart: bool, + target_version: String, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +enum UpdateLogLevel { + Info, + Success, + Warning, + Error, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct SystemUpdateEvent { + id: String, + operation_id: Option, + level: UpdateLogLevel, + step: Option, + message: String, + at: String, +} + +#[derive(Debug, Clone)] +struct SystemUpdateConfig { + version: String, + git_sha: String, + build_time: String, + deployment_mode: String, + build_type: String, + update_channel: String, + update_repository: Option, + github_api_base: String, + executable_path: Option, + web_dist_dir: PathBuf, + update_state_file: PathBuf, + update_lock_file: PathBuf, +} + +/// `GET /api/admin/system/version` +pub(crate) async fn version(_auth: AdminAuth) -> Result { + let config = SystemUpdateConfig::from_env(); + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(config.version_data()), + )) +} + +/// `GET /api/admin/system/check-updates` +pub(crate) async fn check_updates( + _auth: AdminAuth, + Query(query): Query, +) -> Result { + let config = SystemUpdateConfig::from_env(); + let info = check_latest_release(&config, query.force.unwrap_or(false)).await; + Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(info))) +} + +/// `GET /api/admin/system/update-events` +pub(crate) async fn update_event_stream( + _auth: AdminAuth, +) -> Result>>, AdminError> { + let receiver = update_event_sender().subscribe(); + let stream = futures::stream::unfold(receiver, |mut receiver| async move { + loop { + match receiver.recv().await { + Ok(message) => { + let id = message.id.clone(); + let data = serde_json::to_string(&message).unwrap_or_else(|_| "{}".to_string()); + let event = Event::default().event("update").id(id).data(data); + return Some((Ok(event), receiver)); + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }); + + Ok(Sse::new(stream).keep_alive(KeepAlive::default())) +} + +/// `POST /api/admin/system/update` +pub(crate) async fn perform_update(_auth: AdminAuth) -> Result { + let _guard = SYSTEM_OPERATION_LOCK + .try_lock() + .map_err(|_| AdminError::conflict("System update already running"))?; + let config = SystemUpdateConfig::from_env(); + if let Some(reason) = config.update_support_error() { + emit_update_event( + UpdateLogLevel::Error, + None, + Some("preflight"), + reason.clone(), + ); + return Err(AdminError::conflict(reason)); + } + + emit_update_event( + UpdateLogLevel::Info, + None, + Some("release"), + "正在获取最新 Release 信息", + ); + let release = fetch_latest_release( + &config.github_api_base, + config + .update_repository + .as_deref() + .ok_or_else(|| AdminError::conflict("Update checks require CPR_UPDATE_REPOSITORY"))?, + ) + .await + .map_err(|error| { + emit_update_event(UpdateLogLevel::Error, None, Some("release"), error.clone()); + AdminError::bad_gateway(error) + })?; + let info = update_info_from_release(&config, release.clone()); + if !info.has_update { + emit_update_event( + UpdateLogLevel::Warning, + None, + Some("release"), + "当前版本已是最新", + ); + return Err(AdminError::conflict("Already up to date")); + } + let target_version = info.latest_version.clone(); + let operation_id = operation_id("update"); + let lock = OperationLock::acquire(&config.update_lock_file)?; + set_operation_running( + &config.update_state_file, + &operation_id, + SystemOperationKind::Update, + Some(&target_version), + )?; + + emit_update_event( + UpdateLogLevel::Info, + Some(&operation_id), + Some("prepare"), + format!("准备更新到 v{target_version}"), + ); + let result = perform_release_update(&config, &release, &target_version, &operation_id).await; + if let Err(error) = &result { + emit_update_event( + UpdateLogLevel::Error, + Some(&operation_id), + Some("failed"), + error.to_string(), + ); + } else { + emit_update_event( + UpdateLogLevel::Success, + Some(&operation_id), + Some("done"), + "更新文件已替换,等待服务重启生效", + ); + } + finish_operation( + &config.update_state_file, + &operation_id, + SystemOperationKind::Update, + result.as_ref().ok().map(|_| target_version.clone()), + result.as_ref().err().map(ToString::to_string), + ); + drop(lock); + result?; + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(UpdateStartedData { + operation_id, + deployment_mode: config.deployment_mode, + message: "更新完成,请重启服务。".to_string(), + need_restart: true, + target_version, + }), + )) +} + +/// `GET /api/admin/system/update-status` +pub(crate) async fn update_status(_auth: AdminAuth) -> Result { + let config = SystemUpdateConfig::from_env(); + let status = read_state(&config.update_state_file)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(status), + )) +} + +/// `POST /api/admin/system/rollback` +pub(crate) async fn rollback(_auth: AdminAuth) -> Result { + let _guard = SYSTEM_OPERATION_LOCK + .try_lock() + .map_err(|_| AdminError::conflict("系统操作正在执行中"))?; + let config = SystemUpdateConfig::from_env(); + if let Some(reason) = config.update_support_error() { + return Err(AdminError::conflict(reason)); + } + + let operation_id = operation_id("rollback"); + let lock = OperationLock::acquire(&config.update_lock_file)?; + set_operation_running( + &config.update_state_file, + &operation_id, + SystemOperationKind::Rollback, + None, + )?; + let result = rollback_release_update(&config).await; + finish_operation( + &config.update_state_file, + &operation_id, + SystemOperationKind::Rollback, + None, + result.as_ref().err().map(ToString::to_string), + ); + drop(lock); + result?; + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({ + "message": "回滚完成,请重启服务。", + "needRestart": true, + "operationId": operation_id + })), + )) +} + +/// `POST /api/admin/system/restart` +pub(crate) async fn restart(_auth: AdminAuth) -> Result { + if env_string("CPR_ENABLE_SELF_RESTART").as_deref() != Some("true") { + return Err(AdminError::conflict( + "自重启未启用,请设置 CPR_ENABLE_SELF_RESTART=true", + )); + } + + tokio::spawn(async { + tokio::time::sleep(Duration::from_millis(500)).await; + request_shutdown(); + }); + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({ + "message": "已安排重启", + "operationId": operation_id("restart") + })), + )) +} + +impl SystemUpdateConfig { + fn from_env() -> Self { + let update_repository = env_string("CPR_UPDATE_REPOSITORY"); + let state_file = env_string("CPR_UPDATE_STATE_FILE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/app/data/update-state.json")); + let update_lock_file = env_string("CPR_UPDATE_LOCK_FILE") + .map(PathBuf::from) + .unwrap_or_else(|| state_file.with_extension("lock")); + Self { + version: build_version(), + git_sha: build_git_sha(), + build_time: build_time(), + deployment_mode: env_string("CPR_DEPLOYMENT_MODE") + .unwrap_or_else(|| "source".to_string()), + build_type: build_type(), + update_channel: env_string("CPR_UPDATE_CHANNEL") + .unwrap_or_else(|| "stable".to_string()), + update_repository, + github_api_base: env_string("CPR_GITHUB_API_BASE") + .unwrap_or_else(|| GITHUB_API_BASE.to_string()), + executable_path: env_string("CPR_UPDATE_EXE_PATH").map(PathBuf::from), + web_dist_dir: env_string("CPR_WEB_DIST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_WEB_DIST_DIR)), + update_state_file: state_file, + update_lock_file, + } + } + + fn version_data(&self) -> VersionData { + VersionData { + version: self.version.clone(), + git_sha: self.git_sha.clone(), + build_time: self.build_time.clone(), + deployment_mode: self.deployment_mode.clone(), + deployment_mode_label: deployment_mode_label(&self.deployment_mode).to_string(), + update_channel: self.update_channel.clone(), + } + } + + fn update_support_error(&self) -> Option { + if self.build_type != "release" { + return Some("一键更新需要正式构建包".to_string()); + } + if self.update_repository.is_none() { + return Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_string()); + } + if let Err(error) = validate_github_api_base(&self.github_api_base) { + return Some(error); + } + None + } + + fn release_cache_key(&self) -> String { + format!( + "{}|{}|{}|{}|{}", + self.update_repository.as_deref().unwrap_or_default(), + self.version, + self.deployment_mode, + self.build_type, + self.update_channel, + ) + } + + fn executable_path(&self) -> Result { + if let Some(path) = &self.executable_path { + return Ok(path.clone()); + } + env::current_exe() + .and_then(fs::canonicalize) + .map_err(internal_error_with("Failed to resolve executable")) + } +} + +fn internal_error(context: &str, error: impl fmt::Display) -> AdminError { + AdminError::internal(format!("{context}: {error}")) +} + +fn internal_error_with(context: &'static str) -> impl FnOnce(E) -> AdminError { + move |error| internal_error(context, error) +} + +fn bad_request_with(context: &'static str) -> impl FnOnce(E) -> AdminError { + move |error| AdminError::bad_request(format!("{context}: {error}")) +} + +fn bad_gateway_with(context: &'static str) -> impl FnOnce(E) -> AdminError { + move |error| AdminError::bad_gateway(format!("{context}: {error}")) +} + +async fn perform_release_update( + config: &SystemUpdateConfig, + release: &GitHubRelease, + version: &str, + operation_id: &str, +) -> Result<(), AdminError> { + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("asset"), + "正在选择匹配当前平台的更新包", + ); + let archive = select_release_archive(release, version)?; + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("asset"), + format!( + "已选择更新包 {} ({})", + archive.name, + format_bytes(archive.size) + ), + ); + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("verify"), + "正在校验下载地址", + ); + validate_download_url(&archive.browser_download_url, &config.github_api_base)?; + if archive.size > MAX_DOWNLOAD_SIZE { + return Err(AdminError::bad_request("Release archive is too large")); + } + let checksum = release + .assets + .iter() + .find(|asset| asset.name == "checksums.txt") + .ok_or_else(|| AdminError::bad_gateway("Release checksums.txt is required"))?; + validate_download_url(&checksum.browser_download_url, &config.github_api_base)?; + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + 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)) + .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); + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("download"), + "开始下载更新包", + ); + download_file( + &archive.browser_download_url, + &archive_path, + MAX_DOWNLOAD_SIZE, + Some(DownloadProgress { + operation_id, + total_size: archive.size, + }), + ) + .await?; + emit_update_event( + UpdateLogLevel::Success, + Some(operation_id), + Some("download"), + "更新包下载完成", + ); + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("checksum"), + "正在校验 checksum", + ); + verify_checksum(&archive_path, &archive.name, &checksum.browser_download_url).await?; + emit_update_event( + UpdateLogLevel::Success, + Some(operation_id), + Some("checksum"), + "checksum 校验通过", + ); + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("extract"), + "正在解压更新包", + ); + let extracted = extract_release_archive(&archive_path, &temp_dir)?; + emit_update_event( + UpdateLogLevel::Success, + Some(operation_id), + Some("extract"), + "更新包解压完成", + ); + + emit_update_event( + UpdateLogLevel::Info, + Some(operation_id), + Some("replace"), + "正在替换应用文件", + ); + replace_release_files(&exe_path, &config.web_dist_dir, extracted)?; + emit_update_event( + UpdateLogLevel::Success, + Some(operation_id), + Some("replace"), + "应用文件替换完成", + ); + let _ = fs::remove_dir_all(temp_dir); + Ok(()) +} + +fn tempfile_dir_in(parent: &Path, prefix: &str) -> io::Result { + for attempt in 0..100 { + let path = parent.join(format!( + "{prefix}{}-{attempt}", + Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + match fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to create unique temp dir", + )) +} + +fn env_string(key: &str) -> Option { + env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn runtime_build_info_override(key: &str) -> Option { + if cfg!(debug_assertions) { + env_string(key) + } else { + None + } +} + +fn build_version() -> String { + runtime_build_info_override("CPR_VERSION").unwrap_or_else(|| { + option_env!("CPR_VERSION") + .unwrap_or(env!("CARGO_PKG_VERSION")) + .to_string() + }) +} + +fn build_git_sha() -> String { + runtime_build_info_override("CPR_GIT_SHA") + .unwrap_or_else(|| option_env!("CPR_GIT_SHA").unwrap_or("unknown").to_string()) +} + +fn build_time() -> String { + runtime_build_info_override("CPR_BUILD_TIME").unwrap_or_else(|| { + option_env!("CPR_BUILD_TIME") + .unwrap_or("unknown") + .to_string() + }) +} + +fn build_type() -> String { + runtime_build_info_override("CPR_BUILD_TYPE").unwrap_or_else(|| { + option_env!("CPR_BUILD_TYPE") + .unwrap_or("source") + .to_string() + }) +} + +fn update_event_sender() -> &'static broadcast::Sender { + UPDATE_EVENT_SENDER.get_or_init(|| { + let (sender, _receiver) = broadcast::channel(256); + sender + }) +} + +fn emit_update_event( + level: UpdateLogLevel, + operation_id: Option<&str>, + step: Option<&str>, + message: impl Into, +) { + let now = Utc::now(); + let event = SystemUpdateEvent { + id: format!( + "update-log-{}", + now.timestamp_nanos_opt() + .unwrap_or_else(|| now.timestamp_millis()) + ), + operation_id: operation_id.map(ToString::to_string), + level, + step: step.map(ToString::to_string), + message: message.into(), + at: now.to_rfc3339(), + }; + let _ = update_event_sender().send(event); +} + +fn format_bytes(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + if bytes >= MIB { + return format!("{:.1} MiB", bytes as f64 / MIB as f64); + } + if bytes >= KIB { + return format!("{:.1} KiB", bytes as f64 / KIB as f64); + } + format!("{bytes} B") +} + +fn deployment_mode_label(value: &str) -> &str { + match value { + "docker" => "Docker", + "source" => "源码部署", + "binary" => "二进制部署", + _ => value, + } +} + +fn build_type_label(value: &str) -> &str { + match value { + "release" => "正式构建", + "source" => "源码构建", + "dev" => "开发构建", + _ => value, + } +} diff --git a/backend/src/admin/system/updater/archive.rs b/backend/src/admin/system/updater/archive.rs new file mode 100644 index 000000000..b402e3d58 --- /dev/null +++ b/backend/src/admin/system/updater/archive.rs @@ -0,0 +1,262 @@ +//! 自更新归档解压、文件替换与回滚。 + +use std::{ + fs, io, + path::{Component, Path, PathBuf}, +}; + +use flate2::read::GzDecoder; +use tar::Archive; + +use crate::admin::response::AdminError; + +use super::{internal_error, internal_error_with, SystemUpdateConfig, APP_BINARY_NAME}; + +#[derive(Debug)] +pub(super) struct ExtractedRelease { + binary_path: PathBuf, + web_dist_dir: Option, +} + +pub(super) async fn rollback_release_update(config: &SystemUpdateConfig) -> Result<(), AdminError> { + let exe_path = config.executable_path()?; + let backup_path = backup_path_for(&exe_path); + if !backup_path.exists() { + return Err(AdminError::conflict("No binary backup found for rollback")); + } + fs::rename(&backup_path, &exe_path).map_err(internal_error_with("Binary rollback failed"))?; + + let web_backup = backup_path_for(&config.web_dist_dir); + if web_backup.exists() { + if config.web_dist_dir.exists() { + fs::remove_dir_all(&config.web_dist_dir) + .map_err(internal_error_with("Web rollback cleanup failed"))?; + } + fs::rename(&web_backup, &config.web_dist_dir) + .map_err(internal_error_with("Web rollback failed"))?; + } + Ok(()) +} + +pub(super) fn extract_release_archive( + archive_path: &Path, + temp_dir: &Path, +) -> Result { + let file = + fs::File::open(archive_path).map_err(internal_error_with("Failed to open archive"))?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let extract_dir = temp_dir.join("extracted"); + fs::create_dir_all(&extract_dir) + .map_err(internal_error_with("Failed to create extract dir"))?; + let binary_path = temp_dir.join(APP_BINARY_NAME); + let web_dist_dir = temp_dir.join("web-dist"); + let mut found_binary = false; + let mut found_web = false; + + for entry in archive + .entries() + .map_err(internal_error_with("Failed to read archive"))? + { + let mut entry = entry.map_err(internal_error_with("Invalid archive entry"))?; + if !entry.header().entry_type().is_file() { + continue; + } + let path = entry + .path() + .map_err(internal_error_with("Invalid archive path"))? + .to_path_buf(); + if unsafe_archive_path(&path) { + return Err(AdminError::bad_request("Unsafe archive path")); + } + + if path.file_name().is_some_and(|name| name == APP_BINARY_NAME) { + entry + .unpack(&binary_path) + .map_err(internal_error_with("Failed to extract binary"))?; + found_binary = true; + continue; + } + + if let Some(relative) = web_dist_relative_path(&path) { + let target = web_dist_dir.join(relative); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .map_err(internal_error_with("Failed to create web asset dir"))?; + } + entry + .unpack(&target) + .map_err(internal_error_with("Failed to extract web asset"))?; + found_web = true; + } + } + + if !found_binary { + return Err(AdminError::bad_request( + "Release archive does not contain codex-proxy-rs", + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755)) + .map_err(internal_error_with("Failed to chmod binary"))?; + } + + Ok(ExtractedRelease { + binary_path, + web_dist_dir: found_web.then_some(web_dist_dir), + }) +} + +pub(super) fn replace_release_files( + exe_path: &Path, + web_dist_dir: &Path, + extracted: ExtractedRelease, +) -> Result<(), AdminError> { + let web_backup = backup_path_for(web_dist_dir); + let web_replaced = if let Some(new_web) = extracted.web_dist_dir { + replace_dir(web_dist_dir, &web_backup, &new_web)?; + true + } else { + false + }; + + let binary_backup = backup_path_for(exe_path); + if binary_backup.exists() { + if let Err(error) = fs::remove_file(&binary_backup) { + if web_replaced { + let _ = restore_dir(web_dist_dir, &web_backup); + } + return Err(internal_error("Failed to remove old binary backup", error)); + } + } + if let Err(error) = move_file(exe_path, &binary_backup) { + if web_replaced { + let _ = restore_dir(web_dist_dir, &web_backup); + } + return Err(internal_error("Binary backup failed", error)); + } + if let Err(error) = move_file(&extracted.binary_path, exe_path) { + let _ = fs::remove_file(exe_path); + let _ = move_file(&binary_backup, exe_path); + if web_replaced { + let _ = restore_dir(web_dist_dir, &web_backup); + } + return Err(internal_error("Binary replace failed", error)); + } + Ok(()) +} + +fn replace_dir(current: &Path, backup: &Path, replacement: &Path) -> Result<(), AdminError> { + if backup.exists() { + fs::remove_dir_all(backup) + .map_err(internal_error_with("Failed to remove old web backup"))?; + } + if current.exists() { + move_dir(current, backup).map_err(internal_error_with("Failed to backup web assets"))?; + } + if let Err(error) = move_dir(replacement, current) { + if backup.exists() { + let _ = restore_dir(current, backup); + } + return Err(internal_error("Failed to replace web assets", error)); + } + Ok(()) +} + +fn restore_dir(current: &Path, backup: &Path) -> io::Result<()> { + if current.exists() { + fs::remove_dir_all(current)?; + } + if backup.exists() { + move_dir(backup, current)?; + } + Ok(()) +} + +fn move_file(from: &Path, to: &Path) -> io::Result<()> { + match fs::rename(from, to) { + Ok(()) => Ok(()), + Err(error) if is_cross_device_link(&error) => { + fs::copy(from, to)?; + fs::remove_file(from) + } + Err(error) => Err(error), + } +} + +fn move_dir(from: &Path, to: &Path) -> io::Result<()> { + match fs::rename(from, to) { + Ok(()) => Ok(()), + Err(error) if is_cross_device_link(&error) => { + copy_dir_all(from, to)?; + fs::remove_dir_all(from) + } + Err(error) => Err(error), + } +} + +fn copy_dir_all(from: &Path, to: &Path) -> io::Result<()> { + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let file_type = entry.file_type()?; + let target = to.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_all(&entry.path(), &target)?; + } else if file_type.is_file() { + fs::copy(entry.path(), &target)?; + fs::set_permissions(&target, entry.metadata()?.permissions())?; + } else { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("unsupported file type in {}", entry.path().display()), + )); + } + } + Ok(()) +} + +fn is_cross_device_link(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::CrossesDevices +} + +fn backup_path_for(path: &Path) -> PathBuf { + let mut backup = path.as_os_str().to_os_string(); + backup.push(".backup"); + PathBuf::from(backup) +} + +fn web_dist_relative_path(path: &Path) -> Option { + let components = path + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().to_string()), + _ => None, + }) + .collect::>(); + for index in 0..components.len().saturating_sub(1) { + if components[index] == "web" + && components + .get(index + 1) + .is_some_and(|value| value == "dist") + { + return Some(components[index + 2..].iter().collect()); + } + if components[index] == "dist" { + return Some(components[index + 1..].iter().collect()); + } + } + None +} + +fn unsafe_archive_path(path: &Path) -> bool { + path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::Prefix(_) | Component::RootDir + ) + }) +} diff --git a/backend/src/admin/system/updater/download.rs b/backend/src/admin/system/updater/download.rs new file mode 100644 index 000000000..f7e550f0e --- /dev/null +++ b/backend/src/admin/system/updater/download.rs @@ -0,0 +1,226 @@ +//! 自更新下载、checksum 与 URL 校验。 + +use std::{ + fs, + io::{Read, Write}, + path::Path, + time::Duration, +}; + +use sha2::{Digest, Sha256}; + +use crate::admin::response::AdminError; + +use super::{ + bad_gateway_with, bad_request_with, emit_update_event, format_bytes, internal_error_with, + UpdateLogLevel, +}; + +#[derive(Debug, Clone, Copy)] +pub(super) struct DownloadProgress<'a> { + pub operation_id: &'a str, + pub total_size: u64, +} + +pub(super) async fn download_file( + url: &str, + dest: &Path, + max_size: u64, + progress: Option>, +) -> Result<(), AdminError> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(internal_error_with("Failed to create HTTP client"))?; + let mut response = client + .get(url) + .send() + .await + .map_err(bad_gateway_with("Download failed"))?; + if !response.status().is_success() { + return Err(AdminError::bad_gateway(format!( + "Download failed with {}", + response.status() + ))); + } + let mut file = + fs::File::create(dest).map_err(internal_error_with("Failed to create download file"))?; + let mut downloaded = 0_u64; + let mut next_progress = 10_u64; + while let Some(chunk) = response + .chunk() + .await + .map_err(bad_gateway_with("Download stream failed"))? + { + downloaded += chunk.len() as u64; + if downloaded > max_size { + return Err(AdminError::bad_request("Download exceeds max allowed size")); + } + file.write_all(&chunk) + .map_err(internal_error_with("Failed to write download"))?; + if let Some(progress) = progress { + let percent = download_percent(downloaded, progress.total_size); + if progress.total_size > 0 + && (percent >= next_progress || downloaded >= progress.total_size) + { + emit_update_event( + UpdateLogLevel::Info, + Some(progress.operation_id), + Some("download"), + format!( + "已下载 {} / {} ({percent}%)", + format_bytes(downloaded), + format_bytes(progress.total_size) + ), + ); + next_progress = percent.saturating_add(10); + } + } + } + Ok(()) +} + +pub(super) async fn verify_checksum( + file_path: &Path, + file_name: &str, + checksum_url: &str, +) -> Result<(), AdminError> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(internal_error_with("Failed to create HTTP client"))?; + let body = client + .get(checksum_url) + .send() + .await + .map_err(bad_gateway_with("Checksum download failed"))?; + let status = body.status(); + if !status.is_success() { + return Err(AdminError::bad_gateway(format!( + "Checksum download failed with {status}" + ))); + } + let body = body + .text() + .await + .map_err(bad_gateway_with("Checksum read failed"))?; + + let expected = body.lines().find_map(|line| { + let mut parts = line.split_whitespace(); + let hash = parts.next()?; + let name = parts.next()?; + (Path::new(name).file_name()?.to_string_lossy() == file_name).then(|| hash.to_string()) + }); + let expected = expected.ok_or_else(|| AdminError::bad_gateway("Checksum not found"))?; + let actual = sha256_file(file_path)?; + if expected.eq_ignore_ascii_case(&actual) { + Ok(()) + } else { + Err(AdminError::bad_gateway("Checksum mismatch")) + } +} + +pub(super) fn validate_download_url( + raw_url: &str, + github_api_base: &str, +) -> Result<(), AdminError> { + let url = reqwest::Url::parse(raw_url).map_err(bad_request_with("Invalid download URL"))?; + if local_update_test_download_allowed(&url, github_api_base) { + return Ok(()); + } + if url.scheme() != "https" { + return Err(AdminError::bad_request( + "Only HTTPS release downloads are allowed", + )); + } + let host = url + .host_str() + .ok_or_else(|| AdminError::bad_request("Download URL is missing host"))?; + let allowed = host == "github.com" + || host.ends_with(".github.com") + || host == "objects.githubusercontent.com" + || host.ends_with(".objects.githubusercontent.com"); + if allowed { + Ok(()) + } else { + Err(AdminError::bad_request(format!( + "Download host is not allowed: {host}" + ))) + } +} + +pub(super) fn validate_github_api_base(raw_url: &str) -> Result<(), String> { + let url = reqwest::Url::parse(raw_url) + .map_err(|error| format!("Invalid GitHub API base: {error}"))?; + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("GitHub API base must not include credentials, query, or fragment".to_string()); + } + if url.path().trim_end_matches('/') != "/repos" { + return Err("GitHub API base path must be /repos".to_string()); + } + if url_host_is_loopback(&url) { + return Ok(()); + } + if url.scheme() != "https" { + return Err("GitHub API base must use HTTPS".to_string()); + } + if url.host_str() != Some("api.github.com") { + return Err("GitHub API base must be https://api.github.com/repos".to_string()); + } + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = + fs::File::open(path).map_err(internal_error_with("Failed to open checksum file"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = file + .read(&mut buffer) + .map_err(internal_error_with("Failed to read checksum file"))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex::encode(hasher.finalize())) +} + +fn local_update_test_download_allowed(url: &reqwest::Url, github_api_base: &str) -> bool { + if url.scheme() != "http" || !url_host_is_loopback(url) { + return false; + } + let Ok(api_base) = reqwest::Url::parse(github_api_base) else { + return false; + }; + api_base.scheme() == "http" + && url_host_is_loopback(&api_base) + && url.host_str() == api_base.host_str() + && url.port_or_known_default() == api_base.port_or_known_default() +} + +fn url_host_is_loopback(url: &reqwest::Url) -> bool { + url.host_str().is_some_and(|host| { + host == "localhost" + || host == "127.0.0.1" + || host == "::1" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }) +} + +fn download_percent(downloaded: u64, total_size: u64) -> u64 { + if total_size == 0 { + return 0; + } + downloaded + .saturating_mul(100) + .saturating_div(total_size) + .min(100) +} diff --git a/backend/src/admin/system/updater/release.rs b/backend/src/admin/system/updater/release.rs new file mode 100644 index 000000000..32f711c09 --- /dev/null +++ b/backend/src/admin/system/updater/release.rs @@ -0,0 +1,281 @@ +//! 自更新 Release 查询、缓存和 asset 选择。 + +use std::{ + env, + time::{Duration, Instant}, +}; + +use semver::Version; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use crate::admin::response::AdminError; + +use super::{ + build_type_label, deployment_mode_label, download::validate_github_api_base, + SystemUpdateConfig, APP_BINARY_NAME, +}; + +const UPDATE_CACHE_TTL: Duration = Duration::from_secs(20 * 60); + +static RELEASE_CACHE: Mutex> = Mutex::const_new(None); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct UpdateInfoData { + pub current_version: String, + pub latest_version: String, + pub has_update: bool, + pub deployment_mode: String, + pub deployment_mode_label: String, + pub build_type: String, + pub build_type_label: String, + pub release_url: Option, + pub notes: Option, + pub cached: bool, + pub update_supported: bool, + pub unsupported_reason: Option, + pub warning: Option, +} + +#[derive(Debug, Clone)] +struct CachedUpdateInfo { + key: String, + info: UpdateInfoData, + cached_at: Instant, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GitHubRelease { + pub tag_name: String, + pub name: Option, + pub body: Option, + pub html_url: Option, + pub prerelease: bool, + #[serde(default)] + pub assets: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub(super) struct GitHubAsset { + pub name: String, + pub browser_download_url: String, + pub size: u64, +} + +pub(super) async fn check_latest_release( + config: &SystemUpdateConfig, + force: bool, +) -> UpdateInfoData { + let Some(repository) = config.update_repository.as_deref() else { + return UpdateInfoData { + current_version: config.version.clone(), + latest_version: config.version.clone(), + has_update: false, + deployment_mode: config.deployment_mode.clone(), + deployment_mode_label: deployment_mode_label(&config.deployment_mode).to_string(), + build_type: config.build_type.clone(), + build_type_label: build_type_label(&config.build_type).to_string(), + release_url: None, + notes: None, + cached: false, + update_supported: false, + unsupported_reason: Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_string()), + warning: None, + }; + }; + if let Err(reason) = validate_github_api_base(&config.github_api_base) { + return UpdateInfoData { + current_version: config.version.clone(), + latest_version: config.version.clone(), + has_update: false, + deployment_mode: config.deployment_mode.clone(), + deployment_mode_label: deployment_mode_label(&config.deployment_mode).to_string(), + build_type: config.build_type.clone(), + build_type_label: build_type_label(&config.build_type).to_string(), + release_url: None, + notes: None, + cached: false, + update_supported: false, + unsupported_reason: Some(reason), + warning: None, + }; + } + let cache_key = config.release_cache_key(); + + if !force { + if let Some(info) = cached_release_info(&cache_key).await { + return info; + } + } + + match fetch_latest_release(&config.github_api_base, repository).await { + Ok(release) => { + let info = update_info_from_release(config, release); + cache_release_info(cache_key, &info).await; + info + } + Err(error) => cached_release_info(&cache_key) + .await + .unwrap_or_else(|| UpdateInfoData { + current_version: config.version.clone(), + latest_version: config.version.clone(), + has_update: false, + deployment_mode: config.deployment_mode.clone(), + deployment_mode_label: deployment_mode_label(&config.deployment_mode).to_string(), + build_type: config.build_type.clone(), + build_type_label: build_type_label(&config.build_type).to_string(), + release_url: None, + notes: None, + cached: false, + update_supported: false, + unsupported_reason: config.update_support_error(), + warning: Some(error), + }), + } +} + +pub(super) async fn fetch_latest_release( + api_base: &str, + repository: &str, +) -> Result { + let url = format!( + "{}/{repository}/releases/latest", + api_base.trim_end_matches('/') + ); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| error.to_string())?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header(reqwest::header::USER_AGENT, APP_BINARY_NAME) + .send() + .await + .map_err(|error| error.to_string())?; + let status = response.status(); + if !status.is_success() { + return Err(format!("GitHub release check failed with {status}")); + } + response + .json::() + .await + .map_err(|error| error.to_string()) +} + +pub(super) fn update_info_from_release( + config: &SystemUpdateConfig, + release: GitHubRelease, +) -> UpdateInfoData { + let latest_version = normalize_version_tag(&release.tag_name); + let has_update = release_allowed(config, &release) + && version_is_newer(&config.version, &latest_version).unwrap_or(false); + let unsupported_reason = config.update_support_error(); + let update_supported = unsupported_reason.is_none(); + let notes = release.body.or(release.name); + UpdateInfoData { + current_version: config.version.clone(), + latest_version, + has_update, + deployment_mode: config.deployment_mode.clone(), + deployment_mode_label: deployment_mode_label(&config.deployment_mode).to_string(), + build_type: config.build_type.clone(), + build_type_label: build_type_label(&config.build_type).to_string(), + release_url: release.html_url, + notes, + cached: false, + update_supported, + unsupported_reason, + warning: None, + } +} + +pub(super) fn select_release_archive<'a>( + release: &'a GitHubRelease, + version: &str, +) -> Result<&'a GitHubAsset, AdminError> { + let os_aliases = platform_os_aliases(); + let arch_aliases = platform_arch_aliases(); + let normalized = normalize_version_tag(version); + release + .assets + .iter() + .find(|asset| { + let name = asset.name.as_str(); + name.contains(APP_BINARY_NAME) + && name.contains(&normalized) + && asset_matches_current_platform(name, os_aliases, arch_aliases) + && !name.ends_with(".txt") + }) + .or_else(|| { + release.assets.iter().find(|asset| { + let name = asset.name.as_str(); + asset_matches_current_platform(name, os_aliases, arch_aliases) + && !name.ends_with(".txt") + }) + }) + .ok_or_else(|| { + AdminError::conflict(format!( + "No compatible release archive found for {}/{}", + env::consts::OS, + env::consts::ARCH + )) + }) +} + +async fn cached_release_info(cache_key: &str) -> Option { + let cache = RELEASE_CACHE.lock().await; + let cached = cache.as_ref()?; + if cached.key != cache_key || cached.cached_at.elapsed() > UPDATE_CACHE_TTL { + return None; + } + let mut info = cached.info.clone(); + info.cached = true; + Some(info) +} + +async fn cache_release_info(cache_key: String, info: &UpdateInfoData) { + let mut cache = RELEASE_CACHE.lock().await; + *cache = Some(CachedUpdateInfo { + key: cache_key, + info: info.clone(), + cached_at: Instant::now(), + }); +} + +fn release_allowed(config: &SystemUpdateConfig, release: &GitHubRelease) -> bool { + config.update_channel != "stable" || !release.prerelease +} + +fn version_is_newer(current: &str, latest: &str) -> Option { + let current = Version::parse(&normalize_version_tag(current)).ok()?; + let latest = Version::parse(&normalize_version_tag(latest)).ok()?; + Some(latest > current) +} + +fn platform_os_aliases() -> &'static [&'static str] { + match env::consts::OS { + "macos" => &["macos", "darwin"], + "windows" => &["windows", "win32"], + "linux" => &["linux"], + _ => &[env::consts::OS], + } +} + +fn platform_arch_aliases() -> &'static [&'static str] { + match env::consts::ARCH { + "x86_64" => &["x86_64", "amd64"], + "aarch64" => &["aarch64", "arm64"], + _ => &[env::consts::ARCH], + } +} + +fn asset_matches_current_platform(name: &str, os_aliases: &[&str], arch_aliases: &[&str]) -> bool { + os_aliases.iter().any(|os| name.contains(os)) + && arch_aliases.iter().any(|arch| name.contains(arch)) +} + +fn normalize_version_tag(version: &str) -> String { + version.trim().trim_start_matches('v').to_string() +} diff --git a/backend/src/admin/update_payload.rs b/backend/src/admin/update_payload.rs new file mode 100644 index 000000000..11584e417 --- /dev/null +++ b/backend/src/admin/update_payload.rs @@ -0,0 +1,62 @@ +//! 管理端通用更新请求解析。 + +use serde::Deserialize; +use serde_json::Value; + +use crate::admin::response::AdminError; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct EditableUpdateRequest { + id: String, + label: Option>, + status: Option, +} + +pub(crate) struct ParsedEditableUpdate { + pub id: String, + pub label: Option>, + pub status: Option, +} + +pub(crate) struct EditableUpdateMessages<'a> { + pub object_required: &'a str, + pub invalid: &'a str, + pub empty_update: &'a str, + pub unknown_field_editable: bool, +} + +pub(crate) fn parse_editable_update( + payload: &Value, + messages: EditableUpdateMessages<'_>, +) -> Result { + let object = payload + .as_object() + .ok_or_else(|| AdminError::bad_request(messages.object_required))?; + if messages.unknown_field_editable { + for field in object.keys() { + if !matches!(field.as_str(), "id" | "label" | "status") { + return Err(AdminError::bad_request(format!("{field} is not editable"))); + } + } + } + + let update = serde_json::from_value::(payload.clone()) + .map_err(|_| AdminError::bad_request(messages.invalid))?; + if is_blank(&update.id) || update.status.as_deref().is_some_and(is_blank) { + return Err(AdminError::bad_request(messages.invalid)); + } + if update.label.is_none() && update.status.is_none() { + return Err(AdminError::bad_request(messages.empty_update)); + } + + Ok(ParsedEditableUpdate { + id: update.id, + label: update.label, + status: update.status, + }) +} + +fn is_blank(value: &str) -> bool { + value.trim().is_empty() +} diff --git a/backend/src/config/loader.rs b/backend/src/config/loader.rs index 0201cb278..79c849f93 100644 --- a/backend/src/config/loader.rs +++ b/backend/src/config/loader.rs @@ -1,19 +1,31 @@ -use std::path::Path; +use std::{env, path::Path}; use super::types::AppConfig; +const CONFIG_FILE_ENV: &str = "CPR_CONFIG_FILE"; + impl AppConfig { - /// 从当前目录加载配置文件。 + /// 从 `CPR_CONFIG_FILE` 或当前目录 `config.yaml` 加载配置文件。 pub fn load() -> Result { - Self::load_from_dir(".") + let config_file = env::var(CONFIG_FILE_ENV) + .ok() + .filter(|value| !value.trim().is_empty()); + match config_file { + Some(path) => load_file(path), + None => Self::load_from_dir("."), + } } /// 从指定目录加载配置文件。 pub fn load_from_dir(config_dir: impl AsRef) -> Result { let config_dir = config_dir.as_ref(); - ::config::Config::builder() - .add_source(::config::File::from(config_dir.join("config.yaml")).required(true)) - .build()? - .try_deserialize() + load_file(config_dir.join("config.yaml")) } } + +fn load_file(config_file: impl AsRef) -> Result { + ::config::Config::builder() + .add_source(::config::File::from(config_file.as_ref()).required(true)) + .build()? + .try_deserialize() +} diff --git a/backend/src/config/types.rs b/backend/src/config/types.rs index b4802b58d..0aea910b9 100644 --- a/backend/src/config/types.rs +++ b/backend/src/config/types.rs @@ -2,6 +2,17 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +const WEAK_ADMIN_PASSWORDS: &[&str] = &[ + "", + "admin", + "123456", + "password", + "changeme", + "change-me", + "replace-me", + "codex-proxy-rs", +]; + /// 应用运行总配置。 #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -43,11 +54,15 @@ pub struct AppConfig { /// HTTP 监听配置。 #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct ServerConfig { /// 监听主机。 pub host: String, /// 监听端口。 pub port: u16, + /// 可信反向代理 IP 或 CIDR。只有这些 peer 的转发 IP 头会被采信。 + #[serde(default)] + pub trusted_proxies: Vec, } /// 上游 API 地址配置。 @@ -276,6 +291,30 @@ impl Default for AdminConfig { } } +impl AdminConfig { + /// 校验首次启动创建管理员时使用的默认密码。 + pub fn validate_default_password(&self) -> Result<(), AdminConfigError> { + let password = self.default_password.trim(); + if password.len() < 12 { + return Err(AdminConfigError::WeakDefaultPassword); + } + if WEAK_ADMIN_PASSWORDS.contains(&password.to_ascii_lowercase().as_str()) { + return Err(AdminConfigError::WeakDefaultPassword); + } + Ok(()) + } +} + +/// 管理员配置校验错误。 +#[derive(Debug, Clone, Copy, thiserror::Error, PartialEq, Eq)] +pub enum AdminConfigError { + /// 默认管理员密码为空、过短或属于常见弱口令。 + #[error( + "admin.default_password must be set to a non-default password with at least 12 characters" + )] + WeakDefaultPassword, +} + fn default_session_cleanup_interval() -> u64 { 3600 // 默认每小时清理一次过期会话 } @@ -285,7 +324,7 @@ fn default_admin_username() -> String { } fn default_admin_password() -> String { - "admin".to_string() + String::new() } /// 日志持久化配置。 diff --git a/backend/src/http/middleware/request_id.rs b/backend/src/http/middleware/request_id.rs index 868e61c6a..6edfee8fa 100644 --- a/backend/src/http/middleware/request_id.rs +++ b/backend/src/http/middleware/request_id.rs @@ -1,17 +1,56 @@ //! 请求上下文中间件。 -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use axum::{ - extract::{connect_info::ConnectInfo, Request}, - http::HeaderValue, + extract::{connect_info::ConnectInfo, Request, State}, + http::{HeaderMap, HeaderValue}, middleware::Next, response::Response, }; +use ipnet::IpNet; use uuid::Uuid; /// 请求 ID 头。 pub const REQUEST_ID_HEADER: &str = "x-request-id"; +const MAX_REQUEST_ID_LEN: usize = 128; +const CF_CONNECTING_IP_HEADER: &str = "cf-connecting-ip"; +const REAL_IP_HEADER: &str = "x-real-ip"; +const X_FORWARDED_FOR_HEADER: &str = "x-forwarded-for"; +const UNTRUSTED_CLIENT_IP_HEADERS: &[&str] = &[ + CF_CONNECTING_IP_HEADER, + REAL_IP_HEADER, + X_FORWARDED_FOR_HEADER, +]; + +/// 可信反向代理配置。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TrustedProxyConfig { + networks: Vec, +} + +impl TrustedProxyConfig { + /// 从 IP 或 CIDR 字符串构造可信代理配置。 + pub fn from_entries(entries: &[String]) -> Result { + let networks = entries + .iter() + .map(|entry| parse_trusted_proxy_entry(entry)) + .collect::, _>>()?; + Ok(Self { networks }) + } + + fn is_trusted(&self, ip: IpAddr) -> bool { + self.networks.iter().any(|network| network.contains(&ip)) + } +} + +/// 可信代理配置错误。 +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TrustedProxyConfigError { + /// 配置项不是 IP 地址或 CIDR。 + #[error("invalid trusted proxy entry `{entry}`; expected an IP address or CIDR network")] + InvalidEntry { entry: String }, +} /// 请求 ID。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -26,7 +65,7 @@ impl RequestId { /// 从 HTTP 头解析请求 ID。 pub fn from_header(value: &HeaderValue) -> Option { let value = value.to_str().ok()?.trim(); - if value.is_empty() { + if !valid_request_id(value) { return None; } Some(Self(value.to_string())) @@ -38,15 +77,37 @@ impl RequestId { } } +fn valid_request_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_REQUEST_ID_LEN + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + /// 为请求附加请求 ID,并在响应头中回写。 pub async fn attach_request_id(mut request: Request, next: Next) -> Response { + attach_request_context(&TrustedProxyConfig::default(), &mut request); + run_with_request_id(request, next).await +} + +/// 为请求附加请求上下文,并按可信代理配置解析真实客户端 IP。 +pub async fn attach_request_id_with_proxy_config( + State(trusted_proxies): State, + mut request: Request, + next: Next, +) -> Response { + attach_request_context(&trusted_proxies, &mut request); + run_with_request_id(request, next).await +} + +async fn run_with_request_id(mut request: Request, next: Next) -> Response { let request_id = request .headers() .get(REQUEST_ID_HEADER) .and_then(RequestId::from_header) .unwrap_or_else(RequestId::generate); - attach_real_ip_from_connection(&mut request); request.extensions_mut().insert(request_id.clone()); let mut response = next.run(request).await; if let Ok(value) = HeaderValue::from_str(request_id.as_str()) { @@ -55,19 +116,84 @@ pub async fn attach_request_id(mut request: Request, next: Next) -> Response { response } -fn attach_real_ip_from_connection(request: &mut Request) { - if request.headers().contains_key("cf-connecting-ip") - || request.headers().contains_key("x-real-ip") - || request.headers().contains_key("x-forwarded-for") - { - return; +fn attach_request_context(trusted_proxies: &TrustedProxyConfig, request: &mut Request) { + attach_real_ip_from_connection(trusted_proxies, request); +} + +fn attach_real_ip_from_connection(trusted_proxies: &TrustedProxyConfig, request: &mut Request) { + let peer_ip = request + .extensions() + .get::>() + .map(|ConnectInfo(addr)| addr.ip()); + let client_ip = + peer_ip.map(|peer_ip| client_ip_from_peer(peer_ip, request.headers(), trusted_proxies)); + + for header in UNTRUSTED_CLIENT_IP_HEADERS { + request.headers_mut().remove(*header); } - let Some(ConnectInfo(addr)) = request.extensions().get::>() else { + let Some(client_ip) = client_ip else { return; }; - let Ok(value) = HeaderValue::from_str(&addr.ip().to_string()) else { - return; - }; - request.headers_mut().insert("x-real-ip", value); + if let Ok(value) = HeaderValue::from_str(&client_ip.to_string()) { + request.headers_mut().insert(REAL_IP_HEADER, value); + } +} + +fn client_ip_from_peer( + peer_ip: IpAddr, + headers: &HeaderMap, + trusted_proxies: &TrustedProxyConfig, +) -> IpAddr { + if trusted_proxies.is_trusted(peer_ip) { + trusted_forwarded_client_ip(headers).unwrap_or(peer_ip) + } else { + peer_ip + } +} + +fn trusted_forwarded_client_ip(headers: &HeaderMap) -> Option { + header_ip(headers, CF_CONNECTING_IP_HEADER) + .or_else(|| first_forwarded_for_ip(headers)) + .or_else(|| header_ip(headers, REAL_IP_HEADER)) +} + +fn header_ip(headers: &HeaderMap, name: &str) -> Option { + parse_ip(headers.get(name)?.to_str().ok()?) +} + +fn first_forwarded_for_ip(headers: &HeaderMap) -> Option { + let value = headers.get(X_FORWARDED_FOR_HEADER)?.to_str().ok()?; + parse_ip(value.split(',').next()?) +} + +fn parse_ip(value: &str) -> Option { + value.trim().parse().ok() +} + +fn parse_trusted_proxy_entry(entry: &str) -> Result { + let entry = entry.trim(); + if entry.is_empty() { + return Err(invalid_trusted_proxy_entry(entry)); + } + if let Ok(network) = entry.parse::() { + return Ok(network); + } + let ip = entry + .parse::() + .map_err(|_| invalid_trusted_proxy_entry(entry))?; + IpNet::new( + ip, + match ip { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }, + ) + .map_err(|_| invalid_trusted_proxy_entry(entry)) +} + +fn invalid_trusted_proxy_entry(entry: &str) -> TrustedProxyConfigError { + TrustedProxyConfigError::InvalidEntry { + entry: entry.to_string(), + } } diff --git a/backend/src/http/middleware/trace.rs b/backend/src/http/middleware/trace.rs index aa115acb5..ca2fde150 100644 --- a/backend/src/http/middleware/trace.rs +++ b/backend/src/http/middleware/trace.rs @@ -36,20 +36,22 @@ pub fn http_trace_layer() -> HttpTraceLayer { fn make_http_span(request: &Request) -> Span { let request_id = request_id(request); + let uri = sanitized_uri(request); tracing::info_span!( "http", request_id = request_id.as_deref(), method = %request.method(), - uri = %request.uri() + uri = %uri ) } fn on_http_request(request: &Request, _span: &Span) { let request_id = request_id(request); + let uri = sanitized_uri(request); info!( request_id = request_id.as_deref(), method = %request.method(), - uri = %request.uri(), + uri = %uri, "received HTTP request" ); } @@ -80,3 +82,34 @@ fn request_id(request: &Request) -> Option { .get::() .map(|rid| rid.as_str().to_string()) } + +fn sanitized_uri(request: &Request) -> String { + let uri = request.uri(); + let path = uri.path(); + uri.query().map_or_else( + || path.to_string(), + |query| format!("{path}?{}", sanitized_query(query)), + ) +} + +fn sanitized_query(query: &str) -> String { + let mut redacted = String::new(); + for (index, part) in query.split('&').enumerate() { + if index > 0 { + redacted.push('&'); + } + redacted.push_str(&sanitized_query_part(part)); + } + redacted +} + +fn sanitized_query_part(part: &str) -> String { + let Some((key, _value)) = part.split_once('=') else { + return "".to_string(); + }; + if key.is_empty() { + "=".to_string() + } else { + format!("{key}=") + } +} diff --git a/backend/src/http/router.rs b/backend/src/http/router.rs index 3a863eb5d..f5f380ad7 100644 --- a/backend/src/http/router.rs +++ b/backend/src/http/router.rs @@ -2,10 +2,13 @@ use std::path::Path; -use axum::{middleware, Router}; +use axum::{extract::State, http::StatusCode, middleware, routing::get, Router}; use crate::admin; -use crate::http::middleware::{request_id::attach_request_id, trace::http_trace_layer}; +use crate::http::middleware::{ + request_id::{attach_request_id_with_proxy_config, TrustedProxyConfig}, + trace::http_trace_layer, +}; use crate::proxy; use crate::runtime::state::AppState; use crate::web; @@ -15,15 +18,45 @@ pub const DEFAULT_ASSET_DIST_DIR: &str = "web/dist"; /// 构造整个 HTTP 服务路由。 pub fn router() -> Router { - router_with_assets(DEFAULT_ASSET_DIST_DIR) + router_with_trusted_proxies(TrustedProxyConfig::default()) +} + +/// 使用指定可信代理配置构造整个 HTTP 服务路由。 +pub fn router_with_trusted_proxies(trusted_proxies: TrustedProxyConfig) -> Router { + router_with_assets_and_trusted_proxies(DEFAULT_ASSET_DIST_DIR, trusted_proxies) } /// 使用指定前端构建产物目录构造整个 HTTP 服务路由。 pub fn router_with_assets(dist_dir: impl AsRef) -> Router { + router_with_assets_and_trusted_proxies(dist_dir, TrustedProxyConfig::default()) +} + +/// 使用指定前端构建产物目录和可信代理配置构造整个 HTTP 服务路由。 +pub fn router_with_assets_and_trusted_proxies( + dist_dir: impl AsRef, + trusted_proxies: TrustedProxyConfig, +) -> Router { Router::new() + .route("/healthz", get(healthz)) .merge(proxy::router::router()) .merge(admin::router::router()) .fallback_service(web::assets::spa_router(dist_dir)) .layer(http_trace_layer()) - .layer(middleware::from_fn(attach_request_id)) + .layer(middleware::from_fn_with_state( + trusted_proxies, + attach_request_id_with_proxy_config, + )) +} + +async fn healthz(State(state): State) -> StatusCode { + match sqlx::query("select 1") + .execute(state.services.background_tasks.client_keys.pool()) + .await + { + Ok(_) => StatusCode::NO_CONTENT, + Err(error) => { + tracing::warn!(error = %error, "health check failed"); + StatusCode::SERVICE_UNAVAILABLE + } + } } diff --git a/backend/src/infra/database.rs b/backend/src/infra/database.rs index 96d6fe32a..69d125364 100644 --- a/backend/src/infra/database.rs +++ b/backend/src/infra/database.rs @@ -1,10 +1,23 @@ -//! SQLite 数据库连接与 Schema 初始化。 +//! SQLite 数据库连接与迁移初始化。 use std::{fs, path::Path, str::FromStr, time::Duration}; use sqlx::{sqlite::SqliteConnectOptions, SqlitePool}; -/// 连接 SQLite 并初始化 schema。 +const CURRENT_SCHEMA_VERSION: i64 = 1; +const MIGRATIONS: &[Migration] = &[Migration { + version: 1, + name: "initial", + sql: include_str!("migrations/0001_initial.sql"), +}]; + +struct Migration { + version: i64, + name: &'static str, + sql: &'static str, +} + +/// 连接 SQLite 并应用未执行的迁移。 pub async fn connect_sqlite(database_url: &str) -> Result { ensure_sqlite_parent_dir(database_url)?; let options = SqliteConnectOptions::from_str(database_url)? @@ -21,11 +34,135 @@ pub async fn connect_sqlite(database_url: &str) -> Result Result<(), sqlx::Error> { - let schema = include_str!("schema.sql"); - sqlx::raw_sql(schema).execute(pool).await?; + let mut tx = pool.begin().await?; + ensure_migrations_table(&mut tx).await?; + + let applied_versions = applied_migration_versions(&mut tx).await?; + if applied_versions.is_empty() { + reject_unversioned_existing_schema(&mut tx).await?; + } + validate_applied_migrations(&applied_versions)?; + + for migration in MIGRATIONS { + if applied_versions.contains(&migration.version) { + continue; + } + sqlx::raw_sql(migration.sql).execute(&mut *tx).await?; + record_migration(&mut tx, migration).await?; + } + + tx.commit().await?; Ok(()) } +async fn ensure_migrations_table( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, +) -> Result<(), sqlx::Error> { + if table_exists(tx, "schema_migrations").await? { + return Ok(()); + } + + sqlx::query( + "create table schema_migrations ( + version integer not null check (version > 0), + name text not null, + applied_at text not null, + primary key (version) + )", + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn table_exists( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + table_name: &str, +) -> Result { + let row: Option<(i64,)> = sqlx::query_as( + "select 1 + from sqlite_master + where type = 'table' and name = ? + limit 1", + ) + .bind(table_name) + .fetch_optional(&mut **tx) + .await?; + Ok(row.is_some()) +} + +async fn applied_migration_versions( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, +) -> Result, sqlx::Error> { + let rows: Vec<(i64,)> = + sqlx::query_as("select version from schema_migrations order by version") + .fetch_all(&mut **tx) + .await?; + Ok(rows.into_iter().map(|row| row.0).collect()) +} + +fn validate_applied_migrations(applied_versions: &[i64]) -> Result<(), sqlx::Error> { + for version in applied_versions { + if MIGRATIONS + .iter() + .any(|migration| migration.version == *version) + { + continue; + } + return Err(unsupported_schema_version(*version)); + } + if let Some(version) = applied_versions + .last() + .copied() + .filter(|version| *version > CURRENT_SCHEMA_VERSION) + { + return Err(unsupported_schema_version(version)); + } + Ok(()) +} + +async fn reject_unversioned_existing_schema( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, +) -> Result<(), sqlx::Error> { + let (table_count,): (i64,) = sqlx::query_as( + "select count(*) + from sqlite_master + where type = 'table' + and name <> 'schema_migrations' + and name not like 'sqlite_%'", + ) + .fetch_one(&mut **tx) + .await?; + if table_count > 0 { + return Err(sqlx::Error::Protocol( + "unversioned sqlite schema is unsupported; recreate the database or add a migration" + .to_string(), + )); + } + Ok(()) +} + +async fn record_migration( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + migration: &Migration, +) -> Result<(), sqlx::Error> { + sqlx::query( + "insert into schema_migrations (version, name, applied_at) + values (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + ) + .bind(migration.version) + .bind(migration.name) + .execute(&mut **tx) + .await?; + Ok(()) +} + +fn unsupported_schema_version(version: i64) -> sqlx::Error { + sqlx::Error::Protocol(format!( + "unsupported sqlite schema version {version}; expected {CURRENT_SCHEMA_VERSION}" + )) +} + fn ensure_sqlite_parent_dir(database_url: &str) -> Result<(), sqlx::Error> { let Some(path) = sqlite_file_path(database_url) else { return Ok(()); diff --git a/backend/src/infra/format.rs b/backend/src/infra/format.rs index aea747203..dec187ae3 100644 --- a/backend/src/infra/format.rs +++ b/backend/src/infra/format.rs @@ -5,6 +5,11 @@ pub fn nonnegative_i64_to_u64(value: i64) -> u64 { u64::try_from(value).unwrap_or(0) } +/// 将可空负数归零后转换为无符号展示数值。 +pub fn optional_nonnegative_i64_to_u64(value: Option) -> u64 { + value.map(nonnegative_i64_to_u64).unwrap_or_default() +} + /// 使用中文界面常用千分位格式展示整数。 pub fn format_number(value: u64) -> String { let text = value.to_string(); diff --git a/backend/src/infra/schema.sql b/backend/src/infra/migrations/0001_initial.sql similarity index 72% rename from backend/src/infra/schema.sql rename to backend/src/infra/migrations/0001_initial.sql index 9e94da2eb..6a1a35eac 100644 --- a/backend/src/infra/schema.sql +++ b/backend/src/infra/migrations/0001_initial.sql @@ -3,7 +3,7 @@ pragma foreign_keys = on; -- ============================================ -- Admin Users -- ============================================ -create table if not exists admin_users ( +create table admin_users ( id text primary key, password_hash text not null, created_at text not null, @@ -13,19 +13,19 @@ create table if not exists admin_users ( -- ============================================ -- Admin Sessions -- ============================================ -create table if not exists admin_sessions ( +create table admin_sessions ( id text primary key, user_id text not null references admin_users(id) on delete cascade, expires_at text not null, created_at text not null ); -create index if not exists idx_admin_sessions_expires on admin_sessions(expires_at); +create index idx_admin_sessions_expires on admin_sessions(expires_at); -- ============================================ -- Client API Keys -- ============================================ -create table if not exists client_api_keys ( +create table client_api_keys ( id text primary key, name text not null, prefix text not null, @@ -36,13 +36,13 @@ create table if not exists client_api_keys ( last_used_at text ); -create index if not exists idx_client_api_keys_key_enabled on client_api_keys(key) where enabled = 1; -create index if not exists idx_client_api_keys_created_id on client_api_keys(created_at desc, id desc); +create index idx_client_api_keys_key_enabled on client_api_keys(key) where enabled = 1; +create index idx_client_api_keys_created_id on client_api_keys(created_at desc, id desc); -- ============================================ -- Runtime Settings -- ============================================ -create table if not exists runtime_settings ( +create table runtime_settings ( id integer primary key check (id = 1), model_aliases_json text not null default '{}', refresh_margin_seconds integer not null check (refresh_margin_seconds > 0), @@ -57,7 +57,7 @@ create table if not exists runtime_settings ( -- ============================================ -- Accounts -- ============================================ -create table if not exists accounts ( +create table accounts ( id text primary key, email text, chatgpt_account_id text, @@ -79,28 +79,28 @@ create table if not exists accounts ( updated_at text not null ); -create index if not exists idx_accounts_status on accounts(status); -create index if not exists idx_accounts_added_id on accounts(added_at desc, id desc); -create unique index if not exists ux_accounts_chatgpt_identity +create index idx_accounts_status on accounts(status); +create index idx_accounts_added_id on accounts(added_at desc, id desc); +create unique index ux_accounts_chatgpt_identity on accounts(chatgpt_account_id, coalesce(chatgpt_user_id, '')) where chatgpt_account_id is not null; -- ============================================ -- Account Refresh Leases -- ============================================ -create table if not exists account_refresh_leases ( +create table account_refresh_leases ( account_id text primary key references accounts(id) on delete cascade, owner text not null, expires_at text not null, updated_at text not null ); -create index if not exists idx_account_refresh_leases_expires on account_refresh_leases(expires_at); +create index idx_account_refresh_leases_expires on account_refresh_leases(expires_at); -- ============================================ -- Account Usage Statistics -- ============================================ -create table if not exists account_usage ( +create table account_usage ( account_id text primary key references accounts(id) on delete cascade, request_count integer not null default 0 check (request_count >= 0), empty_response_count integer not null default 0 check (empty_response_count >= 0), @@ -127,13 +127,13 @@ create table if not exists account_usage ( last_used_at text ); -create index if not exists idx_account_usage_last_used_account +create index idx_account_usage_last_used_account on account_usage(last_used_at desc, account_id desc); -- ============================================ -- Account Model Usage Statistics -- ============================================ -create table if not exists account_model_usage ( +create table account_model_usage ( account_id text not null references accounts(id) on delete cascade, model text not null, request_count integer not null default 0 check (request_count >= 0), @@ -145,13 +145,13 @@ create table if not exists account_model_usage ( primary key (account_id, model) ); -create index if not exists idx_account_model_usage_last_used +create index idx_account_model_usage_last_used on account_model_usage(last_used_at desc, account_id, model); -- ============================================ -- Usage Time Buckets -- ============================================ -create table if not exists usage_time_buckets ( +create table usage_time_buckets ( bucket_start text not null, account_id text not null default '', model text not null default '', @@ -171,15 +171,15 @@ create table if not exists usage_time_buckets ( primary key (bucket_start, account_id, model, service_tier) ); -create index if not exists idx_usage_time_buckets_bucket +create index idx_usage_time_buckets_bucket on usage_time_buckets(bucket_start); -create index if not exists idx_usage_time_buckets_model_bucket +create index idx_usage_time_buckets_model_bucket on usage_time_buckets(model, bucket_start); -- ============================================ -- Model Account Routes -- ============================================ -create table if not exists model_account_routes ( +create table model_account_routes ( model text not null, account_id text not null references accounts(id) on delete cascade, priority integer not null default 0 check (priority >= 0), @@ -189,15 +189,15 @@ create table if not exists model_account_routes ( primary key (model, account_id) ); -create index if not exists idx_model_account_routes_account +create index idx_model_account_routes_account on model_account_routes(account_id); -create index if not exists idx_model_account_routes_enabled_model +create index idx_model_account_routes_enabled_model on model_account_routes(enabled, model, priority asc, account_id); -- ============================================ -- Account Cookies -- ============================================ -create table if not exists account_cookies ( +create table account_cookies ( id text primary key, account_id text not null references accounts(id) on delete cascade, domain text not null, @@ -209,16 +209,16 @@ create table if not exists account_cookies ( unique(account_id, domain, name, path) ); -create index if not exists idx_account_cookies_account on account_cookies(account_id); -create index if not exists idx_account_cookies_account_domain on account_cookies(account_id, domain); -create index if not exists idx_account_cookies_expires +create index idx_account_cookies_account on account_cookies(account_id); +create index idx_account_cookies_account_domain on account_cookies(account_id, domain); +create index idx_account_cookies_expires on account_cookies(expires_at) where expires_at is not null; -- ============================================ -- Device Fingerprints -- ============================================ -create table if not exists fingerprints ( +create table fingerprints ( id text primary key, originator text not null, app_version text not null, @@ -234,7 +234,7 @@ create table if not exists fingerprints ( updated_at text not null ); -create table if not exists fingerprint_update_history ( +create table fingerprint_update_history ( id text primary key, current_fingerprint_id text not null references fingerprints(id) on delete cascade, app_version text not null, @@ -245,13 +245,13 @@ create table if not exists fingerprint_update_history ( created_at text not null ); -create index if not exists idx_fingerprint_update_history_created_id +create index idx_fingerprint_update_history_created_id on fingerprint_update_history(created_at desc, id desc); -- ============================================ -- Usage Records -- ============================================ -create table if not exists usage_records ( +create table usage_records ( id text primary key, request_id text, kind text not null, @@ -272,24 +272,24 @@ create table if not exists usage_records ( created_at text not null ); -create index if not exists idx_usage_records_created_id on usage_records(created_at desc, id desc); -create index if not exists idx_usage_records_kind_created on usage_records(kind, created_at desc); -create index if not exists idx_usage_records_request_id on usage_records(request_id); -create index if not exists idx_usage_records_account on usage_records(account_id, created_at desc) where account_id is not null; -create index if not exists idx_usage_records_transport on usage_records(transport, created_at desc) where transport is not null; -create index if not exists idx_usage_records_failure_class on usage_records(failure_class, created_at desc) where failure_class is not null; -create index if not exists idx_usage_records_response_id on usage_records(response_id) where response_id is not null; -create index if not exists idx_usage_records_upstream_request_id on usage_records(upstream_request_id) where upstream_request_id is not null; -create index if not exists idx_usage_records_level_created on usage_records(level, created_at desc); -create index if not exists idx_usage_records_route_created on usage_records(route, created_at desc) where route is not null; -create index if not exists idx_usage_records_model_created on usage_records(model, created_at desc) where model is not null; -create index if not exists idx_usage_records_status_created on usage_records(status_code, created_at desc) where status_code is not null; -create index if not exists idx_usage_records_upstream_status_created on usage_records(upstream_status_code, created_at desc) where upstream_status_code is not null; +create index idx_usage_records_created_id on usage_records(created_at desc, id desc); +create index idx_usage_records_kind_created on usage_records(kind, created_at desc); +create index idx_usage_records_request_id on usage_records(request_id); +create index idx_usage_records_account on usage_records(account_id, created_at desc) where account_id is not null; +create index idx_usage_records_transport on usage_records(transport, created_at desc) where transport is not null; +create index idx_usage_records_failure_class on usage_records(failure_class, created_at desc) where failure_class is not null; +create index idx_usage_records_response_id on usage_records(response_id) where response_id is not null; +create index idx_usage_records_upstream_request_id on usage_records(upstream_request_id) where upstream_request_id is not null; +create index idx_usage_records_level_created on usage_records(level, created_at desc); +create index idx_usage_records_route_created on usage_records(route, created_at desc) where route is not null; +create index idx_usage_records_model_created on usage_records(model, created_at desc) where model is not null; +create index idx_usage_records_status_created on usage_records(status_code, created_at desc) where status_code is not null; +create index idx_usage_records_upstream_status_created on usage_records(upstream_status_code, created_at desc) where upstream_status_code is not null; -- ============================================ -- Model Plan Snapshots -- ============================================ -create table if not exists model_plan_snapshots ( +create table model_plan_snapshots ( plan_type text primary key, models_json text not null, fetched_at text not null @@ -298,7 +298,7 @@ create table if not exists model_plan_snapshots ( -- ============================================ -- Session Affinities -- ============================================ -create table if not exists session_affinities ( +create table session_affinities ( response_id text primary key, account_id text not null references accounts(id) on delete cascade, conversation_id text not null, @@ -311,6 +311,6 @@ create table if not exists session_affinities ( created_at text not null ); -create index if not exists idx_session_affinities_conversation on session_affinities(conversation_id, created_at desc); -create index if not exists idx_session_affinities_expires on session_affinities(expires_at); -create index if not exists idx_session_affinities_active_order on session_affinities(expires_at, created_at, response_id); +create index idx_session_affinities_conversation on session_affinities(conversation_id, created_at desc); +create index idx_session_affinities_expires on session_affinities(expires_at); +create index idx_session_affinities_active_order on session_affinities(expires_at, created_at, response_id); diff --git a/backend/src/infra/time.rs b/backend/src/infra/time.rs index bd0b5779e..bc7042fe0 100644 --- a/backend/src/infra/time.rs +++ b/backend/src/infra/time.rs @@ -1,5 +1,7 @@ //! 时间格式化辅助。 +use std::time::Instant; + use chrono::{DateTime, FixedOffset, SecondsFormat, TimeZone, Timelike, Utc}; use serde::Serializer; @@ -10,6 +12,23 @@ pub fn china_rfc3339(value: &DateTime) -> String { value.with_timezone(&china_offset()).to_rfc3339() } +/// 将 RFC3339 字符串解析为 UTC 时间。 +pub fn parse_rfc3339_utc(value: &str) -> Result, chrono::ParseError> { + Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc)) +} + +/// 将可选 RFC3339 字符串解析为 UTC 时间。 +pub fn parse_optional_rfc3339_utc( + value: Option<&str>, +) -> Result>, chrono::ParseError> { + value.map(parse_rfc3339_utc).transpose() +} + +/// 返回从起点到当前的毫秒数,并在超过 i64 上限时饱和。 +pub fn elapsed_millis_i64(started_at: Instant) -> i64 { + started_at.elapsed().as_millis().min(i64::MAX as u128) as i64 +} + /// 将 UTC 时间输出为中国时区毫秒精度 RFC3339 字符串。 pub fn china_rfc3339_millis(value: &DateTime) -> String { value @@ -54,14 +73,6 @@ pub fn china_date(value: &DateTime) -> String { .to_string() } -/// 将 UTC 时间输出为中国时区时间。 -pub fn china_time(value: &DateTime) -> String { - value - .with_timezone(&china_offset()) - .format("%H:%M:%S") - .to_string() -} - /// 将 UTC 时间输出为适合文件名的中国时区时间戳。 pub fn china_filename_timestamp_millis(value: &DateTime) -> String { value diff --git a/backend/src/proxy/auth.rs b/backend/src/proxy/auth.rs index 9bfa21a3b..1a5a60d72 100644 --- a/backend/src/proxy/auth.rs +++ b/backend/src/proxy/auth.rs @@ -2,30 +2,102 @@ //! //! 提供 Bearer token 提取与客户端 API key 校验。 -use axum::http::HeaderMap; +use axum::http::{header::AUTHORIZATION, HeaderMap}; use crate::runtime::state::AppState; +/// client API key 鉴权失败原因。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientApiKeyAuthError { + /// 缺失 Authorization 头。 + MissingAuthorization, + /// Authorization 头不是合法的 Bearer token。 + MalformedAuthorization, + /// Bearer token 不是 client API key 格式。 + InvalidKeyFormat, + /// client API key 不存在或已禁用。 + InvalidKey, + /// 鉴权存储失败。 + StoreUnavailable { message: String }, +} + +impl ClientApiKeyAuthError { + /// 返回可用于日志和指标的稳定失败原因。 + pub fn reason(&self) -> &'static str { + match self { + Self::MissingAuthorization => "missing_authorization", + Self::MalformedAuthorization => "malformed_authorization", + Self::InvalidKeyFormat => "invalid_key_format", + Self::InvalidKey => "invalid_key", + Self::StoreUnavailable { .. } => "store_unavailable", + } + } +} + /// 从请求头提取 Bearer 客户端 API key。 -pub fn bearer_client_api_key(headers: &HeaderMap) -> Option { +pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAuthError> { let raw = headers - .get(axum::http::header::AUTHORIZATION)? + .get(AUTHORIZATION) + .ok_or(ClientApiKeyAuthError::MissingAuthorization)? .to_str() - .ok()?; - let token = raw.strip_prefix("Bearer ")?.trim(); - (!token.is_empty()).then(|| token.to_string()) + .map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?; + let token = raw + .strip_prefix("Bearer ") + .ok_or(ClientApiKeyAuthError::MalformedAuthorization)? + .trim(); + if token.is_empty() { + return Err(ClientApiKeyAuthError::MalformedAuthorization); + } + if !token.starts_with("sk_") { + return Err(ClientApiKeyAuthError::InvalidKeyFormat); + } + Ok(token) } /// 校验客户端 API key。 pub async fn authorize_client_api_key(state: &AppState, headers: &HeaderMap) -> bool { - let Some(api_key) = bearer_client_api_key(headers) else { - return false; - }; + match authorize_client_api_key_result(state, headers).await { + Ok(()) => true, + Err(error) => { + log_client_api_key_auth_failure(&error); + false + } + } +} - state +/// 校验客户端 API key,返回可观测的失败原因。 +pub async fn authorize_client_api_key_result( + state: &AppState, + headers: &HeaderMap, +) -> Result<(), ClientApiKeyAuthError> { + let api_key = bearer_client_api_key(headers)?; + let verified = state .services .client_keys - .verify(&api_key) + .verify(api_key) .await - .unwrap_or(false) + .map_err(|error| ClientApiKeyAuthError::StoreUnavailable { + message: error.to_string(), + })?; + verified + .then_some(()) + .ok_or(ClientApiKeyAuthError::InvalidKey) +} + +fn log_client_api_key_auth_failure(error: &ClientApiKeyAuthError) { + match error { + ClientApiKeyAuthError::StoreUnavailable { message } => { + tracing::warn!( + auth_failure = error.reason(), + error = %message, + "client api key authorization failed" + ); + } + _ => { + tracing::warn!( + auth_failure = error.reason(), + "client api key authorization failed" + ); + } + } } diff --git a/backend/src/proxy/dispatch/auth_recovery.rs b/backend/src/proxy/dispatch/auth_recovery.rs new file mode 100644 index 000000000..279c38d00 --- /dev/null +++ b/backend/src/proxy/dispatch/auth_recovery.rs @@ -0,0 +1,28 @@ +use std::sync::Arc; + +use crate::upstream::{ + accounts::{model::AccountStatus, token_refresh::RuntimeTokenRefreshService}, + token_client::OpenAiTokenClient, +}; + +pub(super) fn trigger_refresh_after_auth_failure( + token_refresh: &Arc>, + account_id: &str, + status: AccountStatus, +) { + if status != AccountStatus::Expired { + return; + } + + let token_refresh = Arc::clone(token_refresh); + let account_id = account_id.to_string(); + tokio::spawn(async move { + if let Err(error) = token_refresh.trigger_account_refresh_now(&account_id).await { + tracing::warn!( + account_id = %account_id, + error = %error, + "reactive token refresh after upstream auth failure failed" + ); + } + }); +} diff --git a/backend/src/proxy/dispatch/chat.rs b/backend/src/proxy/dispatch/chat.rs index 45bfdf926..9d1cf6d10 100644 --- a/backend/src/proxy/dispatch/chat.rs +++ b/backend/src/proxy/dispatch/chat.rs @@ -9,10 +9,11 @@ use thiserror::Error; use crate::{ admin::monitoring::{ - usage_record::{ResponseUsageRecord, UsageRecordLevel}, - usage_record_store::AdminUsageRecordService, + usage_record_model::{ResponseUsageRecord, UsageRecordLevel}, + usage_record_service::AdminUsageRecordService, }, proxy::dispatch::{ + auth_recovery::trigger_refresh_after_auth_failure, cloudflare::{ cloudflare_challenge_error_message, cloudflare_path_block_error_message, is_cloudflare_challenge_upstream_error, is_cloudflare_path_block_upstream_error, @@ -29,6 +30,10 @@ use crate::{ QuotaVerificationContext, QuotaVerificationDecision, QUOTA_VERIFY_LIMIT_REACHED_MESSAGE, }, + usage_events::{ + reasoning_effort_from_request, record_dispatch_error_event, record_response_event, + DispatchErrorUsageRecord, + }, }, proxy::openai::chat::ChatStreamTranslationError, upstream::accounts::{ @@ -80,24 +85,6 @@ impl ChatDispatchService { } } - fn trigger_refresh_after_auth_failure(&self, account_id: &str, status: AccountStatus) { - if status != AccountStatus::Expired { - return; - } - - let token_refresh = self.token_refresh.clone(); - let account_id = account_id.to_string(); - tokio::spawn(async move { - if let Err(error) = token_refresh.trigger_account_refresh_now(&account_id).await { - tracing::warn!( - account_id = %account_id, - error = %error, - "reactive token refresh after upstream auth failure failed" - ); - } - }); - } - /// 调度非流式 Chat Completions 请求到 Codex Responses 上游。 pub async fn complete( &self, @@ -311,7 +298,11 @@ impl ChatDispatchService { self.account_pool .set_status(&release_account_id, account_status) .await; - self.trigger_refresh_after_auth_failure(&release_account_id, account_status); + trigger_refresh_after_auth_failure( + &self.token_refresh, + &release_account_id, + account_status, + ); excluded_account_ids.push(release_account_id); } Err(error) if is_cloudflare_challenge_upstream_error(&error) => { @@ -554,8 +545,6 @@ struct ChatDispatchErrorEventRecord<'a> { error: &'a ChatDispatchError, } -use crate::admin::monitoring::usage_record::UsageRecord; - async fn record_chat_dispatch_error_event(record: ChatDispatchErrorEventRecord<'_>) { let mut metadata = serde_json::json!({ "route": record.route, @@ -571,127 +560,18 @@ async fn record_chat_dispatch_error_event(record: ChatDispatchErrorEventRecord<' object.insert("transport".to_string(), serde_json::json!(transport)); } } - let mut event = UsageRecord::new("v1.chat", UsageRecordLevel::Error, "chat dispatch failed"); - event.request_id = Some(record.request_id.to_string()); - event.account_id = record.account_id.map(ToString::to_string); - event.route = Some(record.route.to_string()); - event.model = Some(record.model.to_string()); - event.status_code = Some(record.error.http_status_code() as i64); - event.latency_ms = Some(elapsed_millis_i64(record.started_at)); - event.metadata = metadata; - if let Err(error) = record.usage_records.record(event).await { - tracing::warn!(error = %error, "failed to record chat dispatch error event"); - } -} - -async fn record_response_event(record: ResponseUsageRecord<'_>) { - let mut event = UsageRecord::new("v1.chat", record.level, record.message); - event.request_id = Some(record.request_id.to_string()); - event.account_id = Some(record.account_id.to_string()); - event.route = Some(record.route.to_string()); - event.model = Some(record.model.to_string()); - event.status_code = Some(record.status_code); - event.latency_ms = Some(elapsed_millis_i64(record.started_at)); - event.metadata = record.metadata; - if let Some(object) = event.metadata.as_object_mut() { - object - .entry("route".to_string()) - .or_insert_with(|| serde_json::json!(record.route)); - object - .entry("apiKind".to_string()) - .or_insert_with(|| serde_json::json!("chat")); - enrich_usage_record_identity( - object, - record.requested_model, - record.model, - record.client_ip, - record.client_user_agent, - record.reasoning_effort, - record.service_tier, - ); - } - let rate_limit_headers = record.rate_limit_headers; - if !rate_limit_headers.is_empty() { - if let Some(object) = event.metadata.as_object_mut() { - object.insert( - "rateLimitHeaders".to_string(), - serde_json::json!(rate_limit_headers), - ); - } - } - if let Err(error) = record.usage_records.record(event).await { - tracing::warn!(account_id = %record.account_id, error = %error, "failed to record response event"); - } -} - -fn enrich_usage_record_identity( - object: &mut serde_json::Map, - requested_model: Option<&str>, - upstream_model: &str, - client_ip: Option<&str>, - client_user_agent: Option<&str>, - reasoning_effort: Option<&str>, - service_tier: Option<&str>, -) { - let upstream_model = upstream_model.trim(); - let requested_model = requested_model - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(upstream_model); - object.insert( - "requestedModel".to_string(), - Value::String(requested_model.to_string()), - ); - object.insert( - "upstreamModel".to_string(), - Value::String(upstream_model.to_string()), - ); - - if let Some(client_ip) = client_ip.map(str::trim).filter(|value| !value.is_empty()) { - object.insert("clientIp".to_string(), Value::String(client_ip.to_string())); - } - - if let Some(user_agent) = client_user_agent - .map(str::trim) - .filter(|value| !value.is_empty()) - { - object.insert( - "userAgent".to_string(), - Value::String(user_agent.to_string()), - ); - } - - if let Some(reasoning_effort) = reasoning_effort - .map(str::trim) - .filter(|value| !value.is_empty()) - { - object.insert( - "reasoningEffort".to_string(), - Value::String(reasoning_effort.to_string()), - ); - } - - if let Some(service_tier) = service_tier - .map(str::trim) - .filter(|value| !value.is_empty()) - { - object.insert( - "serviceTier".to_string(), - Value::String(service_tier.to_string()), - ); - } -} - -fn reasoning_effort_from_request( - request: &crate::upstream::protocol::responses::CodexResponsesRequest, -) -> Option<&str> { - request - .reasoning - .as_ref()? - .get("effort") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) + record_dispatch_error_event(DispatchErrorUsageRecord { + usage_records: record.usage_records, + request_id: record.request_id, + account_id: record.account_id, + route: record.route, + model: record.model, + started_at: record.started_at, + status_code: i64::from(record.error.http_status_code()), + message: "chat dispatch failed", + metadata, + }) + .await; } fn enrich_chat_error_metadata( @@ -789,7 +669,3 @@ fn enrich_chat_error_metadata( object.insert("upstreamStatus".to_string(), serde_json::json!(status)); } } - -fn elapsed_millis_i64(started_at: Instant) -> i64 { - started_at.elapsed().as_millis().min(i64::MAX as u128) as i64 -} diff --git a/backend/src/proxy/dispatch/mod.rs b/backend/src/proxy/dispatch/mod.rs index 69e19da77..fb3046557 100644 --- a/backend/src/proxy/dispatch/mod.rs +++ b/backend/src/proxy/dispatch/mod.rs @@ -1,5 +1,6 @@ //! 请求调度模块(账号选择、回退、恢复、会话亲和性)。 +mod auth_recovery; pub mod chat; pub mod cloudflare; pub mod errors; @@ -8,3 +9,4 @@ pub mod reasoning_replay; pub mod responses; pub mod session_affinity; pub mod upstream; +mod usage_events; diff --git a/backend/src/proxy/dispatch/responses.rs b/backend/src/proxy/dispatch/responses.rs index caa73c5f5..f6bedfdab 100644 --- a/backend/src/proxy/dispatch/responses.rs +++ b/backend/src/proxy/dispatch/responses.rs @@ -7,26 +7,24 @@ //! - 账号回退与错误恢复 //! - 配额验证 -use std::{ - pin::Pin, - sync::Arc, - task::{Context, Poll}, - time::Instant, -}; +use std::{pin::Pin, sync::Arc, time::Instant}; +use axum::body::Bytes; use chrono::{DateTime, Duration, Utc}; use futures::{stream::Stream, StreamExt}; use serde_json::{json, Value}; use thiserror::Error; -use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::sync::Mutex; use tracing; use crate::{ admin::monitoring::{ - usage_record::{ResponseUsageRecord, UsageRecord, UsageRecordLevel}, - usage_record_store::AdminUsageRecordService, + usage_record_model::{ResponseUsageRecord, UsageRecord, UsageRecordLevel}, + usage_record_service::AdminUsageRecordService, }, + infra::time::elapsed_millis_i64, proxy::dispatch::{ + auth_recovery::trigger_refresh_after_auth_failure, cloudflare::{ cloudflare_challenge_error_message, cloudflare_path_block_error_message, is_cloudflare_challenge_upstream_error, is_cloudflare_path_block_upstream_error, @@ -34,11 +32,10 @@ use crate::{ }, errors::{ auth_failure_account_status, backend_transport_name, is_auth_upstream_error, - is_history_recovery_signal, is_history_recovery_upstream_error, - is_invalid_encrypted_content_signal, is_model_unsupported_signal, - is_model_unsupported_upstream_error, is_quota_exhausted_upstream_error, - is_rate_limit_upstream_error, rate_limit_cooldown_until, upstream_error_body, - upstream_error_http_status, upstream_error_set_cookie_headers, + is_history_recovery_upstream_error, is_model_unsupported_upstream_error, + is_quota_exhausted_upstream_error, is_rate_limit_upstream_error, + rate_limit_cooldown_until, upstream_error_body, upstream_error_http_status, + upstream_error_set_cookie_headers, }, reasoning_replay::ReasoningReplayCache, session_affinity::{ @@ -52,6 +49,12 @@ use crate::{ QuotaVerificationContext, QuotaVerificationDecision, QUOTA_VERIFY_LIMIT_REACHED_MESSAGE, }, + usage_events::{ + enrich_event_route_metadata, enrich_usage_record_identity, + event_kind as response_event_kind, reasoning_effort_from_compact_request, + reasoning_effort_from_request, record_dispatch_error_event, record_response_event, + DispatchErrorUsageRecord, + }, }, upstream::accounts::{ model::{Account, AccountStatus}, @@ -63,20 +66,15 @@ use crate::{ protocol::{ events::{extract_sse_usage, extract_usage, TokenUsage}, responses::{ - apply_response_model_options, completed_response_metadata, - reconvert_responses_sse_event_tuple_values, response_body_has_first_event, - response_from_codex_sse, CodexCompactRequest, CodexResponsesRequest, - CollectedResponse, ResponsesSseFailure, - }, - sse::{ - encode_sse_event, parse_sse_events, sse_body_has_done, SseError, DONE_SSE_FRAME, + apply_response_model_options, completed_response_metadata, response_from_codex_sse, + CodexCompactRequest, CodexResponsesRequest, CollectedResponse, ResponsesSseFailure, }, + sse::{parse_sse_events, sse_frame_end, SseError}, }, token_client::OpenAiTokenClient, transport::{ - backend_transport_for_response_request, is_banned_auth_signal, - is_banned_upstream_error, CodexBackendClient, CodexBackendResponse, - CodexBackendSseStream, CodexBackendTransport, CodexClientError, + backend_transport_for_response_request, is_banned_upstream_error, CodexBackendClient, + CodexBackendResponse, CodexBackendSseStream, CodexBackendTransport, CodexClientError, CodexRateLimitHeaderUpdates, CodexTurnStateUpdate, WebSocketPoolDecision, }, }, @@ -88,6 +86,18 @@ use super::implicit_resume::{ use crate::proxy::openai::responses::response_failed_sse_event_with_id; +mod live_stream; +use live_stream::spawn_live_response_stream; +mod sse_failure; +use sse_failure::{ + auth_sse_failure_account_status, client_error_invalid_reasoning_replay, first_sse_failure, + is_auth_sse_failure, is_history_recovery_sse_failure, is_model_unsupported_sse_failure, + is_quota_exhausted_sse_failure, sse_failure_error_body, sse_failure_invalid_reasoning_replay, + status_code_for_stream_failure, stream_failure_http_status, stream_failure_metadata, + stream_failure_source, synthetic_stream_disconnected_detail, STREAM_DISCONNECTED_CODE, + STREAM_DISCONNECTED_MESSAGE, +}; + #[derive(Clone, Copy)] enum ExhaustedAccountClass { QuotaExhausted, @@ -138,29 +148,6 @@ pub struct ResponseDispatchStream { pub body: ResponseBodyStream, } -struct MpscResponseBodyStream { - receiver: mpsc::Receiver>, - cancel: Option>, -} - -impl Drop for MpscResponseBodyStream { - fn drop(&mut self) { - if let Some(cancel) = self.cancel.take() { - let _ = cancel.send(()); - } - } -} - -impl Stream for MpscResponseBodyStream { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.receiver.poll_recv(cx) - } -} - -use axum::body::Bytes; - impl ResponseDispatchService { pub(crate) fn new(parts: ResponseDispatchServiceParts) -> Self { Self { @@ -178,24 +165,6 @@ impl ResponseDispatchService { } } - fn trigger_refresh_after_auth_failure(&self, account_id: &str, status: AccountStatus) { - if status != AccountStatus::Expired { - return; - } - - let token_refresh = self.token_refresh.clone(); - let account_id = account_id.to_string(); - tokio::spawn(async move { - if let Err(error) = token_refresh.trigger_account_refresh_now(&account_id).await { - tracing::warn!( - account_id = %account_id, - error = %error, - "reactive token refresh after upstream auth failure failed" - ); - } - }); - } - async fn prepare_response_session( &self, request: &mut CodexResponsesRequest, @@ -736,7 +705,8 @@ impl ResponseDispatchService { self.account_pool .set_status(&release_account_id, account_status) .await; - self.trigger_refresh_after_auth_failure( + trigger_refresh_after_auth_failure( + &self.token_refresh, &release_account_id, account_status, ); @@ -800,7 +770,11 @@ impl ResponseDispatchService { self.account_pool .set_status(&release_account_id, account_status) .await; - self.trigger_refresh_after_auth_failure(&release_account_id, account_status); + trigger_refresh_after_auth_failure( + &self.token_refresh, + &release_account_id, + account_status, + ); excluded_account_ids.push(release_account_id); } Err(error) if is_cloudflare_challenge_upstream_error(&error) => { @@ -1368,7 +1342,8 @@ impl ResponseDispatchService { self.account_pool .set_status(&release_account_id, account_status) .await; - self.trigger_refresh_after_auth_failure( + trigger_refresh_after_auth_failure( + &self.token_refresh, &release_account_id, account_status, ); @@ -1481,7 +1456,11 @@ impl ResponseDispatchService { self.account_pool .set_status(&release_account_id, account_status) .await; - self.trigger_refresh_after_auth_failure(&release_account_id, account_status); + trigger_refresh_after_auth_failure( + &self.token_refresh, + &release_account_id, + account_status, + ); excluded_account_ids.push(release_account_id); } Err(error) if is_cloudflare_challenge_upstream_error(&error) => { @@ -1872,7 +1851,11 @@ impl ResponseDispatchService { self.account_pool .set_status(&release_account_id, account_status) .await; - self.trigger_refresh_after_auth_failure(&release_account_id, account_status); + trigger_refresh_after_auth_failure( + &self.token_refresh, + &release_account_id, + account_status, + ); excluded_account_ids.push(release_account_id); } Err(error) if is_cloudflare_challenge_upstream_error(&error) => { @@ -1964,91 +1947,6 @@ impl ResponseDispatchService { } } -fn sse_failure_error_body(failure: &ResponsesSseFailure) -> String { - match failure.upstream_code.as_deref() { - Some(code) => serde_json::json!({ - "error": { - "code": code, - "message": failure.message.as_str(), - } - }) - .to_string(), - None => failure.message.clone(), - } -} - -fn is_quota_exhausted_sse_failure(failure: &ResponsesSseFailure) -> bool { - failure - .upstream_code - .as_deref() - .is_some_and(|code| matches!(code, "quota_exceeded" | "insufficient_quota")) - || failure.message.to_ascii_lowercase().contains("quota") -} - -fn is_auth_sse_failure(failure: &ResponsesSseFailure) -> bool { - failure.upstream_code.as_deref().is_some_and(|code| { - let code = code.to_ascii_lowercase(); - matches!( - code.as_str(), - "token_invalid" - | "token_expired" - | "token_revoked" - | "account_deactivated" - | "unauthorized" - | "invalid_api_key" - ) - }) || { - let message = failure.message.to_ascii_lowercase(); - message.contains("token revoked") - || message.contains("token invalid") - || message.contains("token expired") - } -} - -fn is_model_unsupported_sse_failure(failure: &ResponsesSseFailure) -> bool { - failure - .upstream_code - .as_deref() - .is_some_and(is_model_unsupported_signal) - || is_model_unsupported_signal(&failure.message) -} - -fn is_history_recovery_sse_failure(failure: &ResponsesSseFailure) -> bool { - failure - .upstream_code - .as_deref() - .is_some_and(is_history_recovery_signal) - || is_history_recovery_signal(&failure.message) -} - -fn sse_failure_invalid_reasoning_replay(failure: &ResponsesSseFailure) -> bool { - failure - .upstream_code - .as_deref() - .is_some_and(is_invalid_encrypted_content_signal) - || is_invalid_encrypted_content_signal(&failure.message) -} - -fn client_error_invalid_reasoning_replay(error: &CodexClientError) -> bool { - matches!( - error, - CodexClientError::Upstream { body, .. } if is_invalid_encrypted_content_signal(body) - ) -} - -fn auth_sse_failure_account_status(failure: &ResponsesSseFailure) -> AccountStatus { - if failure - .upstream_code - .as_deref() - .is_some_and(is_banned_auth_signal) - || is_banned_auth_signal(&failure.message) - { - AccountStatus::Banned - } else { - AccountStatus::Expired - } -} - fn strip_request_history(request: &mut CodexResponsesRequest) { request.previous_response_id = None; request.turn_state = None; @@ -2060,7 +1958,7 @@ async fn prefetch_first_sse_chunk( mut body: CodexBackendSseStream, ) -> Result<(Bytes, CodexBackendSseStream), ResponseDispatchError> { let mut prefetched = Vec::new(); - while !contains_sse_event_separator(&prefetched) { + while sse_frame_end(&prefetched).is_none() { let Some(next) = body.next().await else { if prefetched.is_empty() { return Err(ResponseDispatchError::EmptyUpstreamResponse); @@ -2081,21 +1979,6 @@ async fn prefetch_first_sse_chunk( Ok((Bytes::from(prefetched), body)) } -fn contains_sse_event_separator(bytes: &[u8]) -> bool { - bytes.windows(2).any(|window| window == b"\n\n") - || bytes.windows(4).any(|window| window == b"\r\n\r\n") -} - -fn first_sse_failure(prefetched: &[u8]) -> Result, SseError> { - let body = String::from_utf8_lossy(prefetched); - match response_from_codex_sse(&body, None)? { - CollectedResponse::Failed(failure) => Ok(Some(failure)), - CollectedResponse::Completed(_) - | CollectedResponse::MissingCompleted - | CollectedResponse::Empty => Ok(None), - } -} - struct LiveResponseStreamContext { account_pool: Arc, session_affinity: Arc, @@ -2121,296 +2004,6 @@ struct LiveResponseStreamContext { started_at: Instant, } -fn spawn_live_response_stream( - context: LiveResponseStreamContext, - prefetched: Bytes, - mut body: CodexBackendSseStream, -) -> ResponseDispatchStream { - let (sender, receiver) = mpsc::channel(8); - let (cancel_sender, mut cancel_receiver) = oneshot::channel(); - tokio::spawn(async move { - let mut tuple_transformer = context - .tuple_schema - .clone() - .map(TupleSseEventTransformer::new); - let mut body_bytes = Vec::new(); - let mut first_token_ms = None; - if !send_live_response_stream_chunk( - &sender, - &mut body_bytes, - tuple_transformer.as_mut(), - prefetched, - ) - .await - { - context.account_pool.release(&context.account_id).await; - return; - } - update_first_token_ms(&context, &body_bytes, &mut first_token_ms); - - loop { - let next = tokio::select! { - _ = &mut cancel_receiver => { - context.account_pool.release(&context.account_id).await; - return; - } - next = body.next() => next, - }; - let Some(next) = next else { - break; - }; - match next { - Ok(chunk) => { - if !send_live_response_stream_chunk( - &sender, - &mut body_bytes, - tuple_transformer.as_mut(), - chunk, - ) - .await - { - context.account_pool.release(&context.account_id).await; - return; - } - update_first_token_ms(&context, &body_bytes, &mut first_token_ms); - } - Err(error) => { - if !flush_live_response_stream_transformer( - &sender, - &mut body_bytes, - tuple_transformer.as_mut(), - ) - .await - { - context.account_pool.release(&context.account_id).await; - return; - } - let detail = error.to_string(); - let Some(body_text) = - send_live_response_stream_tail(&sender, &mut body_bytes, Some(&detail)) - .await - else { - context.account_pool.release(&context.account_id).await; - return; - }; - finalize_live_response_stream(context, body_text, first_token_ms).await; - return; - } - } - } - - if !flush_live_response_stream_transformer( - &sender, - &mut body_bytes, - tuple_transformer.as_mut(), - ) - .await - { - context.account_pool.release(&context.account_id).await; - return; - } - let Some(body_text) = send_live_response_stream_tail(&sender, &mut body_bytes, None).await - else { - context.account_pool.release(&context.account_id).await; - return; - }; - - finalize_live_response_stream(context, body_text, first_token_ms).await; - }); - - ResponseDispatchStream { - body: Box::pin(MpscResponseBodyStream { - receiver, - cancel: Some(cancel_sender), - }), - } -} - -fn update_first_token_ms( - context: &LiveResponseStreamContext, - body_bytes: &[u8], - first_token_ms: &mut Option, -) { - if first_token_ms.is_none() && response_body_has_first_event(body_bytes) { - *first_token_ms = Some(elapsed_millis_i64(context.started_at).max(1)); - } -} - -async fn send_live_response_stream_chunk( - sender: &mpsc::Sender>, - body_bytes: &mut Vec, - transformer: Option<&mut TupleSseEventTransformer>, - chunk: Bytes, -) -> bool { - let chunks = match transformer { - Some(transformer) => transformer.push(&chunk), - None => vec![chunk], - }; - send_live_response_stream_chunks(sender, body_bytes, chunks).await -} - -async fn flush_live_response_stream_transformer( - sender: &mpsc::Sender>, - body_bytes: &mut Vec, - transformer: Option<&mut TupleSseEventTransformer>, -) -> bool { - let Some(transformer) = transformer else { - return true; - }; - send_live_response_stream_chunks(sender, body_bytes, transformer.finish()).await -} - -async fn send_live_response_stream_chunks( - sender: &mpsc::Sender>, - body_bytes: &mut Vec, - chunks: Vec, -) -> bool { - for chunk in chunks { - body_bytes.extend_from_slice(&chunk); - if sender.send(Ok(chunk)).await.is_err() { - return false; - } - } - true -} - -struct TupleSseEventTransformer { - tuple_schema: Value, - pending: Vec, -} - -impl TupleSseEventTransformer { - fn new(tuple_schema: Value) -> Self { - Self { - tuple_schema, - pending: Vec::new(), - } - } - - fn push(&mut self, chunk: &[u8]) -> Vec { - self.pending.extend_from_slice(chunk); - let mut chunks = Vec::new(); - while let Some(frame_end) = next_sse_frame_end(&self.pending) { - let frame = self.pending.drain(..frame_end).collect::>(); - chunks.push(self.transform_frame(&frame)); - } - chunks - } - - fn finish(&mut self) -> Vec { - if self.pending.is_empty() { - return Vec::new(); - } - let frame = std::mem::take(&mut self.pending); - vec![self.transform_frame(&frame)] - } - - fn transform_frame(&self, frame: &[u8]) -> Bytes { - let frame_text = String::from_utf8_lossy(frame); - let Ok(events) = parse_sse_events(&frame_text) else { - return Bytes::copy_from_slice(frame); - }; - let [event] = events.as_slice() else { - return Bytes::copy_from_slice(frame); - }; - let Ok(data) = serde_json::from_str::(&event.data) else { - return Bytes::copy_from_slice(frame); - }; - let transformed = reconvert_responses_sse_event_tuple_values( - event.event.as_deref(), - data, - &self.tuple_schema, - ); - Bytes::from(encode_sse_event( - event.event.as_deref().unwrap_or_default(), - &transformed.to_string(), - )) - } -} - -fn next_sse_frame_end(bytes: &[u8]) -> Option { - let lf_lf = bytes - .windows(2) - .position(|window| window == b"\n\n") - .map(|position| position + 2); - let crlf_crlf = bytes - .windows(4) - .position(|window| window == b"\r\n\r\n") - .map(|position| position + 4); - match (lf_lf, crlf_crlf) { - (Some(left), Some(right)) => Some(left.min(right)), - (Some(end), None) | (None, Some(end)) => Some(end), - (None, None) => None, - } -} - -async fn send_live_response_stream_tail( - sender: &mpsc::Sender>, - body_bytes: &mut Vec, - failure_detail: Option<&str>, -) -> Option { - let mut body_text = String::from_utf8_lossy(body_bytes).to_string(); - if !sse_body_has_terminal_event(&body_text) { - if let Some(separator) = missing_sse_event_separator(&body_text) { - body_text.push_str(separator); - body_bytes.extend_from_slice(separator.as_bytes()); - if sender - .send(Ok(Bytes::copy_from_slice(separator.as_bytes()))) - .await - .is_err() - { - return None; - } - } - let failure = - premature_close_failed_event(latest_response_id(&body_text).as_deref(), failure_detail); - body_text.push_str(&failure); - body_bytes.extend_from_slice(failure.as_bytes()); - if sender.send(Ok(Bytes::from(failure))).await.is_err() { - return None; - } - } - - if !sse_body_has_done(&body_text) { - body_text.push_str(DONE_SSE_FRAME); - body_bytes.extend_from_slice(DONE_SSE_FRAME.as_bytes()); - if sender - .send(Ok(Bytes::from_static(DONE_SSE_FRAME.as_bytes()))) - .await - .is_err() - { - return None; - } - } - - Some(body_text) -} - -fn sse_body_has_terminal_event(body: &str) -> bool { - parse_sse_events(body).is_ok_and(|events| { - events.iter().any(|event| { - matches!( - event.event.as_deref(), - Some("response.completed" | "response.failed" | "error") - ) - }) - }) -} - -fn missing_sse_event_separator(body: &str) -> Option<&'static str> { - if body.is_empty() - || body.ends_with("\n\n") - || body.ends_with("\r\n\r\n") - || body.ends_with("\r\r") - { - None - } else if body.ends_with('\n') || body.ends_with('\r') { - Some("\n") - } else { - Some("\n\n") - } -} - fn latest_response_id(body: &str) -> Option { parse_sse_events(body).ok().and_then(|events| { events.iter().rev().find_map(|event| { @@ -2426,9 +2019,6 @@ fn latest_response_id(body: &str) -> Option { }) } -const STREAM_DISCONNECTED_CODE: &str = "stream_disconnected"; -const STREAM_DISCONNECTED_MESSAGE: &str = "Upstream stream closed before response.completed"; - fn premature_close_failed_event(response_id: Option<&str>, detail: Option<&str>) -> String { let message = match detail.filter(|value| !value.trim().is_empty()) { Some(detail) => format!("{STREAM_DISCONNECTED_MESSAGE}: {detail}"), @@ -2660,26 +2250,18 @@ async fn record_response_dispatch_error_event(record: ResponseDispatchErrorEvent record.transport, ); enrich_response_dispatch_error_metadata(&mut metadata, record.error); - enrich_event_route_metadata(&mut metadata, record.route); - let mut event = UsageRecord::new( - response_event_kind(record.route), - UsageRecordLevel::Error, - "v1 responses dispatch failed", - ); - event.request_id = Some(record.request_id.to_string()); - event.account_id = record.account_id.map(ToString::to_string); - event.route = Some(record.route.to_string()); - event.model = Some(record.model.to_string()); - event.status_code = Some(i64::from(record.error.http_status_code())); - event.latency_ms = Some(elapsed_millis_i64(record.started_at)); - event.metadata = metadata; - if let Err(error) = record.usage_records.record(event).await { - tracing::warn!( - account_id = record.account_id.unwrap_or(""), - error = %error, - "failed to record response dispatch error event" - ); - } + record_dispatch_error_event(DispatchErrorUsageRecord { + usage_records: record.usage_records, + request_id: record.request_id, + account_id: record.account_id, + route: record.route, + model: record.model, + started_at: record.started_at, + status_code: i64::from(record.error.http_status_code()), + message: "v1 responses dispatch failed", + metadata, + }) + .await; } async fn record_response_upstream_error_event(record: ResponseUpstreamErrorEventRecord<'_>) { @@ -2757,150 +2339,6 @@ async fn record_prefetched_response_stream_failure_event( .await; } -async fn record_response_event(record: ResponseUsageRecord<'_>) { - let mut metadata = record.metadata; - enrich_event_route_metadata(&mut metadata, record.route); - let mut event = UsageRecord::new( - response_event_kind(record.route), - record.level, - record.message, - ); - event.request_id = Some(record.request_id.to_string()); - event.account_id = Some(record.account_id.to_string()); - event.route = Some(record.route.to_string()); - event.model = Some(record.model.to_string()); - event.status_code = Some(record.status_code); - event.latency_ms = Some(elapsed_millis_i64(record.started_at)); - enrich_usage_record_identity( - &mut metadata, - record.requested_model, - record.model, - record.client_ip, - record.client_user_agent, - record.reasoning_effort, - record.service_tier, - ); - event.metadata = metadata; - let rate_limit_headers = record.rate_limit_headers; - if !rate_limit_headers.is_empty() { - if let Some(object) = event.metadata.as_object_mut() { - object.insert( - "rateLimitHeaders".to_string(), - serde_json::json!(rate_limit_headers), - ); - } - } - if let Err(error) = record.usage_records.record(event).await { - tracing::warn!(account_id = %record.account_id, error = %error, "failed to record response event"); - } -} - -fn response_event_kind(route: &str) -> &'static str { - if route == "/v1/chat/completions" { - "v1.chat" - } else { - "v1.response" - } -} - -fn response_api_kind(route: &str) -> &'static str { - if route == "/v1/chat/completions" { - "chat" - } else { - "responses" - } -} - -fn enrich_event_route_metadata(metadata: &mut Value, route: &str) { - let Some(object) = metadata.as_object_mut() else { - return; - }; - object - .entry("route".to_string()) - .or_insert_with(|| Value::String(route.to_string())); - object - .entry("apiKind".to_string()) - .or_insert_with(|| Value::String(response_api_kind(route).to_string())); -} - -fn enrich_usage_record_identity( - metadata: &mut Value, - requested_model: Option<&str>, - upstream_model: &str, - client_ip: Option<&str>, - client_user_agent: Option<&str>, - reasoning_effort: Option<&str>, - service_tier: Option<&str>, -) { - let Some(object) = metadata.as_object_mut() else { - return; - }; - - let upstream_model = upstream_model.trim(); - let requested_model = requested_model - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(upstream_model); - object.insert( - "requestedModel".to_string(), - Value::String(requested_model.to_string()), - ); - object.insert( - "upstreamModel".to_string(), - Value::String(upstream_model.to_string()), - ); - - if let Some(client_ip) = client_ip.map(str::trim).filter(|value| !value.is_empty()) { - object.insert("clientIp".to_string(), Value::String(client_ip.to_string())); - } - - if let Some(user_agent) = client_user_agent - .map(str::trim) - .filter(|value| !value.is_empty()) - { - object.insert( - "userAgent".to_string(), - Value::String(user_agent.to_string()), - ); - } - - if let Some(reasoning_effort) = reasoning_effort - .map(str::trim) - .filter(|value| !value.is_empty()) - { - object.insert( - "reasoningEffort".to_string(), - Value::String(reasoning_effort.to_string()), - ); - } - - if let Some(service_tier) = service_tier - .map(str::trim) - .filter(|value| !value.is_empty()) - { - object.insert( - "serviceTier".to_string(), - Value::String(service_tier.to_string()), - ); - } -} - -fn reasoning_effort_from_request(request: &CodexResponsesRequest) -> Option<&str> { - reasoning_effort_from_value(request.reasoning.as_ref()) -} - -fn reasoning_effort_from_compact_request(request: &CodexCompactRequest) -> Option<&str> { - reasoning_effort_from_value(request.reasoning.as_ref()) -} - -fn reasoning_effort_from_value(reasoning: Option<&Value>) -> Option<&str> { - reasoning? - .get("effort") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) -} - fn ensure_stream_metadata_flag(metadata: &mut Value) { let Some(object) = metadata.as_object_mut() else { *metadata = serde_json::json!({ "stream": true }); @@ -3066,57 +2504,6 @@ async fn live_response_turn_state(context: &LiveResponseStreamContext) -> Option context.turn_state.clone() } -fn stream_failure_metadata(failure: &ResponsesSseFailure, usage: Option) -> Value { - let mut metadata = serde_json::json!({ - "stream": true, - "failed": true, - "failureEvent": failure.event, - "failureMessage": failure.message, - "upstreamCode": failure.upstream_code, - "usage": usage, - }); - enrich_stream_failure_source_metadata(&mut metadata, failure); - metadata -} - -fn enrich_stream_failure_source_metadata(metadata: &mut Value, failure: &ResponsesSseFailure) { - let Some(object) = metadata.as_object_mut() else { - return; - }; - object.insert( - "failureSource".to_string(), - Value::String(stream_failure_source(failure).to_string()), - ); - if let Some(detail) = synthetic_stream_disconnected_detail(failure) { - object.insert("synthetic".to_string(), Value::Bool(true)); - if !detail.is_empty() { - object.insert("failureDetail".to_string(), Value::String(detail)); - } - } -} - -fn stream_failure_source(failure: &ResponsesSseFailure) -> &'static str { - if synthetic_stream_disconnected_detail(failure).is_some() { - "proxy" - } else { - "upstream" - } -} - -fn synthetic_stream_disconnected_detail(failure: &ResponsesSseFailure) -> Option { - if failure.upstream_code.as_deref() != Some(STREAM_DISCONNECTED_CODE) { - return None; - } - let detail = failure - .message - .strip_prefix(STREAM_DISCONNECTED_MESSAGE)? - .strip_prefix(": ") - .unwrap_or_default() - .trim() - .to_string(); - Some(detail) -} - fn insert_first_token_ms(metadata: &mut Value, first_token_ms: Option) { let Some(first_token_ms) = first_token_ms else { return; @@ -3138,53 +2525,6 @@ fn insert_websocket_pool_decision(metadata: &mut Value, decision: Option i64 { - let code = failure - .upstream_code - .as_deref() - .unwrap_or("error") - .to_ascii_lowercase(); - if code.contains("model") && (code.contains("not_supported") || code.contains("not_available")) - { - return 400; - } - if code.contains("invalid_request") || code.contains("not_found") { - return 400; - } - if code.contains("context_window") - || code.contains("invalid_prompt") - || code.contains("cyber_policy") - || code.contains("bad_request") - { - return 400; - } - if code.contains("rate_limit") || code.contains("usage_limit") { - return 429; - } - if code.contains("unauthorized") - || code.contains("invalid_api_key") - || code == "token_invalid" - || code == "token_expired" - || code == "account_deactivated" - { - return 401; - } - if code.contains("forbidden") || code.contains("banned") { - return 403; - } - if code.contains("payment") || code.contains("quota") { - return 402; - } - if code.contains("server_overloaded") { - return 503; - } - 502 -} - -fn stream_failure_http_status(failure: &ResponsesSseFailure) -> u16 { - u16::try_from(status_code_for_stream_failure(failure)).unwrap_or(502) -} - // ==================================================================== // Affinity + replay recording // ==================================================================== @@ -3494,7 +2834,3 @@ fn enrich_response_dispatch_error_metadata(metadata: &mut Value, error: &Respons object.insert("upstreamStatus".to_string(), json!(status)); } } - -fn elapsed_millis_i64(started_at: Instant) -> i64 { - started_at.elapsed().as_millis().min(i64::MAX as u128) as i64 -} diff --git a/backend/src/proxy/dispatch/responses/live_stream.rs b/backend/src/proxy/dispatch/responses/live_stream.rs new file mode 100644 index 000000000..6cc25e31d --- /dev/null +++ b/backend/src/proxy/dispatch/responses/live_stream.rs @@ -0,0 +1,317 @@ +use std::{ + pin::Pin, + task::{Context, Poll}, +}; + +use axum::body::Bytes; +use futures::{stream::Stream, StreamExt}; +use serde_json::Value; +use tokio::sync::{mpsc, oneshot}; + +use crate::{ + proxy::dispatch::responses::{ + finalize_live_response_stream, latest_response_id, premature_close_failed_event, + LiveResponseStreamContext, ResponseDispatchStream, ResponseDispatchStreamError, + }, + upstream::{ + protocol::{ + responses::{ + reconvert_responses_sse_event_tuple_values, update_first_response_event_ms, + }, + sse::{ + encode_sse_event, parse_sse_events, sse_body_has_done, sse_frame_end, + DONE_SSE_FRAME, + }, + }, + transport::CodexBackendSseStream, + }, +}; + +struct MpscResponseBodyStream { + receiver: mpsc::Receiver>, + cancel: Option>, +} + +impl Drop for MpscResponseBodyStream { + fn drop(&mut self) { + if let Some(cancel) = self.cancel.take() { + let _ = cancel.send(()); + } + } +} + +impl Stream for MpscResponseBodyStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.receiver.poll_recv(cx) + } +} + +pub(super) fn spawn_live_response_stream( + context: LiveResponseStreamContext, + prefetched: Bytes, + mut body: CodexBackendSseStream, +) -> ResponseDispatchStream { + let (sender, receiver) = mpsc::channel(8); + let (cancel_sender, mut cancel_receiver) = oneshot::channel(); + tokio::spawn(async move { + let mut tuple_transformer = context + .tuple_schema + .clone() + .map(TupleSseEventTransformer::new); + let mut body_bytes = Vec::new(); + let mut first_token_ms = None; + if !send_live_response_stream_chunk( + &sender, + &mut body_bytes, + tuple_transformer.as_mut(), + prefetched, + ) + .await + { + context.account_pool.release(&context.account_id).await; + return; + } + update_first_response_event_ms(context.started_at, &body_bytes, &mut first_token_ms); + + loop { + let next = tokio::select! { + _ = &mut cancel_receiver => { + context.account_pool.release(&context.account_id).await; + return; + } + next = body.next() => next, + }; + let Some(next) = next else { + break; + }; + match next { + Ok(chunk) => { + if !send_live_response_stream_chunk( + &sender, + &mut body_bytes, + tuple_transformer.as_mut(), + chunk, + ) + .await + { + context.account_pool.release(&context.account_id).await; + return; + } + update_first_response_event_ms( + context.started_at, + &body_bytes, + &mut first_token_ms, + ); + } + Err(error) => { + if !flush_live_response_stream_transformer( + &sender, + &mut body_bytes, + tuple_transformer.as_mut(), + ) + .await + { + context.account_pool.release(&context.account_id).await; + return; + } + let detail = error.to_string(); + let Some(body_text) = + send_live_response_stream_tail(&sender, &mut body_bytes, Some(&detail)) + .await + else { + context.account_pool.release(&context.account_id).await; + return; + }; + finalize_live_response_stream(context, body_text, first_token_ms).await; + return; + } + } + } + + if !flush_live_response_stream_transformer( + &sender, + &mut body_bytes, + tuple_transformer.as_mut(), + ) + .await + { + context.account_pool.release(&context.account_id).await; + return; + } + let Some(body_text) = send_live_response_stream_tail(&sender, &mut body_bytes, None).await + else { + context.account_pool.release(&context.account_id).await; + return; + }; + + finalize_live_response_stream(context, body_text, first_token_ms).await; + }); + + ResponseDispatchStream { + body: Box::pin(MpscResponseBodyStream { + receiver, + cancel: Some(cancel_sender), + }), + } +} + +async fn send_live_response_stream_chunk( + sender: &mpsc::Sender>, + body_bytes: &mut Vec, + transformer: Option<&mut TupleSseEventTransformer>, + chunk: Bytes, +) -> bool { + let chunks = match transformer { + Some(transformer) => transformer.push(&chunk), + None => vec![chunk], + }; + send_live_response_stream_chunks(sender, body_bytes, chunks).await +} + +async fn flush_live_response_stream_transformer( + sender: &mpsc::Sender>, + body_bytes: &mut Vec, + transformer: Option<&mut TupleSseEventTransformer>, +) -> bool { + let Some(transformer) = transformer else { + return true; + }; + send_live_response_stream_chunks(sender, body_bytes, transformer.finish()).await +} + +async fn send_live_response_stream_chunks( + sender: &mpsc::Sender>, + body_bytes: &mut Vec, + chunks: Vec, +) -> bool { + for chunk in chunks { + body_bytes.extend_from_slice(&chunk); + if sender.send(Ok(chunk)).await.is_err() { + return false; + } + } + true +} + +struct TupleSseEventTransformer { + tuple_schema: Value, + pending: Vec, +} + +impl TupleSseEventTransformer { + fn new(tuple_schema: Value) -> Self { + Self { + tuple_schema, + pending: Vec::new(), + } + } + + fn push(&mut self, chunk: &[u8]) -> Vec { + self.pending.extend_from_slice(chunk); + let mut chunks = Vec::new(); + while let Some(frame_end) = sse_frame_end(&self.pending) { + let frame = self.pending.drain(..frame_end).collect::>(); + chunks.push(self.transform_frame(&frame)); + } + chunks + } + + fn finish(&mut self) -> Vec { + if self.pending.is_empty() { + return Vec::new(); + } + let frame = std::mem::take(&mut self.pending); + vec![self.transform_frame(&frame)] + } + + fn transform_frame(&self, frame: &[u8]) -> Bytes { + let frame_text = String::from_utf8_lossy(frame); + let Ok(events) = parse_sse_events(&frame_text) else { + return Bytes::copy_from_slice(frame); + }; + let [event] = events.as_slice() else { + return Bytes::copy_from_slice(frame); + }; + let Ok(data) = serde_json::from_str::(&event.data) else { + return Bytes::copy_from_slice(frame); + }; + let transformed = reconvert_responses_sse_event_tuple_values( + event.event.as_deref(), + data, + &self.tuple_schema, + ); + Bytes::from(encode_sse_event( + event.event.as_deref().unwrap_or_default(), + &transformed.to_string(), + )) + } +} + +async fn send_live_response_stream_tail( + sender: &mpsc::Sender>, + body_bytes: &mut Vec, + failure_detail: Option<&str>, +) -> Option { + let mut body_text = String::from_utf8_lossy(body_bytes).to_string(); + if !sse_body_has_terminal_event(&body_text) { + if let Some(separator) = missing_sse_event_separator(&body_text) { + body_text.push_str(separator); + body_bytes.extend_from_slice(separator.as_bytes()); + if sender + .send(Ok(Bytes::copy_from_slice(separator.as_bytes()))) + .await + .is_err() + { + return None; + } + } + let failure = + premature_close_failed_event(latest_response_id(&body_text).as_deref(), failure_detail); + body_text.push_str(&failure); + body_bytes.extend_from_slice(failure.as_bytes()); + if sender.send(Ok(Bytes::from(failure))).await.is_err() { + return None; + } + } + + if !sse_body_has_done(&body_text) { + body_text.push_str(DONE_SSE_FRAME); + body_bytes.extend_from_slice(DONE_SSE_FRAME.as_bytes()); + if sender + .send(Ok(Bytes::from_static(DONE_SSE_FRAME.as_bytes()))) + .await + .is_err() + { + return None; + } + } + + Some(body_text) +} + +fn sse_body_has_terminal_event(body: &str) -> bool { + parse_sse_events(body).is_ok_and(|events| { + events.iter().any(|event| { + matches!( + event.event.as_deref(), + Some("response.completed" | "response.failed" | "error") + ) + }) + }) +} + +fn missing_sse_event_separator(body: &str) -> Option<&'static str> { + if body.is_empty() + || body.ends_with("\n\n") + || body.ends_with("\r\n\r\n") + || body.ends_with("\r\r") + { + None + } else if body.ends_with('\n') || body.ends_with('\r') { + Some("\n") + } else { + Some("\n\n") + } +} diff --git a/backend/src/proxy/dispatch/responses/sse_failure.rs b/backend/src/proxy/dispatch/responses/sse_failure.rs new file mode 100644 index 000000000..05763e895 --- /dev/null +++ b/backend/src/proxy/dispatch/responses/sse_failure.rs @@ -0,0 +1,231 @@ +use serde_json::Value; + +use crate::{ + proxy::dispatch::errors::{ + is_history_recovery_signal, is_invalid_encrypted_content_signal, + is_model_unsupported_signal, + }, + upstream::{ + accounts::model::AccountStatus, + protocol::{ + events::TokenUsage, + responses::{response_from_codex_sse, CollectedResponse, ResponsesSseFailure}, + sse::SseError, + }, + transport::{is_banned_auth_signal, CodexClientError}, + }, +}; + +pub(super) const STREAM_DISCONNECTED_CODE: &str = "stream_disconnected"; +pub(super) const STREAM_DISCONNECTED_MESSAGE: &str = + "Upstream stream closed before response.completed"; + +pub(super) fn sse_failure_error_body(failure: &ResponsesSseFailure) -> String { + match failure.upstream_code.as_deref() { + Some(code) => serde_json::json!({ + "error": { + "code": code, + "message": failure.message.as_str(), + } + }) + .to_string(), + None => failure.message.clone(), + } +} + +fn sse_failure_matches(failure: &ResponsesSseFailure, signal: F) -> bool +where + F: Fn(&str) -> bool, +{ + failure.upstream_code.as_deref().is_some_and(&signal) || signal(&failure.message) +} + +fn sse_failure_matches_parts( + failure: &ResponsesSseFailure, + code_signal: C, + message_signal: M, +) -> bool +where + C: Fn(&str) -> bool, + M: Fn(&str) -> bool, +{ + failure.upstream_code.as_deref().is_some_and(code_signal) || message_signal(&failure.message) +} + +pub(super) fn is_quota_exhausted_sse_failure(failure: &ResponsesSseFailure) -> bool { + sse_failure_matches_parts( + failure, + |code| matches!(code, "quota_exceeded" | "insufficient_quota"), + |message| message.to_ascii_lowercase().contains("quota"), + ) +} + +fn is_auth_sse_failure_code(code: &str) -> bool { + let code = code.to_ascii_lowercase(); + matches!( + code.as_str(), + "token_invalid" + | "token_expired" + | "token_revoked" + | "account_deactivated" + | "unauthorized" + | "invalid_api_key" + ) +} + +fn is_auth_sse_failure_message(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("token revoked") + || message.contains("token invalid") + || message.contains("token expired") +} + +pub(super) fn is_auth_sse_failure(failure: &ResponsesSseFailure) -> bool { + sse_failure_matches_parts( + failure, + is_auth_sse_failure_code, + is_auth_sse_failure_message, + ) +} + +pub(super) fn is_model_unsupported_sse_failure(failure: &ResponsesSseFailure) -> bool { + sse_failure_matches(failure, is_model_unsupported_signal) +} + +pub(super) fn is_history_recovery_sse_failure(failure: &ResponsesSseFailure) -> bool { + sse_failure_matches(failure, is_history_recovery_signal) +} + +pub(super) fn sse_failure_invalid_reasoning_replay(failure: &ResponsesSseFailure) -> bool { + sse_failure_matches(failure, is_invalid_encrypted_content_signal) +} + +pub(super) fn client_error_invalid_reasoning_replay(error: &CodexClientError) -> bool { + matches!( + error, + CodexClientError::Upstream { body, .. } if is_invalid_encrypted_content_signal(body) + ) +} + +pub(super) fn auth_sse_failure_account_status(failure: &ResponsesSseFailure) -> AccountStatus { + if sse_failure_matches(failure, is_banned_auth_signal) { + AccountStatus::Banned + } else { + AccountStatus::Expired + } +} + +pub(super) fn first_sse_failure( + prefetched: &[u8], +) -> Result, SseError> { + let body = String::from_utf8_lossy(prefetched); + match response_from_codex_sse(&body, None)? { + CollectedResponse::Failed(failure) => Ok(Some(failure)), + CollectedResponse::Completed(_) + | CollectedResponse::MissingCompleted + | CollectedResponse::Empty => Ok(None), + } +} + +pub(super) fn stream_failure_metadata( + failure: &ResponsesSseFailure, + usage: Option, +) -> Value { + let mut metadata = serde_json::json!({ + "stream": true, + "failed": true, + "failureEvent": failure.event, + "failureMessage": failure.message, + "upstreamCode": failure.upstream_code, + "usage": usage, + }); + enrich_stream_failure_source_metadata(&mut metadata, failure); + metadata +} + +fn enrich_stream_failure_source_metadata(metadata: &mut Value, failure: &ResponsesSseFailure) { + let Some(object) = metadata.as_object_mut() else { + return; + }; + object.insert( + "failureSource".to_string(), + Value::String(stream_failure_source(failure).to_string()), + ); + if let Some(detail) = synthetic_stream_disconnected_detail(failure) { + object.insert("synthetic".to_string(), Value::Bool(true)); + if !detail.is_empty() { + object.insert("failureDetail".to_string(), Value::String(detail)); + } + } +} + +pub(super) fn stream_failure_source(failure: &ResponsesSseFailure) -> &'static str { + if synthetic_stream_disconnected_detail(failure).is_some() { + "proxy" + } else { + "upstream" + } +} + +pub(super) fn synthetic_stream_disconnected_detail( + failure: &ResponsesSseFailure, +) -> Option { + if failure.upstream_code.as_deref() != Some(STREAM_DISCONNECTED_CODE) { + return None; + } + let detail = failure + .message + .strip_prefix(STREAM_DISCONNECTED_MESSAGE)? + .strip_prefix(": ") + .unwrap_or_default() + .trim() + .to_string(); + Some(detail) +} + +pub(super) fn status_code_for_stream_failure(failure: &ResponsesSseFailure) -> i64 { + let code = failure + .upstream_code + .as_deref() + .unwrap_or("error") + .to_ascii_lowercase(); + if code.contains("model") && (code.contains("not_supported") || code.contains("not_available")) + { + return 400; + } + if code.contains("invalid_request") || code.contains("not_found") { + return 400; + } + if code.contains("context_window") + || code.contains("invalid_prompt") + || code.contains("cyber_policy") + || code.contains("bad_request") + { + return 400; + } + if code.contains("rate_limit") || code.contains("usage_limit") { + return 429; + } + if code.contains("unauthorized") + || code.contains("invalid_api_key") + || code == "token_invalid" + || code == "token_expired" + || code == "account_deactivated" + { + return 401; + } + if code.contains("forbidden") || code.contains("banned") { + return 403; + } + if code.contains("payment") || code.contains("quota") { + return 402; + } + if code.contains("server_overloaded") { + return 503; + } + 502 +} + +pub(super) fn stream_failure_http_status(failure: &ResponsesSseFailure) -> u16 { + u16::try_from(status_code_for_stream_failure(failure)).unwrap_or(502) +} diff --git a/backend/src/proxy/dispatch/session_affinity.rs b/backend/src/proxy/dispatch/session_affinity.rs index 9bf140b95..b4cec4210 100644 --- a/backend/src/proxy/dispatch/session_affinity.rs +++ b/backend/src/proxy/dispatch/session_affinity.rs @@ -9,7 +9,10 @@ use sqlx::SqlitePool; use thiserror::Error; use uuid::Uuid; -use crate::upstream::protocol::responses::CodexResponsesRequest; +use crate::{ + infra::time::parse_rfc3339_utc as parse_rfc3339, + upstream::protocol::responses::CodexResponsesRequest, +}; // ==================================================================== // 亲和性核心类型 @@ -518,10 +521,6 @@ fn stored_session_affinity_from_row( }) } -fn parse_rfc3339(value: &str) -> SqliteSessionAffinityStoreResult> { - Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc)) -} - fn optional_nonnegative_i64_to_u64(value: Option) -> Option { value.and_then(|value| u64::try_from(value).ok()) } diff --git a/backend/src/proxy/dispatch/upstream.rs b/backend/src/proxy/dispatch/upstream.rs index 7bb804289..76f8b0212 100644 --- a/backend/src/proxy/dispatch/upstream.rs +++ b/backend/src/proxy/dispatch/upstream.rs @@ -1,6 +1,6 @@ //! 调度层共享的账号级 Codex 上游调用。 -use std::time::Instant; +use std::{future::Future, time::Instant}; use serde_json::Value; @@ -254,6 +254,108 @@ async fn create_compact_response_with_account( const MAX_UPSTREAM_5XX_RETRIES_PER_ACCOUNT: usize = 2; +async fn retry_upstream_5xx( + request_id: &str, + account_id: &str, + endpoint: Option<&str>, + retry_message: &'static str, + failure_message: &'static str, + mut operation: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut retries = 0; + loop { + let result = operation().await; + match result { + Err(error) + if is_retryable_upstream_5xx_error(&error) + && retries < MAX_UPSTREAM_5XX_RETRIES_PER_ACCOUNT => + { + log_upstream_retry( + request_id, + account_id, + endpoint, + retries + 1, + &error, + retry_message, + ); + retries += 1; + } + Ok(response) => return Ok(response), + Err(error) => { + log_upstream_failure( + request_id, + account_id, + endpoint, + retries, + &error, + failure_message, + ); + return Err(error); + } + } + } +} + +fn log_upstream_retry( + request_id: &str, + account_id: &str, + endpoint: Option<&str>, + retry: usize, + error: &CodexClientError, + message: &'static str, +) { + if let Some(endpoint) = endpoint { + tracing::warn!( + request_id, + account_id = %account_id, + endpoint, + retry, + error = %error, + message + ); + } else { + tracing::warn!( + request_id, + account_id = %account_id, + retry, + error = %error, + message + ); + } +} + +fn log_upstream_failure( + request_id: &str, + account_id: &str, + endpoint: Option<&str>, + retries: usize, + error: &CodexClientError, + message: &'static str, +) { + if let Some(endpoint) = endpoint { + tracing::warn!( + request_id, + account_id = %account_id, + endpoint, + retries, + error = %error, + message + ); + } else { + tracing::warn!( + request_id, + account_id = %account_id, + retries, + error = %error, + message + ); + } +} + pub(crate) async fn create_response_with_account_retrying_5xx( codex: &CodexBackendClient, installation_id: Option<&str>, @@ -263,45 +365,25 @@ pub(crate) async fn create_response_with_account_retrying_5xx( account: &Account, started_at: Instant, ) -> Result { - let mut retries = 0; - loop { - let result = create_response_with_account( - codex, - installation_id, - cloudflare, - request, - request_id, - account, - started_at, - ) - .await; - match result { - Err(error) - if is_retryable_upstream_5xx_error(&error) - && retries < MAX_UPSTREAM_5XX_RETRIES_PER_ACCOUNT => - { - tracing::warn!( - request_id, - account_id = %account.id, - retry = retries + 1, - error = %error, - "upstream response request failed with retryable 5xx" - ); - retries += 1; - } - Ok(response) => return Ok(response), - Err(error) => { - tracing::warn!( - request_id, - account_id = %account.id, - retries, - error = %error, - "upstream response request failed" - ); - return Err(error); - } - } - } + retry_upstream_5xx( + request_id, + &account.id, + None, + "upstream response request failed with retryable 5xx", + "upstream response request failed", + || { + create_response_with_account( + codex, + installation_id, + cloudflare, + request, + request_id, + account, + started_at, + ) + }, + ) + .await } pub(crate) async fn create_response_stream_with_account_retrying_5xx( @@ -312,44 +394,24 @@ pub(crate) async fn create_response_stream_with_account_retrying_5xx( request_id: &str, account: &Account, ) -> Result { - let mut retries = 0; - loop { - let result = create_response_stream_with_account( - codex, - installation_id, - cloudflare, - request, - request_id, - account, - ) - .await; - match result { - Err(error) - if is_retryable_upstream_5xx_error(&error) - && retries < MAX_UPSTREAM_5XX_RETRIES_PER_ACCOUNT => - { - tracing::warn!( - request_id, - account_id = %account.id, - retry = retries + 1, - error = %error, - "upstream response stream request failed with retryable 5xx" - ); - retries += 1; - } - Ok(response) => return Ok(response), - Err(error) => { - tracing::warn!( - request_id, - account_id = %account.id, - retries, - error = %error, - "upstream response stream request failed" - ); - return Err(error); - } - } - } + retry_upstream_5xx( + request_id, + &account.id, + None, + "upstream response stream request failed with retryable 5xx", + "upstream response stream request failed", + || { + create_response_stream_with_account( + codex, + installation_id, + cloudflare, + request, + request_id, + account, + ) + }, + ) + .await } pub(crate) async fn create_compact_response_with_account_retrying_5xx( @@ -360,44 +422,22 @@ pub(crate) async fn create_compact_response_with_account_retrying_5xx( request_id: &str, account: &Account, ) -> Result { - let mut retries = 0; - loop { - let result = create_compact_response_with_account( - codex, - installation_id, - cloudflare, - request, - request_id, - account, - ) - .await; - match result { - Err(error) - if is_retryable_upstream_5xx_error(&error) - && retries < MAX_UPSTREAM_5XX_RETRIES_PER_ACCOUNT => - { - tracing::warn!( - request_id, - account_id = %account.id, - endpoint = "compact", - retry = retries + 1, - error = %error, - "upstream compact request failed with retryable 5xx" - ); - retries += 1; - } - Ok(response) => return Ok(response), - Err(error) => { - tracing::warn!( - request_id, - account_id = %account.id, - endpoint = "compact", - retries, - error = %error, - "upstream compact request failed" - ); - return Err(error); - } - } - } + retry_upstream_5xx( + request_id, + &account.id, + Some("compact"), + "upstream compact request failed with retryable 5xx", + "upstream compact request failed", + || { + create_compact_response_with_account( + codex, + installation_id, + cloudflare, + request, + request_id, + account, + ) + }, + ) + .await } diff --git a/backend/src/proxy/dispatch/usage_events.rs b/backend/src/proxy/dispatch/usage_events.rs new file mode 100644 index 000000000..85b4d616b --- /dev/null +++ b/backend/src/proxy/dispatch/usage_events.rs @@ -0,0 +1,175 @@ +//! Dispatch 使用记录事件辅助。 + +use std::time::Instant; + +use serde_json::Value; + +use crate::{ + admin::monitoring::{ + usage_record_model::{ResponseUsageRecord, UsageRecord, UsageRecordLevel}, + usage_record_service::AdminUsageRecordService, + }, + infra::time::elapsed_millis_i64, + upstream::protocol::responses::{CodexCompactRequest, CodexResponsesRequest}, +}; + +pub(super) struct DispatchErrorUsageRecord<'a> { + pub usage_records: &'a AdminUsageRecordService, + pub request_id: &'a str, + pub account_id: Option<&'a str>, + pub route: &'a str, + pub model: &'a str, + pub started_at: Instant, + pub status_code: i64, + pub message: &'a str, + pub metadata: Value, +} + +pub(super) async fn record_dispatch_error_event(record: DispatchErrorUsageRecord<'_>) { + let mut metadata = record.metadata; + enrich_event_route_metadata(&mut metadata, record.route); + + let mut event = UsageRecord::new( + event_kind(record.route), + UsageRecordLevel::Error, + record.message, + ); + event.request_id = Some(record.request_id.to_string()); + event.account_id = record.account_id.map(ToString::to_string); + event.route = Some(record.route.to_string()); + event.model = Some(record.model.to_string()); + event.status_code = Some(record.status_code); + event.latency_ms = Some(elapsed_millis_i64(record.started_at)); + event.metadata = metadata; + + if let Err(error) = record.usage_records.record(event).await { + tracing::warn!( + account_id = record.account_id.unwrap_or(""), + error = %error, + "failed to record dispatch error event" + ); + } +} + +pub(super) async fn record_response_event(record: ResponseUsageRecord<'_>) { + let mut metadata = record.metadata; + enrich_event_route_metadata(&mut metadata, record.route); + + let mut event = UsageRecord::new(event_kind(record.route), record.level, record.message); + event.request_id = Some(record.request_id.to_string()); + event.account_id = Some(record.account_id.to_string()); + event.route = Some(record.route.to_string()); + event.model = Some(record.model.to_string()); + event.status_code = Some(record.status_code); + event.latency_ms = Some(elapsed_millis_i64(record.started_at)); + enrich_usage_record_identity( + &mut metadata, + record.requested_model, + record.model, + record.client_ip, + record.client_user_agent, + record.reasoning_effort, + record.service_tier, + ); + event.metadata = metadata; + + if !record.rate_limit_headers.is_empty() { + if let Some(object) = event.metadata.as_object_mut() { + object.insert( + "rateLimitHeaders".to_string(), + serde_json::json!(record.rate_limit_headers), + ); + } + } + + if let Err(error) = record.usage_records.record(event).await { + tracing::warn!(account_id = %record.account_id, error = %error, "failed to record response event"); + } +} + +pub(super) fn event_kind(route: &str) -> &'static str { + if route == "/v1/chat/completions" { + "v1.chat" + } else { + "v1.response" + } +} + +pub(super) fn api_kind(route: &str) -> &'static str { + if route == "/v1/chat/completions" { + "chat" + } else { + "responses" + } +} + +pub(super) fn enrich_event_route_metadata(metadata: &mut Value, route: &str) { + let Some(object) = metadata.as_object_mut() else { + return; + }; + object + .entry("route".to_string()) + .or_insert_with(|| Value::String(route.to_string())); + object + .entry("apiKind".to_string()) + .or_insert_with(|| Value::String(api_kind(route).to_string())); +} + +pub(super) fn enrich_usage_record_identity( + metadata: &mut Value, + requested_model: Option<&str>, + upstream_model: &str, + client_ip: Option<&str>, + client_user_agent: Option<&str>, + reasoning_effort: Option<&str>, + service_tier: Option<&str>, +) { + let Some(object) = metadata.as_object_mut() else { + return; + }; + + let upstream_model = upstream_model.trim(); + let requested_model = requested_model + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(upstream_model); + object.insert( + "requestedModel".to_string(), + Value::String(requested_model.to_string()), + ); + object.insert( + "upstreamModel".to_string(), + Value::String(upstream_model.to_string()), + ); + + insert_trimmed_string(object, "clientIp", client_ip); + insert_trimmed_string(object, "userAgent", client_user_agent); + insert_trimmed_string(object, "reasoningEffort", reasoning_effort); + insert_trimmed_string(object, "serviceTier", service_tier); +} + +pub(super) fn reasoning_effort_from_request(request: &CodexResponsesRequest) -> Option<&str> { + reasoning_effort_from_value(request.reasoning.as_ref()) +} + +pub(super) fn reasoning_effort_from_compact_request(request: &CodexCompactRequest) -> Option<&str> { + reasoning_effort_from_value(request.reasoning.as_ref()) +} + +fn insert_trimmed_string( + object: &mut serde_json::Map, + key: &str, + value: Option<&str>, +) { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + object.insert(key.to_string(), Value::String(value.to_string())); + } +} + +fn reasoning_effort_from_value(reasoning: Option<&Value>) -> Option<&str> { + reasoning? + .get("effort") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} diff --git a/backend/src/proxy/openai/chat.rs b/backend/src/proxy/openai/chat.rs index 38eebb849..944e9ea07 100644 --- a/backend/src/proxy/openai/chat.rs +++ b/backend/src/proxy/openai/chat.rs @@ -29,7 +29,9 @@ use crate::{ protocol::{ responses::{apply_response_model_options, CodexResponsesRequest}, schema::{prepare_schema, reconvert_tuple_values}, - sse::{encode_sse_event, parse_sse_events, SseError, DONE_SSE_FRAME}, + sse::{ + encode_sse_event, parse_sse_events, sse_frame_separator, SseError, DONE_SSE_FRAME, + }, }, }, }; @@ -171,7 +173,7 @@ impl ChatCompletionStreamTranslator { self.pending.push_str(chunk); let mut output = String::new(); - while let Some((event_end, separator_len)) = sse_event_separator(&self.pending) { + while let Some((event_end, separator_len)) = sse_frame_separator(&self.pending) { let event_frame = self.pending[..event_end].to_string(); self.pending.drain(..event_end + separator_len); if event_frame.trim().is_empty() { @@ -1207,16 +1209,6 @@ fn openai_stream_usage(usage: Option<&Value>) -> Value { openai_usage } -fn sse_event_separator(input: &str) -> Option<(usize, usize)> { - let lf = input.find("\n\n").map(|index| (index, 2)); - let crlf = input.find("\r\n\r\n").map(|index| (index, 4)); - match (lf, crlf) { - (Some(left), Some(right)) => Some(if left.0 <= right.0 { left } else { right }), - (Some(found), None) | (None, Some(found)) => Some(found), - (None, None) => None, - } -} - fn chat_completion_stream_id() -> String { let suffix = Uuid::new_v4() .simple() diff --git a/backend/src/runtime/bootstrap.rs b/backend/src/runtime/bootstrap.rs index 01d54ce37..5cac696b4 100644 --- a/backend/src/runtime/bootstrap.rs +++ b/backend/src/runtime/bootstrap.rs @@ -5,6 +5,7 @@ use chrono::Utc; use crate::config::settings::RuntimeSettingsService; use crate::config::types::AppConfig; +use crate::http::middleware::request_id::TrustedProxyConfig; use crate::infra::database::connect_sqlite; use crate::infra::logging::{init_tracing, LogGuard, RotationConfig}; use crate::infra::paths::{ensure_data_dir, load_or_create_installation_id}; @@ -59,6 +60,7 @@ async fn build_application( ), }; + config.admin.validate_default_password()?; let default_admin_password = config.admin.default_password.clone(); let data_dir = ensure_data_dir()?; let installation_id = load_or_create_installation_id(Some(&data_dir))?; @@ -97,10 +99,11 @@ async fn build_application( ); let task_coordinator = TaskCoordinator::start(&runtime_config, &services); + let trusted_proxies = TrustedProxyConfig::from_entries(&config.server.trusted_proxies)?; let app_state = AppState { config, services }; Ok(( - crate::http::router::router().with_state(app_state), + crate::http::router::router_with_trusted_proxies(trusted_proxies).with_state(app_state), task_coordinator, )) } diff --git a/backend/src/runtime/services.rs b/backend/src/runtime/services.rs index 908c01099..8a2d7cbec 100644 --- a/backend/src/runtime/services.rs +++ b/backend/src/runtime/services.rs @@ -4,12 +4,13 @@ use std::sync::Arc as StdArc; use crate::{ admin::monitoring::{ - service::AdminUsageService, + account_usage_service::AdminUsageService, + account_usage_store::SqliteUsageStore, + usage_record_service::AdminUsageRecordService, usage_record_store::{ - AdminUsageRecordService, SqliteUsageRecordStore, DEFAULT_USAGE_RECORD_CAPACITY, + SqliteUsageRecordStore, DEFAULT_USAGE_RECORD_CAPACITY, DEFAULT_USAGE_RECORD_CAPTURE_BODY, }, - usage_store::SqliteUsageStore, }, admin::{ accounts::service::{AdminAccountService, AdminAccountServiceParts}, @@ -43,6 +44,7 @@ use crate::{ token_client::{default_openai_token_client, OpenAiTokenClient, TokenClientConfig}, transport::{ build_reqwest_client, tls::CustomCaError, CodexBackendClient, CodexModelCatalogClient, + CodexWebSocketPool, CodexWebSocketPoolConfig, }, }, }; @@ -96,6 +98,7 @@ pub struct Services { pub responses: StdArc, pub session_affinity: StdArc, pub codex: StdArc, + pub websocket_pool: Option>, pub fingerprint: Fingerprint, pub installation_id: Option, pub background_tasks: BackgroundTaskStores, @@ -158,23 +161,22 @@ impl Services { let installation_id = installation_id.filter(|id| !id.trim().is_empty()); let account_store_trait = StdArc::new(stores.accounts.clone()) as StdArc; + let websocket_pool = config.ws_pool.enabled.then(|| { + StdArc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig { + enabled: true, + max_age: std::time::Duration::from_millis(config.ws_pool.max_age_ms), + max_per_account: config.ws_pool.max_per_account, + ..CodexWebSocketPoolConfig::default() + })) + }); let codex = { let client = CodexBackendClient::new( build_reqwest_client(config.tls.force_http11)?, config.api.base_url.clone(), fingerprint.clone(), ); - if config.ws_pool.enabled { - let pool = - StdArc::new(crate::upstream::transport::CodexWebSocketPool::with_config( - crate::upstream::transport::CodexWebSocketPoolConfig { - enabled: config.ws_pool.enabled, - max_age: std::time::Duration::from_millis(config.ws_pool.max_age_ms), - max_per_account: config.ws_pool.max_per_account, - ..crate::upstream::transport::CodexWebSocketPoolConfig::default() - }, - )); - StdArc::new(client.with_websocket_pool(pool)) + if let Some(pool) = &websocket_pool { + StdArc::new(client.with_websocket_pool(pool.clone())) } else { StdArc::new(client) } @@ -292,6 +294,7 @@ impl Services { responses, session_affinity, codex, + websocket_pool, fingerprint, installation_id, background_tasks: stores, diff --git a/backend/src/runtime/shutdown.rs b/backend/src/runtime/shutdown.rs index 6b0064a79..5aa99e8bd 100644 --- a/backend/src/runtime/shutdown.rs +++ b/backend/src/runtime/shutdown.rs @@ -1,9 +1,15 @@ //! 优雅关闭信号处理。 +use std::sync::OnceLock; + use tokio::signal; +use tokio::sync::broadcast; + +static SHUTDOWN_REQUESTS: OnceLock> = OnceLock::new(); /// 等待进程关闭信号(Ctrl+C 或 SIGTERM)。 pub async fn shutdown_signal() { + let mut requested = shutdown_sender().subscribe(); let ctrl_c = async { signal::ctrl_c() .await @@ -22,7 +28,20 @@ pub async fn shutdown_signal() { let terminate = std::future::pending::<()>(); tokio::select! { + _ = requested.recv() => {}, () = ctrl_c => {}, () = terminate => {}, } } + +/// 请求进程按优雅关闭路径退出。 +pub fn request_shutdown() { + let _ = shutdown_sender().send(()); +} + +fn shutdown_sender() -> &'static broadcast::Sender<()> { + SHUTDOWN_REQUESTS.get_or_init(|| { + let (sender, _receiver) = broadcast::channel(16); + sender + }) +} diff --git a/backend/src/runtime/tasks/cleanup.rs b/backend/src/runtime/tasks/cleanup.rs new file mode 100644 index 000000000..d3ffe9773 --- /dev/null +++ b/backend/src/runtime/tasks/cleanup.rs @@ -0,0 +1,92 @@ +//! 通用过期数据清理任务骨架。 + +use std::{fmt::Display, future::Future, time::Duration}; + +use chrono::{DateTime, Utc}; +use tokio::time::interval; +use tracing::{debug, info, warn}; + +use super::coordinator::SchedulerHandle; + +/// 支持按时间清理过期记录的存储。 +pub(crate) trait ExpiredCleanupStore: Send + Sync + 'static { + type Error: Display + Send + Sync + 'static; + + fn delete_expired_at( + &self, + now: DateTime, + ) -> impl Future> + Send + '_; +} + +#[derive(Clone, Copy)] +pub(crate) struct CleanupTaskMessages { + pub started: &'static str, + pub deleted: &'static str, + pub empty: &'static str, + pub failed: &'static str, + pub stopped: &'static str, +} + +/// 周期性过期数据清理任务。 +pub(crate) struct CleanupTask { + store: S, + interval_secs: u64, + messages: CleanupTaskMessages, +} + +impl CleanupTask +where + S: ExpiredCleanupStore, +{ + pub(crate) fn new(store: S, interval_secs: u64, messages: CleanupTaskMessages) -> Self { + Self { + store, + interval_secs, + messages, + } + } + + pub(crate) fn start(self) -> SchedulerHandle { + let (shutdown_tx, mut shutdown_rx) = tokio::sync::mpsc::channel(1); + + tokio::spawn(async move { + let mut ticker = interval(Duration::from_secs(self.interval_secs)); + info!( + interval_secs = self.interval_secs, + "{}", self.messages.started + ); + + loop { + tokio::select! { + _ = ticker.tick() => { + match self.cleanup_once().await { + Ok(deleted) if deleted > 0 => { + info!(deleted, "{}", self.messages.deleted); + } + Ok(_) => { + debug!("{}", self.messages.empty); + } + Err(error) => { + warn!(error = %error, "{}", self.messages.failed); + } + } + } + _ = shutdown_rx.recv() => { + info!("{}", self.messages.stopped); + break; + } + } + } + }); + + SchedulerHandle::new(shutdown_tx) + } + + pub(crate) async fn cleanup_once(&self) -> Result { + self.cleanup_once_at(Utc::now()).await + } + + pub(crate) async fn cleanup_once_at(&self, now: DateTime) -> Result { + self.store.delete_expired_at(now).await + } +} diff --git a/backend/src/runtime/tasks/cookie_cleanup.rs b/backend/src/runtime/tasks/cookie_cleanup.rs index 225784c7c..21c245395 100644 --- a/backend/src/runtime/tasks/cookie_cleanup.rs +++ b/backend/src/runtime/tasks/cookie_cleanup.rs @@ -1,73 +1,61 @@ //! Cookie 清理任务。 -use std::time::Duration; +use std::future::Future; use chrono::{DateTime, Utc}; -use tokio::time::interval; -use tracing::{debug, info, warn}; use crate::upstream::accounts::cookies::{SqliteCookieStore, SqliteCookieStoreError}; -use super::coordinator::SchedulerHandle; +use super::{ + cleanup::{CleanupTask, CleanupTaskMessages, ExpiredCleanupStore}, + coordinator::SchedulerHandle, +}; const DEFAULT_INTERVAL_SECS: u64 = 5 * 60; +const MESSAGES: CleanupTaskMessages = CleanupTaskMessages { + started: "Cookie 清理任务已启动", + deleted: "已清理过期 Cookie", + empty: "没有需要清理的过期 Cookie", + failed: "清理过期 Cookie 失败", + stopped: "Cookie 清理任务已关闭", +}; /// Cookie 清理任务。 pub struct CookieCleanupTask { - store: SqliteCookieStore, - interval_secs: u64, + task: CleanupTask, } impl CookieCleanupTask { /// 构造默认 Cookie 清理任务。 pub fn new(store: SqliteCookieStore) -> Self { Self { - store, - interval_secs: DEFAULT_INTERVAL_SECS, + task: CleanupTask::new(store, DEFAULT_INTERVAL_SECS, MESSAGES), } } /// 启动后台清理任务。 pub fn start(self) -> SchedulerHandle { - let (shutdown_tx, mut shutdown_rx) = tokio::sync::mpsc::channel(1); - - tokio::spawn(async move { - let mut ticker = interval(Duration::from_secs(self.interval_secs)); - info!(interval_secs = self.interval_secs, "Cookie 清理任务已启动"); - - loop { - tokio::select! { - _ = ticker.tick() => { - match self.cleanup_once().await { - Ok(deleted) if deleted > 0 => { - info!(deleted, "已清理过期 Cookie"); - } - Ok(_) => { - debug!("没有需要清理的过期 Cookie"); - } - Err(error) => { - warn!(error = %error, "清理过期 Cookie 失败"); - } - } - } - _ = shutdown_rx.recv() => { - info!("Cookie 清理任务已关闭"); - break; - } - } - } - }); - - SchedulerHandle::new(shutdown_tx) + self.task.start() } /// 执行一次清理。 pub async fn cleanup_once(&self) -> Result { - self.cleanup_once_at(Utc::now()).await + self.task.cleanup_once().await } /// 在指定时间点执行一次清理。 pub async fn cleanup_once_at(&self, now: DateTime) -> Result { - self.store.cleanup_expired(now).await + self.task.cleanup_once_at(now).await + } +} + +impl ExpiredCleanupStore for SqliteCookieStore { + type Error = SqliteCookieStoreError; + + fn delete_expired_at( + &self, + now: DateTime, + ) -> impl Future> + Send + '_ { + self.cleanup_expired(now) } } diff --git a/backend/src/runtime/tasks/coordinator.rs b/backend/src/runtime/tasks/coordinator.rs index 89feaaef6..005ef33c3 100644 --- a/backend/src/runtime/tasks/coordinator.rs +++ b/backend/src/runtime/tasks/coordinator.rs @@ -1,6 +1,6 @@ //! 后台任务协调器。 -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; use tokio::task::JoinHandle; @@ -96,6 +96,12 @@ impl TaskCoordinator { services.fingerprint.build_number.clone(), ), ); + if let Some(pool) = &services.websocket_pool { + coordinator.push( + "websocket_pool", + SchedulerHandle::from_websocket_pool(pool.clone()), + ); + } coordinator } @@ -117,6 +123,8 @@ pub enum SchedulerHandle { Channel(tokio::sync::mpsc::Sender<()>), /// 直接持有 tokio 任务句柄。 JoinHandle(JoinHandle<()>), + /// 关闭上游 WebSocket 连接池。 + WebSocketPool(Arc), } impl SchedulerHandle { @@ -130,6 +138,11 @@ impl SchedulerHandle { Self::JoinHandle(handle) } + /// 使用 WebSocket 连接池构造句柄。 + pub fn from_websocket_pool(pool: Arc) -> Self { + Self::WebSocketPool(pool) + } + /// 关闭任务。 pub async fn shutdown(self) { match self { @@ -139,6 +152,9 @@ impl SchedulerHandle { Self::JoinHandle(handle) => { handle.abort(); } + Self::WebSocketPool(pool) => { + pool.shutdown().await; + } } } } diff --git a/backend/src/runtime/tasks/mod.rs b/backend/src/runtime/tasks/mod.rs index 32b312ba0..2929d45b7 100644 --- a/backend/src/runtime/tasks/mod.rs +++ b/backend/src/runtime/tasks/mod.rs @@ -1,5 +1,6 @@ //! 后台任务子模块。 +mod cleanup; pub mod cookie_cleanup; pub mod coordinator; pub mod fingerprint_update; diff --git a/backend/src/runtime/tasks/session_affinity_cleanup.rs b/backend/src/runtime/tasks/session_affinity_cleanup.rs index f3cc0ab26..ca4648fb4 100644 --- a/backend/src/runtime/tasks/session_affinity_cleanup.rs +++ b/backend/src/runtime/tasks/session_affinity_cleanup.rs @@ -1,72 +1,47 @@ //! 会话亲和性清理任务。 -use std::time::Duration; +use std::future::Future; use chrono::{DateTime, Utc}; -use tokio::time::interval; -use tracing::{debug, info, warn}; use crate::proxy::dispatch::session_affinity::{ SqliteSessionAffinityStore, SqliteSessionAffinityStoreError, }; -use super::coordinator::SchedulerHandle; +use super::{ + cleanup::{CleanupTask, CleanupTaskMessages, ExpiredCleanupStore}, + coordinator::SchedulerHandle, +}; + +const MESSAGES: CleanupTaskMessages = CleanupTaskMessages { + started: "会话亲和性清理任务已启动", + deleted: "已清理过期会话亲和性", + empty: "没有需要清理的过期会话亲和性", + failed: "清理过期会话亲和性失败", + stopped: "会话亲和性清理任务已关闭", +}; /// 会话亲和性清理任务。 pub struct SessionAffinityCleanupTask { - store: SqliteSessionAffinityStore, - interval_secs: u64, + task: CleanupTask, } impl SessionAffinityCleanupTask { /// 构造会话亲和性清理任务。 pub fn new(store: SqliteSessionAffinityStore, interval_secs: u64) -> Self { Self { - store, - interval_secs, + task: CleanupTask::new(store, interval_secs, MESSAGES), } } /// 启动后台清理任务。 pub fn start(self) -> SchedulerHandle { - let (shutdown_tx, mut shutdown_rx) = tokio::sync::mpsc::channel(1); - - tokio::spawn(async move { - let mut ticker = interval(Duration::from_secs(self.interval_secs)); - info!( - interval_secs = self.interval_secs, - "会话亲和性清理任务已启动" - ); - - loop { - tokio::select! { - _ = ticker.tick() => { - match self.cleanup_once().await { - Ok(deleted) if deleted > 0 => { - info!(deleted, "已清理过期会话亲和性"); - } - Ok(_) => { - debug!("没有需要清理的过期会话亲和性"); - } - Err(error) => { - warn!(error = %error, "清理过期会话亲和性失败"); - } - } - } - _ = shutdown_rx.recv() => { - info!("会话亲和性清理任务已关闭"); - break; - } - } - } - }); - - SchedulerHandle::new(shutdown_tx) + self.task.start() } /// 执行一次清理。 pub async fn cleanup_once(&self) -> Result { - self.cleanup_once_at(Utc::now()).await + self.task.cleanup_once().await } /// 在指定时间点执行一次清理。 @@ -74,6 +49,17 @@ impl SessionAffinityCleanupTask { &self, now: DateTime, ) -> Result { - self.store.delete_expired(now).await + self.task.cleanup_once_at(now).await + } +} + +impl ExpiredCleanupStore for SqliteSessionAffinityStore { + type Error = SqliteSessionAffinityStoreError; + + fn delete_expired_at( + &self, + now: DateTime, + ) -> impl Future> + Send + '_ { + self.delete_expired(now) } } diff --git a/backend/src/runtime/tasks/session_cleanup.rs b/backend/src/runtime/tasks/session_cleanup.rs index 70249cfd6..64b871da1 100644 --- a/backend/src/runtime/tasks/session_cleanup.rs +++ b/backend/src/runtime/tasks/session_cleanup.rs @@ -1,74 +1,60 @@ //! 会话清理任务。 -use std::time::Duration; +use std::future::Future; use chrono::{DateTime, Utc}; -use tokio::time::interval; -use tracing::{debug, info, warn}; use crate::admin::auth::service::SqliteAdminSessionStore; -use super::coordinator::SchedulerHandle; +use super::{ + cleanup::{CleanupTask, CleanupTaskMessages, ExpiredCleanupStore}, + coordinator::SchedulerHandle, +}; + +const MESSAGES: CleanupTaskMessages = CleanupTaskMessages { + started: "管理员会话清理任务已启动", + deleted: "已清理过期管理员会话", + empty: "没有需要清理的过期管理员会话", + failed: "清理过期管理员会话失败", + stopped: "管理员会话清理任务已关闭", +}; /// 管理员会话清理任务。 pub struct SessionCleanupTask { - store: SqliteAdminSessionStore, - interval_secs: u64, + task: CleanupTask, } impl SessionCleanupTask { /// 构造管理员会话清理任务。 pub fn new(store: SqliteAdminSessionStore, interval_secs: u64) -> Self { Self { - store, - interval_secs, + task: CleanupTask::new(store, interval_secs, MESSAGES), } } /// 启动后台清理任务。 pub fn start(self) -> SchedulerHandle { - let (shutdown_tx, mut shutdown_rx) = tokio::sync::mpsc::channel(1); - - tokio::spawn(async move { - let mut ticker = interval(Duration::from_secs(self.interval_secs)); - info!( - interval_secs = self.interval_secs, - "管理员会话清理任务已启动" - ); - - loop { - tokio::select! { - _ = ticker.tick() => { - match self.cleanup_once().await { - Ok(deleted) if deleted > 0 => { - info!(deleted, "已清理过期管理员会话"); - } - Ok(_) => { - debug!("没有需要清理的过期管理员会话"); - } - Err(error) => { - warn!(error = %error, "清理过期管理员会话失败"); - } - } - } - _ = shutdown_rx.recv() => { - info!("管理员会话清理任务已关闭"); - break; - } - } - } - }); - - SchedulerHandle::new(shutdown_tx) + self.task.start() } /// 执行一次清理。 pub async fn cleanup_once(&self) -> Result { - self.cleanup_once_at(Utc::now()).await + self.task.cleanup_once().await } /// 在指定时间点执行一次清理。 pub async fn cleanup_once_at(&self, now: DateTime) -> Result { - self.store.cleanup_expired_sessions(now).await + self.task.cleanup_once_at(now).await + } +} + +impl ExpiredCleanupStore for SqliteAdminSessionStore { + type Error = sqlx::Error; + + fn delete_expired_at( + &self, + now: DateTime, + ) -> impl Future> + Send + '_ { + self.cleanup_expired_sessions(now) } } diff --git a/backend/src/upstream/accounts/mod.rs b/backend/src/upstream/accounts/mod.rs index 69e32c291..c26171219 100644 --- a/backend/src/upstream/accounts/mod.rs +++ b/backend/src/upstream/accounts/mod.rs @@ -16,3 +16,4 @@ pub mod service; pub mod store; /// 刷新租约存储与 JWT claims 解码。 pub mod token_refresh; +mod window; diff --git a/backend/src/upstream/accounts/pool.rs b/backend/src/upstream/accounts/pool.rs index 9e3e466b2..25f5ace60 100644 --- a/backend/src/upstream/accounts/pool.rs +++ b/backend/src/upstream/accounts/pool.rs @@ -11,9 +11,11 @@ use chrono::{DateTime, Duration, Utc}; use indexmap::IndexMap; use thiserror::Error; +use crate::infra::time::parse_rfc3339_utc; use crate::upstream::accounts::quota::{quota_snapshot_limit_reached, quota_snapshot_reset_at}; use crate::upstream::accounts::store::AccountStore; use crate::upstream::accounts::token_refresh::{jwt_expiry, JwtExpiry}; +use crate::upstream::accounts::window::should_reset_usage_window; use crate::upstream::accounts::{ model::{Account, AccountModelUsageDelta, AccountStatus, AccountUsageDelta}, service::AccountService, @@ -737,7 +739,12 @@ impl AccountPool { let Some(account) = self.accounts.get_mut(account_id) else { return false; }; - if should_reset_window_counters(account, new_reset_at, limit_window_seconds) { + if should_reset_usage_window( + account.window_reset_at, + account.limit_window_seconds, + new_reset_at, + limit_window_seconds, + ) { reset_window_counters(account); account.window_started_at = Some(now); } @@ -1316,32 +1323,6 @@ fn refresh_quota_window(account: &mut Account, now: DateTime) { } } -fn should_reset_window_counters( - account: &Account, - new_reset_at: DateTime, - limit_window_seconds: Option, -) -> bool { - let Some(old_reset_at) = account.window_reset_at else { - return false; - }; - if old_reset_at == new_reset_at { - return false; - } - let drift = old_reset_at - .signed_duration_since(new_reset_at) - .num_seconds() - .unsigned_abs(); - let window_seconds = limit_window_seconds - .or(account.limit_window_seconds) - .unwrap_or(0); - let threshold = if window_seconds > 0 { - window_seconds / 2 - } else { - 3_600 - }; - drift >= threshold -} - fn reset_window_counters(account: &mut Account) { account.window_request_count = 0; account.window_input_tokens = 0; @@ -1406,7 +1387,7 @@ fn compare_last_used(a: Option<&str>, b: Option<&str>) -> Ordering { fn last_used_millis(value: Option<&str>) -> i64 { value - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .and_then(|value| parse_rfc3339_utc(value).ok()) .map(|datetime| datetime.timestamp_millis()) .unwrap_or(0) } diff --git a/backend/src/upstream/accounts/store.rs b/backend/src/upstream/accounts/store.rs index 172cc6461..409516f9f 100644 --- a/backend/src/upstream/accounts/store.rs +++ b/backend/src/upstream/accounts/store.rs @@ -7,13 +7,18 @@ use serde_json::Value; use sqlx::{sqlite::SqliteRow, QueryBuilder, Row, Sqlite, SqlitePool}; use thiserror::Error; -use crate::infra::json::{decode_cursor, page_offset, NumberedPage, Page}; +use crate::infra::{ + format::nonnegative_i64_to_u64, + json::{decode_cursor, page_offset, NumberedPage, Page}, + time::parse_optional_rfc3339_utc as parse_optional_rfc3339, +}; use crate::upstream::accounts::model::{ Account, AccountModelUsageDelta, AccountStatus, AccountUsageDelta, }; use crate::upstream::accounts::quota::{ quota_snapshot_limit_window_seconds, quota_snapshot_reset_at, }; +use crate::upstream::accounts::window::should_reset_usage_window; // ============================================================================ // SQL 常量 @@ -1806,33 +1811,6 @@ fn sqlite_usage_delta(usage: AccountUsageDelta) -> UsageDelta { } } -fn should_reset_usage_window( - existing_reset_at: Option>, - existing_limit_window_seconds: Option, - new_reset_at: DateTime, - new_limit_window_seconds: Option, -) -> bool { - let Some(existing_reset_at) = existing_reset_at else { - return false; - }; - if existing_reset_at == new_reset_at { - return false; - } - let drift = existing_reset_at - .signed_duration_since(new_reset_at) - .num_seconds() - .unsigned_abs(); - let window_seconds = new_limit_window_seconds - .or(existing_limit_window_seconds) - .unwrap_or(0); - let threshold = if window_seconds > 0 { - window_seconds / 2 - } else { - 3_600 - }; - drift >= threshold -} - fn quota_plan_type(quota_json: &str) -> Option { serde_json::from_str::(quota_json) .ok()? @@ -1902,18 +1880,6 @@ fn status_from_db(value: &str) -> SqliteAccountStoreResult { } } -fn parse_optional_rfc3339(value: Option<&str>) -> SqliteAccountStoreResult>> { - value.map(parse_rfc3339).transpose() -} - -fn parse_rfc3339(value: &str) -> SqliteAccountStoreResult> { - Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc)) -} - -fn nonnegative_i64_to_u64(value: i64) -> u64 { - value.max(0).cast_unsigned() -} - fn u64_to_i64_saturating(value: u64) -> i64 { value.min(i64::MAX as u64) as i64 } @@ -1931,7 +1897,7 @@ async fn count_account_metadata( let mut builder = QueryBuilder::::new("select count(*) from accounts"); push_account_metadata_search(&mut builder, search); let (total,): (i64,) = builder.build_query_as().fetch_one(pool).await?; - Ok(total.max(0).cast_unsigned()) + Ok(nonnegative_i64_to_u64(total)) } fn push_account_metadata_search(builder: &mut QueryBuilder, search: Option<&str>) { diff --git a/backend/src/upstream/accounts/window.rs b/backend/src/upstream/accounts/window.rs new file mode 100644 index 000000000..2546fef50 --- /dev/null +++ b/backend/src/upstream/accounts/window.rs @@ -0,0 +1,31 @@ +//! 账号用量窗口规则。 + +use chrono::{DateTime, Utc}; + +pub(crate) fn should_reset_usage_window( + existing_reset_at: Option>, + existing_limit_window_seconds: Option, + new_reset_at: DateTime, + new_limit_window_seconds: Option, +) -> bool { + let Some(existing_reset_at) = existing_reset_at else { + return false; + }; + if existing_reset_at == new_reset_at { + return false; + } + + let drift = existing_reset_at + .signed_duration_since(new_reset_at) + .num_seconds() + .unsigned_abs(); + let window_seconds = new_limit_window_seconds + .or(existing_limit_window_seconds) + .unwrap_or(0); + let threshold = if window_seconds > 0 { + window_seconds / 2 + } else { + 3_600 + }; + drift >= threshold +} diff --git a/backend/src/upstream/protocol/responses.rs b/backend/src/upstream/protocol/responses.rs index 9c58d9cf0..ac336afd6 100644 --- a/backend/src/upstream/protocol/responses.rs +++ b/backend/src/upstream/protocol/responses.rs @@ -1,13 +1,16 @@ -use std::collections::BTreeSet; +use std::{collections::BTreeSet, time::Instant}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; -use crate::upstream::{ - models::ParsedModelName, - protocol::{ - schema::reconvert_tuple_values, - sse::{parse_sse_events, SseError, SseEvent}, +use crate::{ + infra::time::elapsed_millis_i64, + upstream::{ + models::ParsedModelName, + protocol::{ + schema::reconvert_tuple_values, + sse::{parse_sse_events, SseError, SseEvent}, + }, }, }; @@ -150,6 +153,17 @@ pub fn response_body_has_first_event(body_bytes: &[u8]) -> bool { .is_ok_and(|events| events.iter().any(response_sse_event_has_first_output)) } +/// 已收到首个有效 Responses SSE 输出事件时记录首 token 耗时。 +pub fn update_first_response_event_ms( + started_at: Instant, + body_bytes: &[u8], + first_token_ms: &mut Option, +) { + if first_token_ms.is_none() && response_body_has_first_event(body_bytes) { + *first_token_ms = Some(elapsed_millis_i64(started_at).max(1)); + } +} + fn response_sse_event_has_first_output(event: &SseEvent) -> bool { let data = event.data.trim(); if data.is_empty() || data == "[DONE]" { diff --git a/backend/src/upstream/protocol/sse.rs b/backend/src/upstream/protocol/sse.rs index e789ee723..807b91259 100644 --- a/backend/src/upstream/protocol/sse.rs +++ b/backend/src/upstream/protocol/sse.rs @@ -27,6 +27,16 @@ pub fn sse_body_has_done(body: &str) -> bool { .ends_with(DONE_SSE_FRAME.trim_end_matches(['\r', '\n'])) } +/// 返回下一个完整 SSE 帧分隔符位置与长度。 +pub fn sse_frame_separator(input: &str) -> Option<(usize, usize)> { + sse_frame_separator_bytes(input.as_bytes()) +} + +/// 返回下一个完整 SSE 帧结束位置(含分隔符)。 +pub fn sse_frame_end(bytes: &[u8]) -> Option { + sse_frame_separator_bytes(bytes).map(|(position, separator_len)| position + separator_len) +} + /// SSE 解析错误。 #[derive(Debug, Error, PartialEq, Eq)] pub enum SseError { @@ -170,6 +180,22 @@ fn track_event_buffer_bytes(current_bytes: &mut usize, line: &str) -> Result<(), Ok(()) } +fn sse_frame_separator_bytes(bytes: &[u8]) -> Option<(usize, usize)> { + let lf = bytes + .windows(2) + .position(|window| window == b"\n\n") + .map(|position| (position, 2)); + let crlf = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| (position, 4)); + match (lf, crlf) { + (Some(left), Some(right)) => Some(if left.0 <= right.0 { left } else { right }), + (Some(found), None) | (None, Some(found)) => Some(found), + (None, None) => None, + } +} + fn is_sse_metadata_line(line: &str) -> bool { line.starts_with("event:") || line.starts_with("data:") diff --git a/backend/src/upstream/protocol/websocket.rs b/backend/src/upstream/protocol/websocket.rs index 8d6b96811..17d70c93c 100644 --- a/backend/src/upstream/protocol/websocket.rs +++ b/backend/src/upstream/protocol/websocket.rs @@ -140,7 +140,7 @@ pub fn websocket_event_to_sse_frame(raw: &str) -> Option { Some(encode_sse_event(&event, raw)) } -fn websocket_event_type(raw: &str) -> Option { +pub(crate) fn websocket_event_type(raw: &str) -> Option { serde_json::from_str::(raw).ok().and_then(|value| { value .get("type") diff --git a/backend/src/upstream/transport/client.rs b/backend/src/upstream/transport/client.rs index 0a44278de..b76c3bf4f 100644 --- a/backend/src/upstream/transport/client.rs +++ b/backend/src/upstream/transport/client.rs @@ -13,7 +13,7 @@ use futures::{Stream, TryStreamExt}; use reqwest::{ header::{ HeaderMap, HeaderName, HeaderValue, ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, - COOKIE, RETRY_AFTER, SET_COOKIE, USER_AGENT, + COOKIE, RETRY_AFTER, USER_AGENT, }, Client, Response as ReqwestResponse, StatusCode, }; @@ -26,8 +26,8 @@ use crate::upstream::fingerprint::Fingerprint; use crate::upstream::models::BackendModelEntry; use crate::upstream::protocol::events::extract_sse_usage; use crate::upstream::protocol::responses::{ - http_sse_fallback_allowed, response_body_has_first_event, transport_for_request, - CodexCompactRequest, CodexResponsesRequest, CodexTransport, + http_sse_fallback_allowed, transport_for_request, CodexCompactRequest, CodexResponsesRequest, + CodexTransport, }; use crate::upstream::protocol::sse::SseError; use crate::upstream::protocol::websocket::{ @@ -39,6 +39,7 @@ use super::headers::{ build_ordered_codex_base_headers, insert_optional_header, insert_ordered_headers, websocket_header_pairs, }; +use super::response_meta; use super::tls::{build_reqwest_client_with_custom_ca, custom_ca_env_cache_key, CustomCaError}; use super::websocket::{ execute_response_create_request_stream_with_pool, execute_response_create_request_with_pool, @@ -366,9 +367,9 @@ impl CodexBackendClient { .send() .await?; let status = response.status(); - let turn_state = turn_state(&response); - let set_cookie_headers = set_cookie_headers(&response); - let rate_limit_headers = rate_limit_headers(response.headers()); + let turn_state = response_meta::turn_state(response.headers()); + let set_cookie_headers = response_meta::set_cookie_headers(response.headers()); + let rate_limit_headers = response_meta::rate_limit_headers(response.headers()); let retry_after_seconds = retry_after_seconds(response.headers(), None); if !status.is_success() { @@ -387,7 +388,7 @@ impl CodexBackendClient { let mut stream = response.bytes_stream(); while let Some(chunk) = stream.try_next().await? { body_bytes.extend_from_slice(&chunk); - update_first_token_ms(started_at, &body_bytes, &mut first_token_ms); + response_meta::update_first_token_ms(started_at, &body_bytes, &mut first_token_ms); } let body = String::from_utf8_lossy(&body_bytes).into_owned(); let usage = extract_sse_usage(&body).map_err(CodexClientError::InvalidSse)?; @@ -418,9 +419,9 @@ impl CodexBackendClient { .send() .await?; let status = response.status(); - let turn_state = turn_state(&response); - let set_cookie_headers = set_cookie_headers(&response); - let rate_limit_headers = rate_limit_headers(response.headers()); + let turn_state = response_meta::turn_state(response.headers()); + let set_cookie_headers = response_meta::set_cookie_headers(response.headers()); + let rate_limit_headers = response_meta::rate_limit_headers(response.headers()); let retry_after_seconds = retry_after_seconds(response.headers(), None); if !status.is_success() { @@ -704,8 +705,8 @@ impl CodexBackendClient { .await?; let status = response.status(); - let set_cookie_headers = set_cookie_headers(&response); - let rate_limit_headers = rate_limit_headers(response.headers()); + let set_cookie_headers = response_meta::set_cookie_headers(response.headers()); + let rate_limit_headers = response_meta::rate_limit_headers(response.headers()); let retry_after_seconds = retry_after_seconds(response.headers(), None); let body = response.text().await?; @@ -953,20 +954,6 @@ impl CodexModelCatalogClient for CodexBackendClient { // Response helpers // --------------------------------------------------------------------------- -fn turn_state(response: &ReqwestResponse) -> Option { - response - .headers() - .get("x-codex-turn-state") - .and_then(|value| value.to_str().ok()) - .map(ToString::to_string) -} - -fn update_first_token_ms(started_at: Instant, body_bytes: &[u8], first_token_ms: &mut Option) { - if first_token_ms.is_none() && response_body_has_first_event(body_bytes) { - *first_token_ms = Some(elapsed_millis_i64(started_at).max(1)); - } -} - fn log_websocket_pool_decision( request_id: &str, account_id: Option<&str>, @@ -1016,44 +1003,6 @@ impl WebSocketPoolLogContext { } } -fn elapsed_millis_i64(started_at: Instant) -> i64 { - started_at.elapsed().as_millis().min(i64::MAX as u128) as i64 -} - -fn set_cookie_headers(response: &ReqwestResponse) -> Vec { - response - .headers() - .get_all(SET_COOKIE) - .iter() - .filter_map(|value| value.to_str().ok().map(ToString::to_string)) - .collect() -} - -fn rate_limit_headers(headers: &HeaderMap) -> Vec<(String, String)> { - headers - .iter() - .filter(|(name, _)| is_rate_limit_header(name.as_str())) - .filter_map(|(name, value)| { - value - .to_str() - .ok() - .map(|value| (name.as_str().to_string(), value.to_string())) - }) - .collect() -} - -fn is_rate_limit_header(name: &str) -> bool { - let name = name.to_ascii_lowercase(); - name == "retry-after" - || name.contains("ratelimit") - || name.contains("rate-limit") - || name.starts_with("x-codex-primary-") - || name.starts_with("x-codex-secondary-") - || name.starts_with("x-codex-code-review-") - || name.starts_with("x-codex-review-") - || name.starts_with("x-code-review-") -} - pub(super) fn retry_after_seconds(headers: &HeaderMap, body: Option<&str>) -> Option { headers .get(RETRY_AFTER) diff --git a/backend/src/upstream/transport/mod.rs b/backend/src/upstream/transport/mod.rs index 78c03383c..3b97e887a 100644 --- a/backend/src/upstream/transport/mod.rs +++ b/backend/src/upstream/transport/mod.rs @@ -3,6 +3,7 @@ pub mod client; pub mod endpoints; pub mod headers; +mod response_meta; pub mod tls; pub mod usage; pub mod websocket; diff --git a/backend/src/upstream/transport/response_meta.rs b/backend/src/upstream/transport/response_meta.rs new file mode 100644 index 000000000..6b4da6095 --- /dev/null +++ b/backend/src/upstream/transport/response_meta.rs @@ -0,0 +1,55 @@ +//! 上游响应元数据提取辅助。 + +use std::time::Instant; + +use reqwest::header::{HeaderMap, SET_COOKIE}; + +use crate::upstream::protocol::responses::update_first_response_event_ms; + +pub(super) fn turn_state(headers: &HeaderMap) -> Option { + headers + .get("x-codex-turn-state") + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string) +} + +pub(super) fn set_cookie_headers(headers: &HeaderMap) -> Vec { + headers + .get_all(SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok().map(ToString::to_string)) + .collect() +} + +pub(super) fn rate_limit_headers(headers: &HeaderMap) -> Vec<(String, String)> { + headers + .iter() + .filter(|(name, _)| is_rate_limit_header(name.as_str())) + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_string(), value.to_string())) + }) + .collect() +} + +pub(super) fn update_first_token_ms( + started_at: Instant, + body_bytes: &[u8], + first_token_ms: &mut Option, +) { + update_first_response_event_ms(started_at, body_bytes, first_token_ms); +} + +fn is_rate_limit_header(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name == "retry-after" + || name.contains("ratelimit") + || name.contains("rate-limit") + || name.starts_with("x-codex-primary-") + || name.starts_with("x-codex-secondary-") + || name.starts_with("x-codex-code-review-") + || name.starts_with("x-codex-review-") + || name.starts_with("x-code-review-") +} diff --git a/backend/src/upstream/transport/websocket.rs b/backend/src/upstream/transport/websocket.rs index ac1343b1a..c07d88dee 100644 --- a/backend/src/upstream/transport/websocket.rs +++ b/backend/src/upstream/transport/websocket.rs @@ -11,7 +11,6 @@ use std::{ use bytes::Bytes; use chrono::Utc; use futures::{channel::mpsc, SinkExt, Stream, StreamExt}; -use serde_json::Value; use thiserror::Error; use tokio::{sync::Mutex, time::timeout}; use tokio_tungstenite::{connect_async_tls_with_config, Connector}; @@ -19,23 +18,24 @@ use tungstenite::{ self, extensions::{compression::deflate::DeflateConfig, ExtensionsConfig}, handshake::client::Request as WsRequest, - http::{HeaderMap, Response as WsResponse}, + http::Response as WsResponse, protocol::WebSocketConfig, Message, }; use crate::infra::time::china_filename_timestamp_millis; use crate::upstream::protocol::events::{self, TokenUsage}; -use crate::upstream::protocol::responses::{response_body_has_first_event, CodexResponsesRequest}; +use crate::upstream::protocol::responses::CodexResponsesRequest; use crate::upstream::protocol::sse::SseError; use crate::upstream::protocol::websocket::{ classify_websocket_error_frame, is_terminal_websocket_event, retry_after_seconds_from_wrapped_error_headers, websocket_event_to_sse_frame, - websocket_incomplete_response_reason, websocket_metadata_turn_state, + websocket_event_type, websocket_incomplete_response_reason, websocket_metadata_turn_state, websocket_response_completed_parse_error, websocket_response_create_payload_text, OpeningAuditHeader, OpeningAuditSnapshot, WebSocketAuditArtifact, }; +use super::response_meta; use super::websocket_pool::{ CodexWebSocketConnectionMetadata, CodexWebSocketPool, CodexWebSocketPoolKey, CodexWsStream, PooledWebSocketConnection, WebSocketPoolAcquire, WebSocketPoolDecision, @@ -373,7 +373,7 @@ fn websocket_opening_error(response: &WsResponse>>) -> CodexWebSo status_code, retry_after_seconds, body, - set_cookie_headers: websocket_set_cookie_headers(response.headers()), + set_cookie_headers: response_meta::set_cookie_headers(response.headers()), } } @@ -795,7 +795,7 @@ async fn collect_websocket_response( let forwarded = if let Some(frame) = websocket_event_to_sse_frame(&raw) { body.push_str(&frame); saw_response_frame = true; - update_first_token_ms(started_at, body.as_bytes(), &mut first_token_ms); + response_meta::update_first_token_ms(started_at, body.as_bytes(), &mut first_token_ms); true } else { false @@ -835,23 +835,13 @@ fn reused_connection_died_before_first_frame( } } -fn update_first_token_ms(started_at: Instant, body_bytes: &[u8], first_token_ms: &mut Option) { - if first_token_ms.is_none() && response_body_has_first_event(body_bytes) { - *first_token_ms = Some(elapsed_millis_i64(started_at).max(1)); - } -} - -fn elapsed_millis_i64(started_at: Instant) -> i64 { - started_at.elapsed().as_millis().min(i64::MAX as u128) as i64 -} - fn websocket_connection_metadata( response: &WsResponse>>, ) -> CodexWebSocketConnectionMetadata { CodexWebSocketConnectionMetadata { - turn_state: websocket_response_turn_state(response.headers()), - set_cookie_headers: websocket_set_cookie_headers(response.headers()), - rate_limit_headers: websocket_rate_limit_headers(response.headers()), + turn_state: response_meta::turn_state(response.headers()), + set_cookie_headers: response_meta::set_cookie_headers(response.headers()), + rate_limit_headers: response_meta::rate_limit_headers(response.headers()), handshake_status: response.status().as_u16(), } } @@ -1071,55 +1061,6 @@ async fn discard_stream_websocket( // Header / metadata helpers // --------------------------------------------------------------------------- -fn websocket_response_turn_state(headers: &HeaderMap) -> Option { - headers - .get("x-codex-turn-state") - .and_then(|value| value.to_str().ok()) - .map(ToString::to_string) -} - -fn websocket_set_cookie_headers(headers: &HeaderMap) -> Vec { - headers - .get_all("set-cookie") - .iter() - .filter_map(|value| value.to_str().ok().map(ToString::to_string)) - .collect() -} - -fn websocket_rate_limit_headers(headers: &HeaderMap) -> Vec<(String, String)> { - headers - .iter() - .filter(|(name, _)| is_rate_limit_header(name.as_str())) - .filter_map(|(name, value)| { - value - .to_str() - .ok() - .map(|value| (name.as_str().to_string(), value.to_string())) - }) - .collect() -} - -fn is_rate_limit_header(name: &str) -> bool { - let name = name.to_ascii_lowercase(); - name == "retry-after" - || name.contains("ratelimit") - || name.contains("rate-limit") - || name.starts_with("x-codex-primary-") - || name.starts_with("x-codex-secondary-") - || name.starts_with("x-codex-code-review-") - || name.starts_with("x-codex-review-") - || name.starts_with("x-code-review-") -} - -fn websocket_event_type(raw: &str) -> Option { - serde_json::from_str::(raw).ok().and_then(|value| { - value - .get("type") - .and_then(Value::as_str) - .map(ToString::to_string) - }) -} - fn websocket_rate_limit_event_headers(raw: &str) -> Option> { events::parse_rate_limits_event_raw(raw) .map(|parsed| events::rate_limits_to_header_pairs(&parsed)) diff --git a/backend/src/upstream/transport/websocket_pool.rs b/backend/src/upstream/transport/websocket_pool.rs index 21b9ad9af..d0d9ac7f5 100644 --- a/backend/src/upstream/transport/websocket_pool.rs +++ b/backend/src/upstream/transport/websocket_pool.rs @@ -271,7 +271,7 @@ impl CodexWebSocketPool { break; }; let pool = CodexWebSocketPool { inner, config }; - if pool.is_shutting_down().await { + if pool.is_shutdown().await { break; } pool.maintain_idle_connections().await; @@ -279,7 +279,8 @@ impl CodexWebSocketPool { }); } - async fn is_shutting_down(&self) -> bool { + /// 返回连接池是否已进入关闭状态。 + pub async fn is_shutdown(&self) -> bool { self.inner.lock().await.shutting_down } diff --git a/backend/src/web/assets.rs b/backend/src/web/assets.rs index 8bdcb0879..8afb546c4 100644 --- a/backend/src/web/assets.rs +++ b/backend/src/web/assets.rs @@ -37,7 +37,7 @@ async fn serve_index(State(state): State) -> Response { } async fn serve_spa_fallback(State(state): State, uri: Uri) -> Response { - if uri.path() == "/api" || uri.path().starts_with("/api/") { + if is_api_path(uri.path()) { return ( StatusCode::NOT_FOUND, Json(json!({ @@ -52,6 +52,10 @@ async fn serve_spa_fallback(State(state): State, uri: Uri) -> Respon serve_file(&state.dist_dir.join("index.html"), "/").await } +fn is_api_path(path: &str) -> bool { + path == "/api" || path.starts_with("/api/") || path == "/v1" || path.starts_with("/v1/") +} + async fn serve_asset( State(state): State, AxumPath(path): AxumPath, diff --git a/backend/src/web/headers.rs b/backend/src/web/headers.rs index f3f8730a0..00b622552 100644 --- a/backend/src/web/headers.rs +++ b/backend/src/web/headers.rs @@ -5,7 +5,7 @@ const ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; const CONTENT_SECURITY_POLICY: &str = concat!( "default-src 'self'; ", "script-src 'self'; ", - "style-src 'self' 'unsafe-inline'; ", + "style-src 'self'; ", "img-src 'self' data:; ", "font-src 'self' data:; ", "connect-src 'self'; ", diff --git a/backend/tests/admin/accounts/exporting.rs b/backend/tests/admin/accounts/exporting.rs index 779e4c3bc..488eb9aa8 100644 --- a/backend/tests/admin/accounts/exporting.rs +++ b/backend/tests/admin/accounts/exporting.rs @@ -1,7 +1,10 @@ use super::*; +use sqlx::Row; + +const EXPORT_CONFIRM: &str = "confirm=export_sensitive_accounts"; #[tokio::test] -async fn admin_accounts_export_should_return_cpr_payload_for_all_accounts() { +async fn admin_accounts_export_should_reject_empty_ids() { let (app, _state, pool, _dir) = admin_accounts_test_app("admin-accounts-export-all.sqlite", 131).await; seed_account( @@ -59,7 +62,7 @@ async fn admin_accounts_export_should_return_cpr_payload_for_all_accounts() { .oneshot( Request::builder() .method("GET") - .uri("/api/admin/accounts/export") + .uri(format!("/api/admin/accounts/export?{EXPORT_CONFIRM}")) .header("cookie", "cpr_admin_session=session_1") .body(Body::empty()) .unwrap(), @@ -68,18 +71,10 @@ async fn admin_accounts_export_should_return_cpr_payload_for_all_accounts() { .unwrap(); let status = response.status(); let body = response_json(response).await; - let accounts = body["data"]["accounts"].as_array().unwrap(); - assert_eq!(status, StatusCode::OK); - assert_eq!(body["data"]["sourceFormat"], "cpr"); - assert_eq!(accounts.len(), 2); - assert!(accounts.iter().any(|account| { - account["id"] == "acct_export_a" - && account["token"] - .as_str() - .is_some_and(|token| !token.is_empty()) - && account["refreshToken"] == "refresh-export-a" - })); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], 40001); + assert_eq!(body["message"], "account ids are required"); } #[tokio::test] @@ -141,7 +136,9 @@ async fn admin_accounts_export_should_filter_by_ids() { .oneshot( Request::builder() .method("GET") - .uri("/api/admin/accounts/export?ids=acct_export_selected") + .uri(format!( + "/api/admin/accounts/export?ids=acct_export_selected&{EXPORT_CONFIRM}" + )) .header("cookie", "cpr_admin_session=session_1") .body(Body::empty()) .unwrap(), @@ -164,7 +161,9 @@ async fn admin_accounts_export_should_fail_when_selected_id_is_missing() { .oneshot( Request::builder() .method("GET") - .uri("/api/admin/accounts/export?ids=missing_account") + .uri(format!( + "/api/admin/accounts/export?ids=missing_account&{EXPORT_CONFIRM}" + )) .header("cookie", "cpr_admin_session=session_1") .body(Body::empty()) .unwrap(), @@ -209,7 +208,9 @@ async fn admin_accounts_export_payload_should_import_directly() { .oneshot( Request::builder() .method("GET") - .uri("/api/admin/accounts/export?ids=acct_export_roundtrip") + .uri(format!( + "/api/admin/accounts/export?ids=acct_export_roundtrip&{EXPORT_CONFIRM}" + )) .header("cookie", "cpr_admin_session=session_1") .body(Body::empty()) .unwrap(), @@ -249,3 +250,156 @@ async fn admin_accounts_export_payload_should_import_directly() { "refresh-roundtrip" ); } + +#[tokio::test] +async fn admin_accounts_export_should_require_explicit_sensitive_export_confirmation() { + let (app, _state, pool, _dir) = + admin_accounts_test_app("admin-accounts-export-confirm.sqlite", 136).await; + seed_account( + &pool, + NewAccount { + id: "acct_export_confirm".into(), + email: Some("confirm@example.com".into()), + account_id: Some("chatgpt_export_confirm".into()), + user_id: Some("user_export_confirm".into()), + label: None, + plan_type: Some("pro".into()), + access_token: SecretString::new( + test_jwt( + "chatgpt_export_confirm", + Some("user_export_confirm"), + Some("confirm@example.com"), + Some("pro"), + ) + .into(), + ), + refresh_token: Some(SecretString::new("refresh-confirm".into())), + access_token_expires_at: None, + status: AccountStatus::Active, + added_at: None, + }, + ) + .await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/accounts/export?ids=acct_export_confirm") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_export_missing_confirmation") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let audit = account_export_audit_row(&pool, "req_export_missing_confirmation").await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["message"], + "account export requires confirm=export_sensitive_accounts" + ); + assert_eq!(audit.level, "warn"); + assert_eq!(audit.status_code, Some(400)); + assert_eq!( + audit.failure_class, + Some("missing_confirmation".to_string()) + ); + assert_eq!(audit.metadata["accountIds"][0], "acct_export_confirm"); + assert!(audit.metadata.get("token").is_none()); + assert!(audit.metadata.get("refreshToken").is_none()); +} + +#[tokio::test] +async fn admin_accounts_export_should_record_success_audit_event() { + let (app, _state, pool, _dir) = + admin_accounts_test_app("admin-accounts-export-audit.sqlite", 137).await; + seed_account( + &pool, + NewAccount { + id: "acct_export_audit".into(), + email: Some("audit@example.com".into()), + account_id: Some("chatgpt_export_audit".into()), + user_id: Some("user_export_audit".into()), + label: None, + plan_type: Some("team".into()), + access_token: SecretString::new( + test_jwt( + "chatgpt_export_audit", + Some("user_export_audit"), + Some("audit@example.com"), + Some("team"), + ) + .into(), + ), + refresh_token: Some(SecretString::new("refresh-audit".into())), + access_token_expires_at: None, + status: AccountStatus::Active, + added_at: None, + }, + ) + .await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/admin/accounts/export?ids=acct_export_audit&{EXPORT_CONFIRM}" + )) + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_export_success_audit") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let audit = account_export_audit_row(&pool, "req_export_success_audit").await; + + assert_eq!(audit.kind, "admin_account_export"); + assert_eq!(audit.level, "warn"); + assert_eq!(audit.status_code, Some(200)); + assert_eq!(audit.failure_class, None); + assert_eq!(audit.message, "Admin account export succeeded"); + assert_eq!(audit.metadata["accountCount"], 1); + assert_eq!(audit.metadata["accountIds"][0], "acct_export_audit"); + assert!(audit.metadata.get("token").is_none()); + assert!(audit.metadata.get("refreshToken").is_none()); +} + +#[derive(Debug)] +struct AccountExportAuditRow { + kind: String, + level: String, + status_code: Option, + failure_class: Option, + message: String, + metadata: Value, +} + +async fn account_export_audit_row(pool: &SqlitePool, request_id: &str) -> AccountExportAuditRow { + let row = sqlx::query( + "select kind, level, status_code, failure_class, message, metadata_json \ + from usage_records \ + where kind = 'admin_account_export' and request_id = ? \ + order by created_at desc, id desc \ + limit 1", + ) + .bind(request_id) + .fetch_one(pool) + .await + .unwrap(); + + AccountExportAuditRow { + kind: row.get("kind"), + level: row.get("level"), + status_code: row.get("status_code"), + failure_class: row.get("failure_class"), + message: row.get("message"), + metadata: serde_json::from_str(&row.get::("metadata_json")).unwrap(), + } +} diff --git a/backend/tests/admin/auth/session.rs b/backend/tests/admin/auth/session.rs index 56d965c83..17adc3190 100644 --- a/backend/tests/admin/auth/session.rs +++ b/backend/tests/admin/auth/session.rs @@ -4,6 +4,8 @@ use axum::{ }; use chrono::Utc; use codex_proxy_rs::infra::{database::connect_sqlite, identity::hash_admin_password}; +use serde_json::Value; +use sqlx::Row; use sqlx::SqlitePool; use tower::util::ServiceExt; @@ -11,45 +13,7 @@ use crate::support::{config::test_config, http::response_json}; #[tokio::test] async fn admin_login_should_issue_http_only_session_cookie() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-login.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_user(&pool, "correct-password").await; - let config = test_config(url); - let stores = codex_proxy_rs::runtime::services::BackgroundTaskStores { - accounts: codex_proxy_rs::upstream::accounts::store::SqliteAccountStore::new(pool.clone()), - admin_sessions: codex_proxy_rs::admin::auth::service::SqliteAdminSessionStore::new( - pool.clone(), - ), - cookies: codex_proxy_rs::upstream::accounts::cookies::SqliteCookieStore::new(pool.clone()), - fingerprints: codex_proxy_rs::upstream::fingerprint::FingerprintRepository::new( - pool.clone(), - ), - session_affinity: - codex_proxy_rs::proxy::dispatch::session_affinity::SqliteSessionAffinityStore::new( - pool.clone(), - ), - refresh_leases: codex_proxy_rs::upstream::accounts::token_refresh::RefreshLeaseStore::new( - pool.clone(), - ), - client_keys: codex_proxy_rs::admin::keys::service::SqliteClientKeyStore::new(pool.clone()), - usage_records: - codex_proxy_rs::admin::monitoring::usage_record_store::SqliteUsageRecordStore::new( - pool.clone(), - ), - }; - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(codex_proxy_rs::runtime::services::Services::new( - &config, - stores, - fingerprint, - )); - let state = codex_proxy_rs::runtime::state::AppState { - config, - services: (*services).clone(), - }; - let app = codex_proxy_rs::http::router::router().with_state(state); + let (app, _dir, _pool) = admin_login_test_app("admin-login.sqlite", "correct-password").await; let response = app .clone() @@ -75,6 +39,7 @@ async fn admin_login_should_issue_http_only_session_cookie() { let body = response_json(response).await; let cookie = cookie.expect("login should set admin session cookie"); assert!(cookie.starts_with("cpr_admin_session=")); + assert!(cookie.contains("Secure")); assert!(cookie.contains("HttpOnly")); assert!(cookie.contains("SameSite=Lax")); assert_eq!(body["code"], 200); @@ -96,11 +61,132 @@ async fn admin_login_should_issue_http_only_session_cookie() { #[tokio::test] async fn admin_login_should_reject_client_api_key_as_password_or_authorization() { + let (app, _dir, _pool) = + admin_login_test_app("admin-login-rejects-client-key.sqlite", "correct-password").await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/login") + .header("content-type", "application/json") + .header("authorization", "Bearer cpr_not_an_admin_session") + .header("x-request-id", "req_login_bad") + .body(Body::from(r#"{"password":"cpr_not_an_admin_password"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let has_cookie = response.headers().get("set-cookie").is_some(); + + assert_eq!(status, StatusCode::UNAUTHORIZED); + let body = response_json(response).await; + assert!(!has_cookie); + assert_eq!(body["code"], 40102); +} + +#[tokio::test] +async fn admin_login_should_throttle_repeated_failures_from_same_source() { + let (app, _dir, _pool) = + admin_login_test_app("admin-login-throttle.sqlite", "correct-password").await; + + for _ in 0..5 { + let response = post_login(&app, "wrong-password").await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let response = post_login(&app, "correct-password").await; + let status = response.status(); + let body = response_json(response).await; + + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(body["code"], 42901); +} + +#[tokio::test] +async fn admin_login_should_record_success_audit_event() { + let (app, _dir, pool) = + admin_login_test_app("admin-login-success-audit.sqlite", "correct-password").await; + + let response = post_login_with_request_id(&app, "correct-password", "req_login_audit_ok").await; + assert_eq!(response.status(), StatusCode::OK); + let audit = admin_login_audit_row(&pool, "req_login_audit_ok").await; + + assert_eq!(audit.kind, "admin_auth"); + assert_eq!(audit.level, "info"); + assert_eq!(audit.route, Some("/api/admin/login".to_string())); + assert_eq!(audit.status_code, Some(200)); + assert_eq!(audit.failure_class, None); + assert_eq!(audit.message, "Admin login succeeded"); + assert_eq!(audit.metadata["source"], "unknown"); + assert_eq!(audit.metadata["usernameProvided"], false); + assert!(audit.metadata.get("username").is_none()); +} + +#[tokio::test] +async fn admin_login_should_record_invalid_credentials_audit_event() { + let (app, _dir, pool) = + admin_login_test_app("admin-login-invalid-audit.sqlite", "correct-password").await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/login") + .header("content-type", "application/json") + .header("x-request-id", "req_login_audit_bad") + .body(Body::from( + r#"{"username":"admin","password":"wrong-password"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let audit = admin_login_audit_row(&pool, "req_login_audit_bad").await; + + assert_eq!(audit.level, "warn"); + assert_eq!(audit.status_code, Some(401)); + assert_eq!(audit.failure_class, Some("invalid_credentials".to_string())); + assert_eq!(audit.message, "Admin login failed"); + assert_eq!(audit.metadata["usernameProvided"], true); + assert!(audit.metadata.get("username").is_none()); +} + +#[tokio::test] +async fn admin_login_should_record_throttled_audit_event() { + let (app, _dir, pool) = + admin_login_test_app("admin-login-throttled-audit.sqlite", "correct-password").await; + + for index in 0..5 { + let response = + post_login_with_request_id(&app, "wrong-password", &format!("req_login_audit_{index}")) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + let response = + post_login_with_request_id(&app, "correct-password", "req_login_audit_throttled").await; + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + let audit = admin_login_audit_row(&pool, "req_login_audit_throttled").await; + + assert_eq!(audit.level, "warn"); + assert_eq!(audit.status_code, Some(429)); + assert_eq!(audit.failure_class, Some("login_throttled".to_string())); + assert_eq!(audit.message, "Admin login throttled"); +} + +async fn admin_login_test_app( + db_name: &str, + password: &str, +) -> (axum::Router, tempfile::TempDir, SqlitePool) { let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("admin-login-rejects-client-key.sqlite"); + let db = dir.path().join(db_name); let url = format!("sqlite://{}", db.display()); let pool = connect_sqlite(&url).await.unwrap(); - seed_admin_user(&pool, "correct-password").await; + seed_admin_user(&pool, password).await; let config = test_config(url); let stores = codex_proxy_rs::runtime::services::BackgroundTaskStores { accounts: codex_proxy_rs::upstream::accounts::store::SqliteAccountStore::new(pool.clone()), @@ -134,29 +220,68 @@ async fn admin_login_should_reject_client_api_key_as_password_or_authorization() config, services: (*services).clone(), }; - let app = codex_proxy_rs::http::router::router().with_state(state); + ( + codex_proxy_rs::http::router::router().with_state(state), + dir, + pool, + ) +} - let response = app - .clone() +async fn post_login(app: &axum::Router, password: &str) -> axum::response::Response { + post_login_with_request_id(app, password, "req_login_throttle").await +} + +async fn post_login_with_request_id( + app: &axum::Router, + password: &str, + request_id: &str, +) -> axum::response::Response { + app.clone() .oneshot( Request::builder() .method("POST") .uri("/api/admin/login") .header("content-type", "application/json") - .header("authorization", "Bearer cpr_not_an_admin_session") - .header("x-request-id", "req_login_bad") - .body(Body::from(r#"{"password":"cpr_not_an_admin_password"}"#)) + .header("x-request-id", request_id) + .body(Body::from(format!(r#"{{"password":"{password}"}}"#))) .unwrap(), ) .await - .unwrap(); - let status = response.status(); - let has_cookie = response.headers().get("set-cookie").is_some(); + .unwrap() +} - assert_eq!(status, StatusCode::UNAUTHORIZED); - let body = response_json(response).await; - assert!(!has_cookie); - assert_eq!(body["code"], 40102); +#[derive(Debug)] +struct AdminLoginAuditRow { + kind: String, + level: String, + route: Option, + status_code: Option, + failure_class: Option, + message: String, + metadata: Value, +} + +async fn admin_login_audit_row(pool: &SqlitePool, request_id: &str) -> AdminLoginAuditRow { + let row = sqlx::query( + "select kind, level, route, status_code, failure_class, message, metadata_json \ + from usage_records \ + where kind = 'admin_auth' and request_id = ? \ + order by created_at desc, id desc \ + limit 1", + ) + .bind(request_id) + .fetch_one(pool) + .await + .unwrap(); + AdminLoginAuditRow { + kind: row.get("kind"), + level: row.get("level"), + route: row.get("route"), + status_code: row.get("status_code"), + failure_class: row.get("failure_class"), + message: row.get("message"), + metadata: serde_json::from_str(&row.get::("metadata_json")).unwrap(), + } } async fn seed_admin_user(pool: &SqlitePool, password: &str) { diff --git a/backend/tests/admin/monitoring/dashboard.rs b/backend/tests/admin/monitoring/dashboard.rs index 259b1b277..4673a02e9 100644 --- a/backend/tests/admin/monitoring/dashboard.rs +++ b/backend/tests/admin/monitoring/dashboard.rs @@ -8,7 +8,7 @@ use codex_proxy_rs::{ auth::service::SqliteAdminSessionStore, keys::service::SqliteClientKeyStore, monitoring::{ - usage_record::{UsageRecord, UsageRecordLevel}, + usage_record_model::{UsageRecord, UsageRecordLevel}, usage_record_store::SqliteUsageRecordStore, }, }, diff --git a/backend/tests/admin/monitoring/mod.rs b/backend/tests/admin/monitoring/mod.rs index 4b2c0c5eb..23a82b62d 100644 --- a/backend/tests/admin/monitoring/mod.rs +++ b/backend/tests/admin/monitoring/mod.rs @@ -1,3 +1,3 @@ pub(crate) mod dashboard; +pub(crate) mod usage_record_routes; pub(crate) mod usage_record_store; -pub(crate) mod usage_records_routes; diff --git a/backend/tests/admin/monitoring/usage_records_routes.rs b/backend/tests/admin/monitoring/usage_record_routes.rs similarity index 99% rename from backend/tests/admin/monitoring/usage_records_routes.rs rename to backend/tests/admin/monitoring/usage_record_routes.rs index eadcf86c7..2a15b4c44 100644 --- a/backend/tests/admin/monitoring/usage_records_routes.rs +++ b/backend/tests/admin/monitoring/usage_record_routes.rs @@ -7,7 +7,7 @@ use codex_proxy_rs::{ admin::auth::service::SqliteAdminSessionStore, admin::keys::service::SqliteClientKeyStore, admin::monitoring::{ - usage_record::{UsageRecord, UsageRecordLevel}, + usage_record_model::{UsageRecord, UsageRecordLevel}, usage_record_store::SqliteUsageRecordStore, }, config::types::AppConfig, diff --git a/backend/tests/admin/monitoring/usage_record_store.rs b/backend/tests/admin/monitoring/usage_record_store.rs index 5dbd81dfb..ce96c0aba 100644 --- a/backend/tests/admin/monitoring/usage_record_store.rs +++ b/backend/tests/admin/monitoring/usage_record_store.rs @@ -1,6 +1,6 @@ use codex_proxy_rs::{ admin::monitoring::{ - usage_record::{UsageRecord, UsageRecordLevel}, + usage_record_model::{UsageRecord, UsageRecordLevel}, usage_record_store::{SqliteUsageRecordStore, UsageRecordFilter}, }, infra::database::connect_sqlite, diff --git a/backend/tests/admin/system/mod.rs b/backend/tests/admin/system/mod.rs index b11f3422c..0b79a8599 100644 --- a/backend/tests/admin/system/mod.rs +++ b/backend/tests/admin/system/mod.rs @@ -1,4 +1,9 @@ -use std::{io::Write, path::Path, sync::Arc}; +use std::{ + io::Write, + path::Path, + sync::Arc, + time::{Duration, SystemTime}, +}; use axum::{ body::Body, @@ -21,7 +26,7 @@ use flate2::{write::GzEncoder, Compression}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use sqlx::SqlitePool; -use tar::{Builder, Header}; +use tar::{Builder, EntryType, Header}; use tokio::sync::Mutex; use tower::util::ServiceExt; use wiremock::{ @@ -141,6 +146,58 @@ async fn check_updates_should_report_source_build_as_unsupported() { ); } +#[tokio::test] +async fn check_updates_should_reject_untrusted_github_api_base() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + set_system_update_env( + "zyycn/codex-proxy-rs-untrusted-api-base-route", + "https://mirror.example", + ); + let (app, _dir) = admin_system_test_app("system-untrusted-api-base.sqlite").await; + + let info = get_check_updates(&app, true, "req_system_untrusted_api_base").await; + + assert!( + info["hasUpdate"] == false + && info["updateSupported"] == false + && info["unsupportedReason"] == "GitHub API base must be https://api.github.com/repos" + ); +} + +#[tokio::test] +async fn restart_should_request_graceful_shutdown() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + set_system_update_env("zyycn/codex-proxy-rs-restart-route", "http://127.0.0.1:9"); + std::env::set_var("CPR_ENABLE_SELF_RESTART", "true"); + let (app, _dir) = admin_system_test_app("system-restart.sqlite").await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/system/restart") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_system_restart") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + let shutdown = tokio::time::timeout( + Duration::from_secs(2), + codex_proxy_rs::runtime::shutdown::shutdown_signal(), + ) + .await; + + assert!( + body["data"]["operationId"] + .as_str() + .is_some_and(|id| id.starts_with("sysop-restart-")) + && shutdown.is_ok() + ); +} + #[tokio::test] async fn update_should_replace_local_release_files_with_latest_asset() { let _guard = SYSTEM_ENV_LOCK.lock().await; @@ -190,6 +247,310 @@ async fn update_should_replace_local_release_files_with_latest_asset() { ); } +#[tokio::test] +async fn update_should_reject_untrusted_github_api_base() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + let deploy = tempfile::tempdir().unwrap(); + let exe_path = deploy.path().join("codex-proxy-rs"); + let web_dist = deploy.path().join("web/dist"); + std::fs::create_dir_all(&web_dist).unwrap(); + std::fs::write(&exe_path, "old-binary").unwrap(); + std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); + set_system_update_env( + "zyycn/codex-proxy-rs-update-untrusted-api-base-route", + "https://mirror.example", + ); + set_system_update_paths(deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-untrusted-api-base.sqlite").await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/system/update") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_system_update_untrusted_api_base") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + + assert!( + status == StatusCode::CONFLICT + && body["message"] == "GitHub API base must be https://api.github.com/repos" + && std::fs::read_to_string(&exe_path).unwrap() == "old-binary" + && std::fs::read_to_string(web_dist.join("index.html")).unwrap() == "old-web" + ); +} + +#[tokio::test] +async fn update_should_fail_when_release_checksum_is_missing() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + let repository = "zyycn/codex-proxy-rs-missing-checksum-route"; + let github = MockServer::start().await; + let deploy = tempfile::tempdir().unwrap(); + let exe_path = deploy.path().join("codex-proxy-rs"); + let web_dist = deploy.path().join("web/dist"); + std::fs::create_dir_all(&web_dist).unwrap(); + std::fs::write(&exe_path, "old-binary").unwrap(); + std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); + mount_latest_release_with_archive_without_checksum( + &github, + repository, + "0.4.0", + "new-binary", + "new-web", + ) + .await; + set_system_update_env(repository, &github.uri()); + set_system_update_paths(deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-missing-checksum.sqlite").await; + + assert_update_failure_preserves_files( + &app, + "req_system_update_missing_checksum", + StatusCode::BAD_GATEWAY, + "Release checksums.txt is required", + &exe_path, + &web_dist, + ) + .await; +} + +#[tokio::test] +async fn update_should_fail_when_release_checksum_mismatches() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + let repository = "zyycn/codex-proxy-rs-bad-checksum-route"; + let github = MockServer::start().await; + let deploy = tempfile::tempdir().unwrap(); + let exe_path = deploy.path().join("codex-proxy-rs"); + let web_dist = deploy.path().join("web/dist"); + std::fs::create_dir_all(&web_dist).unwrap(); + std::fs::write(&exe_path, "old-binary").unwrap(); + std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); + mount_latest_release_with_archive_bad_checksum( + &github, + repository, + "0.4.0", + "new-binary", + "new-web", + ) + .await; + set_system_update_env(repository, &github.uri()); + set_system_update_paths(deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-bad-checksum.sqlite").await; + + assert_update_failure_preserves_files( + &app, + "req_system_update_bad_checksum", + StatusCode::BAD_GATEWAY, + "Checksum mismatch", + &exe_path, + &web_dist, + ) + .await; +} + +#[tokio::test] +async fn update_should_reject_release_archive_from_untrusted_host() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + let repository = "zyycn/codex-proxy-rs-untrusted-download-route"; + let github = MockServer::start().await; + let deploy = tempfile::tempdir().unwrap(); + let exe_path = deploy.path().join("codex-proxy-rs"); + let web_dist = deploy.path().join("web/dist"); + std::fs::create_dir_all(&web_dist).unwrap(); + std::fs::write(&exe_path, "old-binary").unwrap(); + std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); + mount_latest_release_with_archive_urls( + &github, + repository, + "0.4.0", + "https://evil.example/download/codex-proxy-rs.tar.gz", + "https://github.com/zyycn/codex-proxy-rs/releases/download/v0.4.0/checksums.txt", + ) + .await; + set_system_update_env(repository, &github.uri()); + set_system_update_paths(deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-untrusted-download.sqlite").await; + + assert_update_failure_preserves_files( + &app, + "req_system_update_untrusted_download", + StatusCode::BAD_REQUEST, + "Download host is not allowed: evil.example", + &exe_path, + &web_dist, + ) + .await; +} + +#[tokio::test] +async fn update_should_reject_insecure_release_archive_even_when_env_flag_is_set() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + let repository = "zyycn/codex-proxy-rs-insecure-download-route"; + let github = MockServer::start().await; + let deploy = tempfile::tempdir().unwrap(); + let exe_path = deploy.path().join("codex-proxy-rs"); + let web_dist = deploy.path().join("web/dist"); + std::fs::create_dir_all(&web_dist).unwrap(); + std::fs::write(&exe_path, "old-binary").unwrap(); + std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); + mount_latest_release_with_archive_urls( + &github, + repository, + "0.4.0", + "http://github.com/zyycn/codex-proxy-rs/releases/download/v0.4.0/codex-proxy-rs.tar.gz", + "https://github.com/zyycn/codex-proxy-rs/releases/download/v0.4.0/checksums.txt", + ) + .await; + set_system_update_env(repository, &github.uri()); + std::env::set_var("CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS", "true"); + set_system_update_paths(deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-insecure-download.sqlite").await; + + assert_update_failure_preserves_files( + &app, + "req_system_update_insecure_download", + StatusCode::BAD_REQUEST, + "Only HTTPS release downloads are allowed", + &exe_path, + &web_dist, + ) + .await; +} + +#[tokio::test] +async fn update_should_reject_release_archive_with_unsafe_path() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + let repository = "zyycn/codex-proxy-rs-unsafe-archive-route"; + let github = MockServer::start().await; + let deploy = tempfile::tempdir().unwrap(); + let exe_path = deploy.path().join("codex-proxy-rs"); + let web_dist = deploy.path().join("web/dist"); + std::fs::create_dir_all(&web_dist).unwrap(); + std::fs::write(&exe_path, "old-binary").unwrap(); + std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); + mount_latest_release_with_unsafe_archive(&github, repository, "0.4.0", "new-binary", "new-web") + .await; + set_system_update_env(repository, &github.uri()); + set_system_update_paths(deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-unsafe-archive.sqlite").await; + + assert_update_failure_preserves_files( + &app, + "req_system_update_unsafe_archive", + StatusCode::BAD_REQUEST, + "Unsafe archive path", + &exe_path, + &web_dist, + ) + .await; +} + +#[tokio::test] +async fn update_should_restore_web_assets_when_binary_backup_fails() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + let repository = "zyycn/codex-proxy-rs-binary-backup-failure-route"; + let github = MockServer::start().await; + let deploy = tempfile::tempdir().unwrap(); + let exe_path = deploy.path().join("codex-proxy-rs"); + let web_dist = deploy.path().join("web/dist"); + std::fs::create_dir_all(&web_dist).unwrap(); + std::fs::write(&exe_path, "old-binary").unwrap(); + std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); + std::fs::create_dir(deploy.path().join("codex-proxy-rs.backup")).unwrap(); + mount_latest_release_with_archive(&github, repository, "0.4.0", "new-binary", "new-web").await; + set_system_update_env(repository, &github.uri()); + set_system_update_paths(deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-binary-backup-failure.sqlite").await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/system/update") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_system_update_binary_backup_failure") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status_code = response.status(); + let body = response_json(response).await; + let status = wait_for_operation_status(&app, "failed").await; + let message = body["message"].as_str().unwrap_or_default(); + let state_error = status["operation"]["error"].as_str().unwrap_or_default(); + + assert!( + status_code == StatusCode::INTERNAL_SERVER_ERROR + && message.starts_with("Failed to remove old binary backup:") + && state_error.starts_with("Failed to remove old binary backup:") + && std::fs::read_to_string(&exe_path).unwrap() == "old-binary" + && std::fs::read_to_string(web_dist.join("index.html")).unwrap() == "old-web" + && !deploy.path().join("web/dist.backup").exists(), + "status={status_code}, message={message}, state_error={state_error}, exe={}, web={}, web_backup_exists={}", + std::fs::read_to_string(&exe_path).unwrap_or_default(), + std::fs::read_to_string(web_dist.join("index.html")).unwrap_or_default(), + deploy.path().join("web/dist.backup").exists() + ); +} + +#[tokio::test] +async fn update_should_remove_stale_file_lock_and_continue() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + let repository = "zyycn/codex-proxy-rs-stale-lock-route"; + let github = MockServer::start().await; + let deploy = tempfile::tempdir().unwrap(); + let exe_path = deploy.path().join("codex-proxy-rs"); + let web_dist = deploy.path().join("web/dist"); + let lock_path = deploy.path().join(".runtime/update.lock"); + std::fs::create_dir_all(&web_dist).unwrap(); + std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap(); + std::fs::write(&exe_path, "old-binary").unwrap(); + std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); + std::fs::write(&lock_path, "pid=1\ncreated_at=stale\n").unwrap(); + std::fs::File::options() + .write(true) + .open(&lock_path) + .unwrap() + .set_modified(SystemTime::now() - Duration::from_secs(31 * 60)) + .unwrap(); + mount_latest_release_with_archive(&github, repository, "0.4.0", "new-binary", "new-web").await; + set_system_update_env(repository, &github.uri()); + set_system_update_paths(deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-stale-lock.sqlite").await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/system/update") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_system_update_stale_lock") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + let status = wait_for_operation_status(&app, "succeeded").await; + + assert!( + body["data"]["targetVersion"] == "0.4.0" + && status["operation"]["status"] == "succeeded" + && std::fs::read_to_string(&exe_path).unwrap() == "new-binary" + && std::fs::read_to_string(web_dist.join("index.html")).unwrap() == "new-web" + && !lock_path.exists() + ); +} + #[tokio::test] async fn update_should_replace_web_assets_across_filesystems() { let _guard = SYSTEM_ENV_LOCK.lock().await; @@ -339,6 +700,40 @@ async fn get_check_updates(app: &axum::Router, force: bool, request_id: &str) -> response_json(response).await["data"].clone() } +async fn assert_update_failure_preserves_files( + app: &axum::Router, + request_id: &str, + expected_status: StatusCode, + expected_message: &str, + exe_path: &Path, + web_dist: &Path, +) { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/system/update") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", request_id) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status_code = response.status(); + let body = response_json(response).await; + let status = wait_for_operation_status(app, "failed").await; + + assert!( + status_code == expected_status + && body["message"] == expected_message + && status["operation"]["error"] == expected_message + && std::fs::read_to_string(exe_path).unwrap() == "old-binary" + && std::fs::read_to_string(web_dist.join("index.html")).unwrap() == "old-web" + ); +} + async fn mount_latest_release(server: &MockServer, repository: &str, version: &str) { Mock::given(method("GET")) .and(path(format!("/repos/{repository}/releases/latest"))) @@ -371,6 +766,73 @@ async fn mount_latest_release_with_archive( "{} {archive_name}\n", hex::encode(Sha256::digest(&archive)) ); + mount_latest_release_archive( + server, + repository, + version, + archive_name, + archive, + Some(checksum), + ) + .await; +} + +async fn mount_latest_release_with_archive_without_checksum( + server: &MockServer, + repository: &str, + version: &str, + binary: &str, + index: &str, +) { + let archive_name = format!( + "codex-proxy-rs_{version}_{}_{}.tar.gz", + std::env::consts::OS, + release_arch() + ); + let archive = release_archive(binary, index); + mount_latest_release_archive(server, repository, version, archive_name, archive, None).await; +} + +async fn mount_latest_release_with_archive_bad_checksum( + server: &MockServer, + repository: &str, + version: &str, + binary: &str, + index: &str, +) { + let archive_name = format!( + "codex-proxy-rs_{version}_{}_{}.tar.gz", + std::env::consts::OS, + release_arch() + ); + let archive = release_archive(binary, index); + let checksum = format!( + "{} {archive_name}\n", + "0".repeat(Sha256::output_size() * 2) + ); + mount_latest_release_archive( + server, + repository, + version, + archive_name, + archive, + Some(checksum), + ) + .await; +} + +async fn mount_latest_release_with_archive_urls( + server: &MockServer, + repository: &str, + version: &str, + archive_url: &str, + checksum_url: &str, +) { + let archive_name = format!( + "codex-proxy-rs_{version}_{}_{}.tar.gz", + std::env::consts::OS, + release_arch() + ); Mock::given(method("GET")) .and(path(format!("/repos/{repository}/releases/latest"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -382,28 +844,93 @@ async fn mount_latest_release_with_archive( "assets": [ { "name": archive_name, - "browser_download_url": format!("{}/download/{archive_name}", server.uri()), - "size": archive.len() + "browser_download_url": archive_url, + "size": 128 }, { "name": "checksums.txt", - "browser_download_url": format!("{}/download/checksums.txt", server.uri()), - "size": checksum.len() + "browser_download_url": checksum_url, + "size": 128 } ] }))) .mount(server) .await; +} + +async fn mount_latest_release_with_unsafe_archive( + server: &MockServer, + repository: &str, + version: &str, + binary: &str, + index: &str, +) { + let archive_name = format!( + "codex-proxy-rs_{version}_{}_{}.tar.gz", + std::env::consts::OS, + release_arch() + ); + let archive = release_archive_with_extra_file(binary, index, "../escape", b"unsafe"); + let checksum = format!( + "{} {archive_name}\n", + hex::encode(Sha256::digest(&archive)) + ); + mount_latest_release_archive( + server, + repository, + version, + archive_name, + archive, + Some(checksum), + ) + .await; +} + +async fn mount_latest_release_archive( + server: &MockServer, + repository: &str, + version: &str, + archive_name: String, + archive: Vec, + checksum: Option, +) { + let mut assets = vec![json!({ + "name": archive_name, + "browser_download_url": format!("{}/download/{archive_name}", server.uri()), + "size": archive.len() + })]; + if let Some(checksum) = checksum.as_ref() { + assets.push(json!({ + "name": "checksums.txt", + "browser_download_url": format!("{}/download/checksums.txt", server.uri()), + "size": checksum.len() + })); + } + + Mock::given(method("GET")) + .and(path(format!("/repos/{repository}/releases/latest"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "tag_name": format!("v{version}"), + "name": format!("v{version}"), + "body": "## Changes\n- Update package", + "html_url": format!("https://github.com/{repository}/releases/tag/v{version}"), + "prerelease": false, + "assets": assets + }))) + .mount(server) + .await; Mock::given(method("GET")) .and(path(format!("/download/{archive_name}"))) .respond_with(ResponseTemplate::new(200).set_body_bytes(archive)) .mount(server) .await; - Mock::given(method("GET")) - .and(path("/download/checksums.txt")) - .respond_with(ResponseTemplate::new(200).set_body_string(checksum)) - .mount(server) - .await; + if let Some(checksum) = checksum { + Mock::given(method("GET")) + .and(path("/download/checksums.txt")) + .respond_with(ResponseTemplate::new(200).set_body_string(checksum)) + .mount(server) + .await; + } } fn release_arch() -> &'static str { @@ -415,12 +942,24 @@ fn release_arch() -> &'static str { } fn release_archive(binary: &str, index: &str) -> Vec { + release_archive_with_extra_file(binary, index, "", &[]) +} + +fn release_archive_with_extra_file( + binary: &str, + index: &str, + extra_path: &str, + extra_bytes: &[u8], +) -> Vec { let mut data = Vec::new(); { let encoder = GzEncoder::new(&mut data, Compression::default()); let mut builder = Builder::new(encoder); append_tar_file(&mut builder, "codex-proxy-rs", binary.as_bytes()); append_tar_file(&mut builder, "web/dist/index.html", index.as_bytes()); + if !extra_path.is_empty() { + append_raw_tar_file(&mut builder, extra_path, extra_bytes); + } builder.finish().unwrap(); } data @@ -433,6 +972,17 @@ fn append_tar_file(builder: &mut Builder, path: &str, bytes: &[u8]) builder.append_data(&mut header, path, bytes).unwrap(); } +fn append_raw_tar_file(builder: &mut Builder, path: &str, bytes: &[u8]) { + let mut header = Header::new_old(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_entry_type(EntryType::Regular); + let name = path.as_bytes(); + header.as_old_mut().name[..name.len()].copy_from_slice(name); + header.set_cksum(); + builder.append(&header, bytes).unwrap(); +} + fn set_system_update_env(repository: &str, github_api_base: &str) { std::env::set_var("CPR_VERSION", "0.1.0"); std::env::set_var("CPR_DEPLOYMENT_MODE", "docker"); @@ -440,11 +990,12 @@ fn set_system_update_env(repository: &str, github_api_base: &str) { std::env::set_var("CPR_UPDATE_CHANNEL", "stable"); std::env::set_var("CPR_UPDATE_REPOSITORY", repository); std::env::set_var("CPR_GITHUB_API_BASE", format!("{github_api_base}/repos")); - std::env::set_var("CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS", "true"); + std::env::remove_var("CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS"); std::env::remove_var("CPR_UPDATE_EXE_PATH"); 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_ENABLE_SELF_RESTART"); } fn set_system_update_paths(root: &Path, exe_path: &Path, web_dist: &Path) { diff --git a/backend/tests/config/mod.rs b/backend/tests/config/mod.rs index 44d7448e5..6ea8a3b6c 100644 --- a/backend/tests/config/mod.rs +++ b/backend/tests/config/mod.rs @@ -1,12 +1,14 @@ use codex_proxy_rs::config::settings::account_pool_options_from_config; -use codex_proxy_rs::config::types::AppConfig; use codex_proxy_rs::config::types::LoggingConfig; +use codex_proxy_rs::config::types::{AdminConfig, AdminConfigError, AppConfig}; +use serde::de::DeserializeOwned; use std::fs; const DEFAULT_CONFIG_YAML: &str = r#" server: host: 0.0.0.0 port: 8080 + trusted_proxies: [] api: base_url: https://chatgpt.com/backend-api database: @@ -67,7 +69,7 @@ fingerprint: admin: session_ttl_minutes: 1440 default_username: admin - default_password: admin + default_password: test-admin-password logging: directory: .runtime/logs retention_days: 14 @@ -76,8 +78,9 @@ logging: #[test] fn default_config_keeps_only_codex_backend() { - let cfg: AppConfig = serde_yml::from_str(DEFAULT_CONFIG_YAML).unwrap(); + let cfg: AppConfig = parse_yaml_config(DEFAULT_CONFIG_YAML).unwrap(); assert_eq!(cfg.server.host, "0.0.0.0"); + assert!(cfg.server.trusted_proxies.is_empty()); assert_eq!(cfg.api.base_url, "https://chatgpt.com/backend-api"); assert!(cfg.model_aliases.is_empty()); assert_eq!(cfg.auth.refresh_margin_seconds, 300); @@ -99,7 +102,7 @@ fn default_config_keeps_only_codex_backend() { #[test] fn default_config_keeps_runtime_artifacts_under_runtime_directory() { - let cfg: AppConfig = serde_yml::from_str(DEFAULT_CONFIG_YAML).unwrap(); + let cfg: AppConfig = parse_yaml_config(DEFAULT_CONFIG_YAML).unwrap(); assert_eq!( cfg.database.url, @@ -110,7 +113,7 @@ fn default_config_keeps_runtime_artifacts_under_runtime_directory() { #[test] fn account_pool_options_should_use_quota_skip_exhausted() { - let mut cfg: AppConfig = serde_yml::from_str(DEFAULT_CONFIG_YAML).unwrap(); + let mut cfg: AppConfig = parse_yaml_config(DEFAULT_CONFIG_YAML).unwrap(); cfg.quota.skip_exhausted = false; let options = account_pool_options_from_config(&cfg); @@ -118,9 +121,32 @@ fn account_pool_options_should_use_quota_skip_exhausted() { assert!(!options.skip_quota_limited); } +#[test] +fn admin_config_should_reject_weak_default_password() { + let config = AdminConfig { + default_password: "123456".to_string(), + ..AdminConfig::default() + }; + + assert_eq!( + config.validate_default_password(), + Err(AdminConfigError::WeakDefaultPassword) + ); +} + +#[test] +fn admin_config_should_accept_explicit_strong_default_password() { + let config = AdminConfig { + default_password: "correct-horse-battery-staple".to_string(), + ..AdminConfig::default() + }; + + assert_eq!(config.validate_default_password(), Ok(())); +} + #[test] fn config_should_reject_unknown_top_level_sections() { - let err = serde_yml::from_str::( + let err = parse_yaml_config::( r" server: host: 127.0.0.1 @@ -233,6 +259,7 @@ ws_pool: assert_eq!(cfg.server.host, "127.0.0.1"); assert_eq!(cfg.server.port, 8080); + assert!(cfg.server.trusted_proxies.is_empty()); assert_eq!( cfg.database.url, "sqlite://.runtime/data/codex-proxy-rs.sqlite" @@ -253,9 +280,57 @@ ws_pool: assert_eq!(cfg.logging.retention_days, 14); } +#[test] +fn config_should_parse_trusted_proxy_entries() { + let cfg: AppConfig = parse_yaml_config(&DEFAULT_CONFIG_YAML.replace( + "trusted_proxies: []", + "trusted_proxies:\n - 127.0.0.1\n - 10.0.0.0/8", + )) + .unwrap(); + + assert_eq!( + cfg.server.trusted_proxies, + vec!["127.0.0.1".to_string(), "10.0.0.0/8".to_string()] + ); +} + +#[test] +fn server_config_should_reject_unknown_fields() { + let err = parse_yaml_config::(&DEFAULT_CONFIG_YAML.replace( + "trusted_proxies: []", + "trusted_proxies: []\n trusted_proxy: 127.0.0.1", + )) + .unwrap_err(); + + assert!( + err.to_string().contains("trusted_proxy"), + "expected unknown server field to be rejected, got {err}" + ); +} + +#[test] +fn config_loader_should_read_explicit_config_file_from_env() { + let dir = tempfile::tempdir().unwrap(); + let config_file = dir.path().join("custom-config.yaml"); + fs::write( + &config_file, + DEFAULT_CONFIG_YAML + .replace("host: 0.0.0.0", "host: 127.0.0.2") + .replace("directory: .runtime/logs", "directory: .runtime/env-logs"), + ) + .unwrap(); + std::env::set_var("CPR_CONFIG_FILE", &config_file); + + let cfg = AppConfig::load().unwrap(); + + std::env::remove_var("CPR_CONFIG_FILE"); + assert_eq!(cfg.server.host, "127.0.0.2"); + assert_eq!(cfg.logging.directory, ".runtime/env-logs"); +} + #[test] fn logging_config_should_reject_unknown_fields() { - let err = serde_yml::from_str::( + let err = parse_yaml_config::( r" directory: .runtime/logs unexpected: true @@ -270,3 +345,10 @@ enabled: false "expected unknown logging field to be rejected, got {err}" ); } + +fn parse_yaml_config(yaml: &str) -> Result { + config::Config::builder() + .add_source(config::File::from_str(yaml, config::FileFormat::Yaml)) + .build()? + .try_deserialize() +} diff --git a/backend/tests/http/trace_middleware/mod.rs b/backend/tests/http/trace_middleware/mod.rs index 91b673ac1..c3a382408 100644 --- a/backend/tests/http/trace_middleware/mod.rs +++ b/backend/tests/http/trace_middleware/mod.rs @@ -9,11 +9,14 @@ use axum::{ extract::connect_info::ConnectInfo, http::HeaderMap, http::{Request, StatusCode}, - middleware::from_fn, + middleware::{from_fn, from_fn_with_state}, routing::get, Router, }; -use codex_proxy_rs::http::middleware::{request_id::attach_request_id, trace::http_trace_layer}; +use codex_proxy_rs::http::middleware::{ + request_id::{attach_request_id, attach_request_id_with_proxy_config, TrustedProxyConfig}, + trace::http_trace_layer, +}; use tokio::time::{sleep, Duration}; use tower::ServiceExt; use tracing_subscriber::fmt::MakeWriter; @@ -108,6 +111,113 @@ async fn http_trace_should_include_request_id_and_completion_fields() { ); } +#[tokio::test(flavor = "current_thread")] +async fn http_trace_should_redact_request_uri_query_values() { + let logs = SharedLogBuffer::default(); + let subscriber = tracing_subscriber::fmt() + .json() + .with_writer(logs.clone()) + .with_current_span(true) + .with_span_list(true) + .with_ansi(false) + .finish(); + + let app = Router::new() + .route("/trace-test", get(|| async { StatusCode::NO_CONTENT })) + .layer(http_trace_layer()) + .layer(from_fn(attach_request_id)); + + let _guard = tracing::subscriber::set_default(subscriber); + let response = app + .oneshot( + Request::builder() + .uri("/trace-test?token=secret-token&code=secret-code") + .header("x-request-id", "req_trace_query") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let _body = to_bytes(response.into_body(), 1024).await.unwrap(); + + let output = wait_for_trace_output(&logs).await; + assert!( + output.contains("/trace-test?token=&code=") + && !output.contains("secret-token") + && !output.contains("secret-code") + && output.contains("token=") + && output.contains("code="), + "unexpected trace output: {output}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_trace_should_redact_bare_query_segments() { + let logs = SharedLogBuffer::default(); + let subscriber = tracing_subscriber::fmt() + .json() + .with_writer(logs.clone()) + .with_current_span(true) + .with_span_list(true) + .with_ansi(false) + .finish(); + + let app = Router::new() + .route("/trace-test", get(|| async { StatusCode::NO_CONTENT })) + .layer(http_trace_layer()) + .layer(from_fn(attach_request_id)); + + let _guard = tracing::subscriber::set_default(subscriber); + let response = app + .oneshot( + Request::builder() + .uri("/trace-test?bare-secret&empty=&page=2&page=3") + .header("x-request-id", "req_trace_query_bare") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let _body = to_bytes(response.into_body(), 1024).await.unwrap(); + + let output = wait_for_trace_output(&logs).await; + assert!( + output.contains("/trace-test?&empty=&page=&page=") + && !output.contains("bare-secret") + && !output.contains("page=2") + && !output.contains("page=3"), + "unexpected trace output: {output}" + ); +} + +#[tokio::test] +async fn request_context_should_generate_new_request_id_for_invalid_header() { + let app = Router::new() + .route("/request-id", get(|| async { StatusCode::NO_CONTENT })) + .layer(from_fn(attach_request_id)); + + let response = app + .oneshot( + Request::builder() + .uri("/request-id") + .header("x-request-id", "req invalid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let request_id = response + .headers() + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + + assert!( + request_id.starts_with("req_") && request_id != "req invalid", + "unexpected request id: {request_id}" + ); +} + #[tokio::test] async fn request_context_should_attach_real_ip_from_connection() { let app = Router::new() @@ -137,7 +247,7 @@ async fn request_context_should_attach_real_ip_from_connection() { } #[tokio::test] -async fn request_context_should_not_override_forwarded_ip_headers() { +async fn request_context_should_replace_untrusted_forwarded_ip_headers() { let app = Router::new() .route( "/ip", @@ -153,6 +263,8 @@ async fn request_context_should_not_override_forwarded_ip_headers() { let mut request = Request::builder() .uri("/ip") .header("x-forwarded-for", "203.0.113.42, 10.0.0.2") + .header("cf-connecting-ip", "203.0.113.43") + .header("x-real-ip", "203.0.113.44") .body(Body::empty()) .expect("request should build"); request @@ -162,5 +274,88 @@ async fn request_context_should_not_override_forwarded_ip_headers() { let response = app.oneshot(request).await.expect("response"); let bytes = to_bytes(response.into_body(), 1024).await.unwrap(); - assert_eq!(std::str::from_utf8(&bytes).unwrap(), "missing"); + assert_eq!(std::str::from_utf8(&bytes).unwrap(), "127.0.0.1"); +} + +#[tokio::test] +async fn request_context_should_accept_forwarded_ip_from_trusted_proxy() { + let trusted_proxies = + TrustedProxyConfig::from_entries(&["127.0.0.0/8".to_string()]).expect("trusted proxy"); + let app = Router::new() + .route( + "/ip", + get(|headers: HeaderMap| async move { + let real_ip = headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .unwrap_or("missing"); + let forwarded_present = headers.contains_key("x-forwarded-for"); + format!("{real_ip}|forwarded={forwarded_present}") + }), + ) + .layer(from_fn_with_state( + trusted_proxies, + attach_request_id_with_proxy_config, + )); + let mut request = Request::builder() + .uri("/ip") + .header("x-forwarded-for", "203.0.113.42, 10.0.0.2") + .body(Body::empty()) + .expect("request should build"); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 61234)))); + + let response = app.oneshot(request).await.expect("response"); + let bytes = to_bytes(response.into_body(), 1024).await.unwrap(); + + assert_eq!( + std::str::from_utf8(&bytes).unwrap(), + "203.0.113.42|forwarded=false" + ); +} + +#[tokio::test] +async fn request_context_should_prefer_cloudflare_ip_from_trusted_proxy() { + let trusted_proxies = + TrustedProxyConfig::from_entries(&["127.0.0.1".to_string()]).expect("trusted proxy"); + let app = Router::new() + .route( + "/ip", + get(|headers: HeaderMap| async move { + headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .unwrap_or("missing") + .to_string() + }), + ) + .layer(from_fn_with_state( + trusted_proxies, + attach_request_id_with_proxy_config, + )); + let mut request = Request::builder() + .uri("/ip") + .header("cf-connecting-ip", "198.51.100.23") + .header("x-forwarded-for", "203.0.113.42, 10.0.0.2") + .body(Body::empty()) + .expect("request should build"); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 61234)))); + + let response = app.oneshot(request).await.expect("response"); + let bytes = to_bytes(response.into_body(), 1024).await.unwrap(); + + assert_eq!(std::str::from_utf8(&bytes).unwrap(), "198.51.100.23"); +} + +#[test] +fn trusted_proxy_config_should_reject_invalid_entries() { + let error = TrustedProxyConfig::from_entries(&["not-an-ip".to_string()]).unwrap_err(); + + assert!( + error.to_string().contains("not-an-ip"), + "unexpected error: {error}" + ); } diff --git a/backend/tests/http/web_assets/mod.rs b/backend/tests/http/web_assets/mod.rs index a28b11be4..8d8844868 100644 --- a/backend/tests/http/web_assets/mod.rs +++ b/backend/tests/http/web_assets/mod.rs @@ -1,9 +1,13 @@ +use std::path::Path; + use axum::{ body::{to_bytes, Body}, http::{header, Request, StatusCode}, + Router, }; use codex_proxy_rs::{config::types::AppConfig, infra::database::connect_sqlite}; use serde_json::Value; +use sqlx::SqlitePool; use tower::ServiceExt; use crate::support::{config::test_config as base_test_config, http::response_json}; @@ -34,8 +38,124 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes .expect("asset should be written"); let db = dir.path().join("assets-routes.sqlite"); let database_url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&database_url).await.expect("sqlite pool"); - let config = test_config(database_url); + let (app, _pool) = router_with_dist_and_database(&dist, &database_url).await; + + let index = app.clone().oneshot(request("/")).await.expect("index"); + let asset = app + .clone() + .oneshot(request("/assets/app.js")) + .await + .expect("asset"); + let api = app + .clone() + .oneshot(request("/api/admin/settings")) + .await + .expect("api response"); + let health = app + .oneshot(request("/healthz")) + .await + .expect("health response"); + + assert_eq!(index.status(), StatusCode::OK); + assert_eq!(asset.status(), StatusCode::OK); + assert_eq!(api.status(), StatusCode::UNAUTHORIZED); + assert_eq!(health.status(), StatusCode::NO_CONTENT); + let csp = index + .headers() + .get(header::CONTENT_SECURITY_POLICY) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + assert!( + csp.contains("style-src 'self';") && !csp.contains("unsafe-inline"), + "unexpected content security policy: {csp}" + ); + assert_eq!( + asset.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static( + "public, max-age=31536000, immutable" + )) + ); + assert_body_contains(index, "Codex Proxy Admin").await; + assert_body_contains(asset, "__asset").await; + assert_eq!(response_json(api).await["code"], Value::from(40101)); +} + +#[tokio::test] +async fn server_router_should_return_json_404_for_unknown_api_routes() { + let dir = tempfile::tempdir().expect("temp dir"); + let dist = dir.path().join("dist"); + std::fs::create_dir_all(&dist).expect("dist dir should be created"); + std::fs::write( + dist.join("index.html"), + "
Codex Proxy Admin
", + ) + .expect("index should be written"); + let db = dir.path().join("unknown-api-routes.sqlite"); + let database_url = format!("sqlite://{}", db.display()); + let (app, _pool) = router_with_dist_and_database(&dist, &database_url).await; + + let admin = app + .clone() + .oneshot(request("/api/admin/does-not-exist")) + .await + .expect("admin unknown response"); + let openai = app + .oneshot(request("/v1/does-not-exist")) + .await + .expect("openai unknown response"); + + assert_unknown_api_route(admin).await; + assert_unknown_api_route(openai).await; +} + +#[tokio::test] +async fn healthz_should_report_unavailable_when_sqlite_is_closed() { + let dir = tempfile::tempdir().expect("temp dir"); + let dist = dir.path().join("dist"); + std::fs::create_dir_all(&dist).expect("dist dir should be created"); + std::fs::write(dist.join("index.html"), "").expect("index should be written"); + let db = dir.path().join("healthz.sqlite"); + let database_url = format!("sqlite://{}", db.display()); + let (app, pool) = router_with_dist_and_database(&dist, &database_url).await; + pool.close().await; + + let response = app + .oneshot(request("/healthz")) + .await + .expect("health response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +fn request(uri: &str) -> Request { + Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("request should build") +} + +async fn assert_body_contains(response: axum::response::Response, expected: &str) { + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should collect"); + let text = std::str::from_utf8(&bytes).expect("body should be utf8"); + + assert!( + text.contains(expected), + "body should contain `{expected}`, got `{text}`" + ); +} + +async fn assert_unknown_api_route(response: axum::response::Response) { + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = response_json(response).await; + assert_eq!(body["code"], Value::from(40401)); + assert_eq!(body["message"], Value::from("API route not found")); +} + +async fn router_with_dist_and_database(dist: &Path, database_url: &str) -> (Router, SqlitePool) { + let pool = connect_sqlite(database_url).await.expect("sqlite pool"); + let config = test_config(database_url.to_string()); let stores = codex_proxy_rs::runtime::services::BackgroundTaskStores { accounts: codex_proxy_rs::upstream::accounts::store::SqliteAccountStore::new(pool.clone()), admin_sessions: codex_proxy_rs::admin::auth::service::SqliteAdminSessionStore::new( @@ -68,50 +188,10 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes config, services: (*services).clone(), }; - let app = codex_proxy_rs::http::router::router_with_assets(&dist).with_state(state); - - let index = app.clone().oneshot(request("/")).await.expect("index"); - let asset = app - .clone() - .oneshot(request("/assets/app.js")) - .await - .expect("asset"); - let api = app - .oneshot(request("/api/admin/settings")) - .await - .expect("api response"); - - assert_eq!(index.status(), StatusCode::OK); - assert_eq!(asset.status(), StatusCode::OK); - assert_eq!(api.status(), StatusCode::UNAUTHORIZED); - assert_eq!( - asset.headers().get(header::CACHE_CONTROL), - Some(&header::HeaderValue::from_static( - "public, max-age=31536000, immutable" - )) - ); - assert_body_contains(index, "Codex Proxy Admin").await; - assert_body_contains(asset, "__asset").await; - assert_eq!(response_json(api).await["code"], Value::from(40101)); -} - -fn request(uri: &str) -> Request { - Request::builder() - .uri(uri) - .body(Body::empty()) - .expect("request should build") -} - -async fn assert_body_contains(response: axum::response::Response, expected: &str) { - let bytes = to_bytes(response.into_body(), usize::MAX) - .await - .expect("body should collect"); - let text = std::str::from_utf8(&bytes).expect("body should be utf8"); - - assert!( - text.contains(expected), - "body should contain `{expected}`, got `{text}`" - ); + ( + codex_proxy_rs::http::router::router_with_assets(dist).with_state(state), + pool, + ) } fn test_config(database_url: String) -> AppConfig { diff --git a/backend/tests/infra/storage_schema/mod.rs b/backend/tests/infra/storage_schema/mod.rs index e1e1783ec..d0cb8abf9 100644 --- a/backend/tests/infra/storage_schema/mod.rs +++ b/backend/tests/infra/storage_schema/mod.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use codex_proxy_rs::infra::database::connect_sqlite; #[tokio::test] @@ -8,7 +10,7 @@ async fn sqlite_schema_should_create_current_tables() { let pool = connect_sqlite(&url).await.unwrap(); let rows: Vec<(String,)> = sqlx::query_as( - "select name from sqlite_master where type = 'table' and name in ('admin_users', 'admin_sessions', 'client_api_keys', 'runtime_settings', 'accounts', 'account_refresh_leases', 'account_usage', 'account_model_usage', 'usage_time_buckets', 'model_account_routes', 'account_cookies', 'fingerprints', 'fingerprint_update_history', 'usage_records', 'model_plan_snapshots', 'session_affinities') order by name", + "select name from sqlite_master where type = 'table' and name in ('schema_migrations', 'admin_users', 'admin_sessions', 'client_api_keys', 'runtime_settings', 'accounts', 'account_refresh_leases', 'account_usage', 'account_model_usage', 'usage_time_buckets', 'model_account_routes', 'account_cookies', 'fingerprints', 'fingerprint_update_history', 'usage_records', 'model_plan_snapshots', 'session_affinities') order by name", ) .fetch_all(&pool) .await @@ -29,6 +31,7 @@ async fn sqlite_schema_should_create_current_tables() { "model_account_routes", "model_plan_snapshots", "runtime_settings", + "schema_migrations", "session_affinities", "usage_records", "usage_time_buckets", @@ -36,6 +39,59 @@ async fn sqlite_schema_should_create_current_tables() { ); } +#[tokio::test] +async fn sqlite_schema_should_record_applied_migrations() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("schema-migrations.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + + let rows: Vec<(i64, String)> = + sqlx::query_as("select version, name from schema_migrations order by version") + .fetch_all(&pool) + .await + .unwrap(); + + assert_eq!(rows, [(1, "initial".to_string())]); +} + +#[test] +fn initial_migration_should_not_use_idempotent_business_schema_ddl() { + let migration_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("infra") + .join("migrations") + .join("0001_initial.sql"); + let migration = std::fs::read_to_string(migration_path).unwrap(); + + assert!(!migration.to_lowercase().contains("if not exists")); +} + +#[tokio::test] +async fn connect_sqlite_should_reject_unversioned_existing_schema() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("unversioned.sqlite"); + let url = format!("sqlite://{}", db.display()); + + std::fs::File::create(&db).unwrap(); + let pool = sqlx::SqlitePool::connect(&url).await.unwrap(); + sqlx::query("create table admin_users (id text primary key)") + .execute(&pool) + .await + .unwrap(); + pool.close().await; + + let err = match connect_sqlite(&url).await { + Ok(pool) => { + pool.close().await; + panic!("unversioned sqlite schema should be rejected"); + } + Err(err) => err, + }; + + assert!(err.to_string().contains("unversioned sqlite schema")); +} + #[tokio::test] async fn sqlite_schema_should_persist_runtime_settings_columns() { let dir = tempfile::tempdir().unwrap(); diff --git a/backend/tests/proxy/auth.rs b/backend/tests/proxy/auth.rs new file mode 100644 index 000000000..7346c20a7 --- /dev/null +++ b/backend/tests/proxy/auth.rs @@ -0,0 +1,54 @@ +use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue}; +use codex_proxy_rs::proxy::auth::{bearer_client_api_key, ClientApiKeyAuthError}; + +#[test] +fn bearer_client_api_key_should_report_missing_authorization() { + let headers = HeaderMap::new(); + + let result = bearer_client_api_key(&headers); + + assert_eq!(result, Err(ClientApiKeyAuthError::MissingAuthorization)); +} + +#[test] +fn bearer_client_api_key_should_report_malformed_authorization() { + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Token sk_test")); + + let result = bearer_client_api_key(&headers); + + assert_eq!(result, Err(ClientApiKeyAuthError::MalformedAuthorization)); +} + +#[test] +fn bearer_client_api_key_should_report_empty_bearer_token() { + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer ")); + + let result = bearer_client_api_key(&headers); + + assert_eq!(result, Err(ClientApiKeyAuthError::MalformedAuthorization)); +} + +#[test] +fn bearer_client_api_key_should_report_invalid_key_format() { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer admin-secret"), + ); + + let result = bearer_client_api_key(&headers); + + assert_eq!(result, Err(ClientApiKeyAuthError::InvalidKeyFormat)); +} + +#[test] +fn bearer_client_api_key_should_extract_trimmed_client_key() { + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer sk_test ")); + + let result = bearer_client_api_key(&headers); + + assert_eq!(result, Ok("sk_test")); +} diff --git a/backend/tests/proxy/dispatch/chat_upstream/mod.rs b/backend/tests/proxy/dispatch/chat_upstream/mod.rs index 393e03ad4..ec27bcd74 100644 --- a/backend/tests/proxy/dispatch/chat_upstream/mod.rs +++ b/backend/tests/proxy/dispatch/chat_upstream/mod.rs @@ -15,7 +15,7 @@ use codex_proxy_rs::{ admin::auth::service::SqliteAdminSessionStore, admin::keys::service::SqliteClientKeyStore, admin::monitoring::{ - usage_record::UsageRecordLevel, + usage_record_model::UsageRecordLevel, usage_record_store::{ SqliteUsageRecordStore, UsageRecordFilter, DEFAULT_USAGE_RECORD_CAPACITY, }, diff --git a/backend/tests/proxy/mod.rs b/backend/tests/proxy/mod.rs index 9458e7e74..62d3d5566 100644 --- a/backend/tests/proxy/mod.rs +++ b/backend/tests/proxy/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod auth; pub(crate) mod dispatch; pub(crate) mod keys; pub(crate) mod openai; diff --git a/backend/tests/proxy/openai/models_routes.rs b/backend/tests/proxy/openai/models_routes.rs index 81e7288cc..25ff78b8e 100644 --- a/backend/tests/proxy/openai/models_routes.rs +++ b/backend/tests/proxy/openai/models_routes.rs @@ -1,6 +1,7 @@ use axum::{ body::Body, http::{Request, StatusCode}, + Router, }; use codex_proxy_rs::{ admin::auth::service::SqliteAdminSessionStore, @@ -16,36 +17,13 @@ use codex_proxy_rs::{ }; use tower::util::ServiceExt; -use crate::support::{client_keys::insert_client_api_key, config::test_config}; +use crate::support::{ + client_keys::insert_client_api_key, config::test_config, http::response_json, +}; #[tokio::test] async fn models_route_should_reject_unknown_client_api_key() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("openai-models-auth.sqlite"); - let url = format!("sqlite://{}", db.display()); - let pool = connect_sqlite(&url).await.unwrap(); - let config = test_config(url); - let stores = BackgroundTaskStores { - accounts: SqliteAccountStore::new(pool.clone()), - admin_sessions: SqliteAdminSessionStore::new(pool.clone()), - cookies: SqliteCookieStore::new(pool.clone()), - fingerprints: FingerprintRepository::new(pool.clone()), - session_affinity: SqliteSessionAffinityStore::new(pool.clone()), - refresh_leases: RefreshLeaseStore::new(pool.clone()), - client_keys: SqliteClientKeyStore::new(pool.clone()), - usage_records: SqliteUsageRecordStore::new(pool.clone()), - }; - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); - services - .initialize_hot_path_state() - .await - .expect("hot path state should initialize"); - let state = AppState { - config, - services: (*services).clone(), - }; - let app = codex_proxy_rs::http::router::router().with_state(state); + let (app, _key, _dir) = test_app("openai-models-auth.sqlite", false).await; let response = app .oneshot( @@ -61,13 +39,61 @@ async fn models_route_should_reject_unknown_client_api_key() { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn models_route_should_return_uniform_error_for_malformed_authorization() { + let (app, _key, _dir) = test_app("openai-models-auth-malformed.sqlite", false).await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", "Token sk_not_bearer") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response_json(response).await["error"]["code"], + "invalid_api_key" + ); +} + #[tokio::test] async fn models_route_should_accept_stored_client_api_key() { + let (app, plaintext, _dir) = test_app("openai-models-auth-valid.sqlite", true).await; + let plaintext = plaintext.expect("client api key should be seeded"); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +async fn test_app( + db_name: &str, + seed_client_key: bool, +) -> (Router, Option, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("openai-models-auth-valid.sqlite"); + let db = dir.path().join(db_name); let url = format!("sqlite://{}", db.display()); let pool = connect_sqlite(&url).await.unwrap(); - let plaintext = insert_client_api_key(&pool).await; + let plaintext = if seed_client_key { + Some(insert_client_api_key(&pool).await) + } else { + None + }; let config = test_config(url); let stores = BackgroundTaskStores { accounts: SqliteAccountStore::new(pool.clone()), @@ -91,16 +117,5 @@ async fn models_route_should_accept_stored_client_api_key() { }; let app = codex_proxy_rs::http::router::router().with_state(state); - let response = app - .oneshot( - Request::builder() - .method("GET") - .uri("/v1/models") - .header("authorization", format!("Bearer {plaintext}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + (app, plaintext, dir) } diff --git a/backend/tests/runtime/services.rs b/backend/tests/runtime/services.rs index 9a67b03e9..344c5282a 100644 --- a/backend/tests/runtime/services.rs +++ b/backend/tests/runtime/services.rs @@ -14,9 +14,26 @@ use codex_proxy_rs::{ transport::tls::CODEX_CA_CERT_ENV, }, }; +use sqlx::SqlitePool; use crate::support::{config::test_config, sqlite::init_test_db}; +fn stores(pool: SqlitePool) -> BackgroundTaskStores { + BackgroundTaskStores { + accounts: SqliteAccountStore::new(pool.clone()), + admin_sessions: SqliteAdminSessionStore::new(pool.clone()), + cookies: SqliteCookieStore::new(pool.clone()), + fingerprints: FingerprintRepository::new(pool.clone()), + session_affinity: + codex_proxy_rs::proxy::dispatch::session_affinity::SqliteSessionAffinityStore::new( + pool.clone(), + ), + refresh_leases: RefreshLeaseStore::new(pool.clone()), + client_keys: SqliteClientKeyStore::new(pool.clone()), + usage_records: SqliteUsageRecordStore::new(pool), + } +} + #[tokio::test] async fn services_try_new_should_use_configured_tls_transport_builder() { const CASE_ENV: &str = "CODEX_PROXY_TEST_SERVICES_TLS_CASE"; @@ -27,19 +44,7 @@ async fn services_try_new_should_use_configured_tls_transport_builder() { "sqlite://{}", temp_dir.path().join("services-tls.sqlite").display() )); - let stores = BackgroundTaskStores { - accounts: SqliteAccountStore::new(pool.clone()), - admin_sessions: SqliteAdminSessionStore::new(pool.clone()), - cookies: SqliteCookieStore::new(pool.clone()), - fingerprints: FingerprintRepository::new(pool.clone()), - session_affinity: - codex_proxy_rs::proxy::dispatch::session_affinity::SqliteSessionAffinityStore::new( - pool.clone(), - ), - refresh_leases: RefreshLeaseStore::new(pool.clone()), - client_keys: SqliteClientKeyStore::new(pool.clone()), - usage_records: SqliteUsageRecordStore::new(pool), - }; + let stores = stores(pool); let Err(error) = Services::try_new( &config, @@ -71,3 +76,44 @@ async fn services_try_new_should_use_configured_tls_transport_builder() { String::from_utf8_lossy(&output.stderr) ); } + +#[tokio::test] +async fn services_try_new_should_expose_websocket_pool_when_enabled() { + let (pool, temp_dir) = init_test_db("services-ws-pool-enabled.sqlite").await; + let config = test_config(format!( + "sqlite://{}", + temp_dir + .path() + .join("services-ws-pool-enabled.sqlite") + .display() + )); + let services = Services::try_new( + &config, + stores(pool), + crate::support::fingerprint::test_fingerprint(), + ) + .expect("services should build"); + + assert!(services.websocket_pool.is_some()); +} + +#[tokio::test] +async fn services_try_new_should_not_expose_websocket_pool_when_disabled() { + let (pool, temp_dir) = init_test_db("services-ws-pool-disabled.sqlite").await; + let mut config = test_config(format!( + "sqlite://{}", + temp_dir + .path() + .join("services-ws-pool-disabled.sqlite") + .display() + )); + config.ws_pool.enabled = false; + let services = Services::try_new( + &config, + stores(pool), + crate::support::fingerprint::test_fingerprint(), + ) + .expect("services should build"); + + assert!(services.websocket_pool.is_none()); +} diff --git a/backend/tests/runtime/tasks/coordinator.rs b/backend/tests/runtime/tasks/coordinator.rs index 9c0409bd3..2e714cd4d 100644 --- a/backend/tests/runtime/tasks/coordinator.rs +++ b/backend/tests/runtime/tasks/coordinator.rs @@ -1,5 +1,31 @@ +use std::{sync::Arc, time::Duration}; + +use codex_proxy_rs::{ + runtime::tasks::coordinator::SchedulerHandle, + upstream::transport::{CodexWebSocketPool, CodexWebSocketPoolConfig}, +}; + #[tokio::test] async fn task_coordinator_should_shutdown_without_panicking() { let coordinator = codex_proxy_rs::runtime::tasks::coordinator::TaskCoordinator::default(); coordinator.shutdown().await; } + +#[tokio::test] +async fn scheduler_handle_should_shutdown_websocket_pool() { + let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig { + maintenance_interval: None, + ping_interval: None, + liveness_timeout: None, + max_age: Duration::from_secs(60), + max_per_account: 1, + enabled: true, + ping_timeout: Duration::ZERO, + })); + + SchedulerHandle::from_websocket_pool(pool.clone()) + .shutdown() + .await; + + assert!(pool.is_shutdown().await); +} diff --git a/backend/tests/support/config.rs b/backend/tests/support/config.rs index 8f79a4185..851050d9b 100644 --- a/backend/tests/support/config.rs +++ b/backend/tests/support/config.rs @@ -10,6 +10,7 @@ pub(crate) fn test_config(database_url: String) -> AppConfig { server: ServerConfig { host: "127.0.0.1".to_string(), port: 0, + trusted_proxies: Vec::new(), }, api: ApiConfig { base_url: "https://chatgpt.com/backend-api".to_string(), @@ -41,7 +42,7 @@ pub(crate) fn test_config(database_url: String) -> AppConfig { session_ttl_minutes: 1440, session_cleanup_interval_secs: 3600, default_username: "admin".to_string(), - default_password: "admin".to_string(), + default_password: "test-admin-password".to_string(), }, logging: LoggingConfig { directory: "logs".to_string(), diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 03e60e75e..8e60b3de9 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -51,7 +51,10 @@ COPY --from=release-asset-builder /out / FROM debian:bookworm-slim AS runtime-base RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ + && 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 \ && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -59,18 +62,23 @@ ENV CPR_DEPLOYMENT_MODE=docker 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"] FROM runtime-base AS runtime -COPY --from=backend-builder /tmp/codex-proxy-rs /usr/local/bin/codex-proxy-rs -COPY --from=frontend-builder /app/frontend/dist ./web/dist +COPY --from=backend-builder --chown=cpr:cpr /tmp/codex-proxy-rs /usr/local/bin/codex-proxy-rs +COPY --from=frontend-builder --chown=cpr:cpr /app/frontend/dist ./web/dist +USER cpr FROM runtime-base AS runtime-prebuilt ARG TARGETARCH ARG CPR_PREBUILT_DIR=dist/docker -COPY ${CPR_PREBUILT_DIR}/linux-${TARGETARCH}/codex-proxy-rs /usr/local/bin/codex-proxy-rs -COPY ${CPR_PREBUILT_DIR}/web/dist ./web/dist +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}/web/dist ./web/dist RUN chmod +x /usr/local/bin/codex-proxy-rs +USER cpr diff --git a/deploy/Dockerfile.dockerignore b/deploy/Dockerfile.dockerignore index 1f4d0c7a7..8f04c4391 100644 --- a/deploy/Dockerfile.dockerignore +++ b/deploy/Dockerfile.dockerignore @@ -2,6 +2,10 @@ **/target/ frontend/node_modules/ frontend/dist/ +frontend/.vite/ +dist/* +!dist/docker/ +!dist/docker/** # Application runtime state. .runtime/ @@ -14,6 +18,7 @@ deploy/logs/ *.db *.db-shm *.db-wal +*.sqlite-* *.log # Local configuration. diff --git a/deploy/README.md b/deploy/README.md index 47901bc8a..0c7468561 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -10,7 +10,9 @@ Docker 部署入口集中在本目录。 cp deploy/config.example.yaml deploy/config.yaml ``` -`deploy/config.example.yaml` 是 Docker 部署模板,默认将数据写入容器内 `/app/data`、日志写入 `/app/logs`,由 Compose 挂载到宿主机 `deploy/data` 和 `deploy/logs`。 +`deploy/config.example.yaml` 是 Docker 部署模板,默认将数据写入容器内 `/app/data`、日志写入 `/app/logs`,由 Compose 命名卷持久化。 +复制后必须设置 `admin.default_password`,空值或常见弱口令会被后端拒绝启动。 +如果前面还有 Nginx、Caddy、Cloudflare Tunnel 等反向代理,只在该代理会覆盖或清理 `X-Forwarded-For` / `X-Real-IP` / `CF-Connecting-IP` 时,把代理的 IP 或 CIDR 写入 `server.trusted_proxies`;默认空列表会剥离这些客户端可伪造的头并使用 TCP peer IP。 构建并启动: @@ -19,18 +21,16 @@ docker compose -f deploy/docker-compose.yml build docker compose -f deploy/docker-compose.yml up -d ``` -默认挂载: +默认挂载和卷: - `deploy/config.yaml` -> `/app/config.yaml` -- `deploy/data` -> `/app/data` -- `deploy/logs` -> `/app/logs` +- `cpr-data` -> `/app/data` +- `cpr-logs` -> `/app/logs` -可以通过环境变量覆盖: +可以通过环境变量覆盖宿主机配置文件路径;容器内会通过 `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 43950c09b..9d4ae4ffa 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -3,6 +3,8 @@ server: host: '0.0.0.0' # 容器内服务端口,需与 docker-compose.yml 的 target port 一致。 port: 8080 + # 可信反向代理 IP 或 CIDR。为空时会忽略并剥离客户端传入的转发 IP 头。 + trusted_proxies: [] api: # ChatGPT / Codex 上游 API 地址。 @@ -82,9 +84,9 @@ admin: session_ttl_minutes: 1440 # 过期会话清理间隔,单位秒。 session_cleanup_interval_secs: 3600 - # 首次启动创建的默认管理员账号。生产部署前必须修改默认密码。 + # 首次启动创建的默认管理员账号。default_password 必须改成至少 12 位的非默认密码。 default_username: admin@cpr.local - default_password: 123456 + default_password: '' logging: # Docker 部署默认写入 /app/logs,由 Compose 挂载到宿主机 deploy/logs。 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 2dad3dfd0..f61074222 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -12,13 +12,32 @@ services: CPR_BUILD_TYPE: ${CPR_BUILD_TYPE:-release} container_name: codex-proxy-rs restart: unless-stopped + user: "10001:10001" + init: true + stop_grace_period: 30s + pids_limit: 256 + cpus: "2.0" + mem_limit: 1g ports: - - "8080:8080" + - "127.0.0.1:8080:8080" volumes: - ${CPR_CONFIG_FILE:-./config.yaml}:/app/config.yaml:ro - - ${CPR_DATA_DIR:-./data}:/app/data - - ${CPR_LOG_DIR:-./logs}:/app/logs + - cpr-data:/app/data + - cpr-logs:/app/logs + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + tmpfs: + - /tmp:size=64m,mode=1777 + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s environment: + CPR_CONFIG_FILE: /app/config.yaml CPR_DEPLOYMENT_MODE: docker CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs CPR_UPDATE_CHANNEL: stable @@ -26,3 +45,7 @@ services: 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 53026d780..e26e3e2b8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -154,7 +154,7 @@ Docker 构建入口是 `deploy/Dockerfile`,上下文保持仓库根目录。 - `frontend-builder`:构建 Vue SPA。 - `backend-builder`:构建 Rust release 二进制,并复制前端 dist。 - `release-asset`:导出 GitHub Release tar 包。 -- `runtime`:最小运行镜像,基于 `debian:bookworm-slim`,只包含 CA、后端二进制和 `web/dist`。 +- `runtime`:运行镜像基于 `debian:bookworm-slim`,包含 CA、curl healthcheck、后端二进制和 `web/dist`,默认以非 root 用户运行。 Compose 文件是 `deploy/docker-compose.yml`。示例配置只保留 `deploy/config.example.yaml`,用户复制为 `deploy/config.yaml` 后部署。 diff --git a/docs/audit-2026-07-03.md b/docs/audit-2026-07-03.md new file mode 100644 index 000000000..09cf6f93a --- /dev/null +++ b/docs/audit-2026-07-03.md @@ -0,0 +1,279 @@ +# Codex Proxy RS 项目审计结论 + +审计日期:2026-07-03 + +## 结论 + +项目整体工程质量高于普通自托管代理项目:Rust 后端边界清晰,热路径有较完整的集成测试,账号调度、限流、WebSocket fallback、SSE 终止补偿、使用记录裁剪等关键路径都有明确实现和测试支撑。首轮已完成弱默认密码、管理 Cookie、请求头边界、登录失败节流、账号导出空 ids 拦截和 Docker 默认部署面的加固。当前主要风险不在“代码不可运行”,而在敏感数据落盘形态、运行时任务生命周期、数据库升级机制和发布供应链。 + +上线前优先处理四类事项: + +1. 安全默认值:弱默认管理员密码、管理会话 Cookie `Secure`、登录失败节流、登录审计落库、trusted proxy 显式配置、Docker 本机绑定、容器 healthcheck/rootless/security options 已完成首轮整改;管理端 CSP 已移除 `unsafe-inline`。 +2. 敏感数据:客户端 API Key、admin API Key、账号 access/refresh token、Cloudflare cookie 目前均明文存 SQLite,需要分层加密或不可逆哈希策略;该项按本轮决策暂缓,不与当前加固批次混做。 +3. 运维闭环:WebSocket 池已纳入 `TaskCoordinator` 统一 shutdown;构建与安全扫描已有 CI;本轮已清理当前 RustSec warning 来源。 +4. 在线更新供应链:系统更新接口有管理员鉴权、进程内锁、文件锁、下载大小上限、GitHub API base 信任边界、HTTPS/GitHub host 校验、强制 `checksums.txt` SHA256 校验和 tar 路径穿越检查;发布 workflow 已增加 release asset provenance、容器镜像 SBOM/provenance 和 cosign keyless 签名,运行时更新客户端仍需加入签名/provenance 验证。 + +## 审计范围 + +覆盖范围: + +- 后端入口、运行时 bootstrap、服务组合、HTTP router。 +- 管理端认证、session、admin API key、client API key、账号管理和设置。 +- `/v1` 代理认证、OpenAI 兼容协议转换、调度、限流、WebSocket/SSE 路径。 +- SQLite schema、使用记录、敏感字段、配置加载和部署配置。 +- Vue 管理端路由守卫、API 请求层、登录流、构建配置。 +- 系统在线更新、回滚、自重启、实时更新日志和前端更新弹窗。 +- CI、安全扫描、Docker/Compose、发布/更新文档。 + +未做事项: + +- 未做真实上游压测或长连接 soak test。 +- 未在本地实际执行容器镜像漏洞扫描;security workflow 已配置 runtime 镜像 Trivy 扫描。 +- 未做浏览器端 Playwright 可视化/交互回归。 +- 未实际触发 GitHub release workflow;本轮仅做 workflow 配置解析、action SHA pinning 校验和本地 Docker build 验证。 + +## 验证结果 + +已执行: + +- `cargo fmt --manifest-path backend/Cargo.toml --check`:通过。 +- `cargo test --manifest-path backend/Cargo.toml --test main config`:18 个相关测试通过,覆盖管理员默认密码校验、`CPR_CONFIG_FILE` 显式配置路径、`server.trusted_proxies` 配置解析和 `server` 未知字段拒绝。 +- `cargo test --manifest-path backend/Cargo.toml --test main admin::auth`:9 个相关测试通过,覆盖管理端登录 Cookie、失败节流、成功/失败/限流登录审计落库。 +- `cargo test --manifest-path backend/Cargo.toml --test main admin::accounts::exporting`:6 个相关测试通过,覆盖空 ids 拒绝导出、显式敏感导出确认、按 ids 导出和导出审计落库。 +- `cargo test --manifest-path backend/Cargo.toml --test main admin::system`:18 个相关测试通过,覆盖更新缓存、更新成功、GitHub API base 信任边界、跨文件系统替换、缺失/不匹配 checksum 失败、非允许下载域名拒绝、HTTP 下载绕过开关失效、tar 路径穿越拒绝、替换失败回滚、stale 文件锁清理、优雅重启请求、更新状态和 SSE 认证。 +- `cargo test --manifest-path backend/Cargo.toml --test main infra::storage_schema`:29 个相关测试通过,覆盖当前表结构、迁移记录、初始迁移不使用 `if not exists`、未版本化旧库拒绝和 SQLite 约束。 +- `cargo test --manifest-path backend/Cargo.toml --test main runtime::`:16 个相关测试通过,覆盖 WebSocket pool shutdown 句柄、Services 暴露池句柄和后台任务关闭。 +- `cargo test --manifest-path backend/Cargo.toml --test main proxy::`:150 个相关测试通过,覆盖 `/v1` client key 鉴权失败原因解析、对外统一 401、调度、WebSocket/SSE、usage logging 和 session affinity。 +- `cargo test --manifest-path backend/Cargo.toml --test main http`:50 个相关测试通过,覆盖 `/healthz` SQLite 可用性、request id 校验、可信代理 IP/CIDR、转发 IP 头收紧、CSP 不含 `unsafe-inline`、HTTP trace query 参数值脱敏和未知 API 路径不落入 SPA fallback。 +- `docker compose -f deploy/docker-compose.yml config`:通过,确认本机端口绑定、healthcheck、非 root 用户、cap drop、命名卷和容器内 `CPR_CONFIG_FILE=/app/config.yaml`。 +- `docker build -f deploy/Dockerfile --target runtime -t codex-proxy-rs:runtime-check .`:通过,确认 `deploy/Dockerfile.dockerignore` 生效;本次 Docker 内前端构建未复现 Rolldown `INVALID_ANNOTATION` 警告。 +- `cargo clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings`:通过。 +- `cargo test --manifest-path backend/Cargo.toml --test main admin::keys`:9 个相关测试通过,覆盖 client key 创建、列表、更新、启停、删除和运行时鉴权缓存。 +- `cargo test --manifest-path backend/Cargo.toml --test main admin::accounts::lifecycle`:12 个相关测试通过,覆盖账号创建、状态更新、刷新、健康检查和账号池同步。 +- `cargo test --manifest-path backend/Cargo.toml --test main`:589 个测试全部通过。 +- `pnpm --dir frontend build`:通过;本次本地构建未复现 Rolldown `INVALID_ANNOTATION` 警告。 +- `pnpm --dir frontend audit --prod --audit-level=high`:通过,无 high 级以上已知漏洞。 +- `cargo audit`:通过,无漏洞或 allowed warnings;`serde_yml` 和 `validator_derive` 引入的 `proc-macro-error2` 已从依赖图移除。 +- `cargo tree --manifest-path backend/Cargo.toml -d`:直接 `sha2` 已收敛到现有 `0.10.9` 依赖线;仍存在多版本依赖,包括 `rand 0.9/0.10`、`getrandom 0.2/0.3/0.4`、`sqlx-sqlite` 重复树等。 +- `ruby -e 'require "yaml"; ...' .github/workflows/*.yml`:通过,确认 CI/security/release workflow YAML 可解析。 +- workflow action pinning 校验脚本:通过,`.github/workflows/*.yml` 内所有 `uses:` ref 均为 40 位 commit SHA;对应 SHA 使用 `git ls-remote` 从各 action 仓库解析。 +- `docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest .github/workflows/ci.yml .github/workflows/security-scan.yml .github/workflows/release.yml`:通过,无 workflow 语法/表达式问题输出。 +- CodeGraph 复核:系统更新、账号导出、客户端 API key、使用记录和前端更新流均按调用路径审计。 +- 横向扫描:前端未发现 `v-html` / `innerHTML` / `localStorage` / `sessionStorage` 命中;`any` 使用较多,主要集中在 API payload、图表、配额和错误处理。 + +## 已有强项 + +- 后端启用 `unsafe_code = "forbid"`,clippy 基线较严格,且当前全量 clippy 通过。 +- 代理调度路径对 quota exhausted、rate limit、expired、disabled、banned、Cloudflare challenge/path block、model unsupported 做了细分类和 fallback。 +- 流式响应路径在客户端取消、上游错误、正常完成时都释放账号槽位,并补齐 `response.failed` / `[DONE]`,降低悬挂连接和 OpenAI 协议不完整风险。 +- 使用记录默认不保存请求/响应体,只记录必要元数据;容量默认裁剪,避免无限增长。 +- SQLite schema 有较多约束和索引,测试覆盖了当前表结构、非法状态、负计数、运行时设置等。 +- 管理端静态资源带 CSP、`nosniff`、`x-frame-options`、`referrer-policy`,且 CSP 已限制 script/style 到 `self`,不再允许 `unsafe-inline`。 +- 管理端 session Cookie 现在包含 `Secure; HttpOnly; SameSite=Lax`。 +- 启动时会拒绝空密码、过短密码和常见弱默认管理员密码;Docker 示例配置不再提供可用弱密码。 +- CI 已覆盖 Rust fmt/clippy/test、前端 format/build、Compose config 和 runtime Docker 镜像构建;单独 security workflow 覆盖 cargo audit、pnpm audit 和 runtime 镜像 Trivy 扫描;当前 RustSec warning 已清零;workflow action 引用已 pin 到 commit SHA。 +- Docker 运行阶段已改为非 root 用户,Compose 默认仅绑定 `127.0.0.1:8080`,并包含 healthcheck、`no-new-privileges`、cap drop、`init`、停止宽限、pid/CPU/内存边界、`/tmp` tmpfs 和命名卷。 +- release workflow 会产出多平台二进制、Docker 镜像和 `checksums.txt`;release archives 会生成 GitHub artifact attestation,Docker 镜像会随 BuildKit 发布 SBOM/provenance attestations 并用 cosign keyless 签名;缺口转为运行时更新客户端验证这些证明。 +- 系统更新入口均要求 `require_admin_auth`;更新和回滚同时使用进程内 `SYSTEM_OPERATION_LOCK` 与文件锁,能防止常规并发更新。 +- 更新包处理有 500 MiB 下载上限、HTTPS/GitHub 下载域名白名单、tar 路径穿越拦截、跨文件系统移动 fallback、web/binary 备份与失败回滚。 +- 前端更新弹窗把 release notes 当文本渲染,没有用不可信 HTML;SSE/EventSource 使用 cookie session,并在弹窗关闭或组件卸载时断开。 + +## 关键证据索引 + +- `backend/src/config/types.rs`:`AdminConfig::default()` 的 `default_password` 为空;`validate_default_password()` 拒绝空值、短密码和常见弱口令。 +- `backend/src/config/loader.rs` / `backend/src/runtime/bootstrap.rs`:启动会优先读取 `CPR_CONFIG_FILE` 指向的配置文件,未设置时回退到当前目录 `config.yaml`;在没有管理员时用 `default_password` 创建默认管理员,创建前会先校验默认密码。 +- `backend/src/admin/auth/session.rs`:登录和登出 Cookie 字符串包含 `Secure; HttpOnly; SameSite=Lax`;登录成功、凭证失败、限流和内部错误会写入 `admin_auth` 审计记录,元数据只保留来源和是否提供用户名,不写入用户名、密码或 session id。 +- `backend/src/admin/auth/service.rs`:同一来源管理员登录失败达到 5 次后短期锁定,成功登录会清理该来源失败计数。 +- `backend/src/proxy/auth.rs`:`/v1` client API key 鉴权会区分 `missing_authorization`、`malformed_authorization`、`invalid_key_format`、`invalid_key` 和 `store_unavailable`,并写入结构化日志;对外仍返回统一 OpenAI `invalid_api_key`。 +- `backend/src/admin/keys/service.rs`:`AdminClientApiKey` 含完整 `key` 字段,SQLite `create` 直接写入完整 key,`list()` / `get()` 会把存储对象映射给管理端返回;运行时 cache 也以明文 key 为查找键。 +- `backend/src/admin/keys/routes.rs`:`ClientApiKeyData` 响应结构包含完整 `key`。 +- `backend/tests/admin/keys/store.rs`:测试显式断言数据库 `key` 字段和列表返回值等于创建出的完整 key,说明这是当前行为契约而不是偶然泄露。 +- `frontend/src/views/api-keys/composables/useApiKeyMutations.ts`:UI 会用 prefix 做遮罩展示,但后端响应、网络传输和前端内存里仍可能存在完整 key。 +- `backend/src/config/settings.rs`:`runtime_settings.admin_api_key` 保存完整 admin API key;校验用 constant-time compare,展示状态时才 masking。 +- `backend/src/admin/accounts/routes.rs` / `backend/src/admin/accounts/service/exporting.rs`:导出接口要求 `confirm=export_sensitive_accounts` 且 ids 非空;成功、缺少确认、空 ids、账号不存在和内部失败会写入 `admin_account_export` 审计记录;按 ids 导出时 `ExportedAccount::from` 仍会明文导出 access token 和 refresh token。 +- `backend/src/http/middleware/request_id.rs`:只接受长度不超过 128 且由字母、数字、`_`、`-`、`.` 组成的 `x-request-id`;`TrustedProxyConfig` 支持 IP/CIDR。未命中 trusted proxy 时会移除 `cf-connecting-ip`、`x-real-ip`、`x-forwarded-for` 并写入 TCP peer IP;命中 trusted proxy 时才从转发头提取客户端 IP,并统一折叠为规范化 `x-real-ip`。 +- `backend/src/web/headers.rs`:静态资源 CSP 当前为 `script-src 'self'` 和 `style-src 'self'`,已删除长期 `unsafe-inline` 依赖。 +- `backend/src/http/middleware/trace.rs`:日志 span 和请求日志保留 URI path 与 query 参数名、顺序和重复结构,但所有参数值均写成 ``;无 `=` 的裸 query 段也会整体脱敏。 +- `backend/src/infra/database.rs` / `backend/src/infra/migrations/0001_initial.sql`:SQLite 初始化启用 WAL、foreign keys、busy timeout,并通过 `schema_migrations` 执行编号迁移;业务表 DDL 不再使用 `create table if not exists` 作为隐式升级机制。 +- `backend/src/admin/system/routes.rs`:`perform_update` 有管理员鉴权、mutex、文件锁、GitHub API base 校验、下载域名校验、强制 HTTPS 下载和强制 `checksums.txt` 校验;`CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS=true` 不再放行非本地同源 HTTP 下载;checksum 校验仍来自同一 release;`restart` 现在请求运行时优雅关闭信号,不再直接 `std::process::exit(0)`。 +- `backend/src/http/router.rs`:`/healthz` 会执行 SQLite `select 1`,数据库不可用时返回 503。 +- `backend/src/admin/monitoring/usage_record_store.rs`:`capture_body=false` 时会移除请求/响应 body 字段,这是正确默认;`record_audit()` 用于安全审计事件,不受普通 usage logging 开关影响,但仍沿用容量裁剪和 body 捕获策略。 +- `deploy/Dockerfile`:runtime 阶段创建并使用 `cpr` 非 root 用户,内置 `/healthz` healthcheck。 +- `deploy/docker-compose.yml`:示例端口为 `127.0.0.1:8080:8080`,并配置 healthcheck、`no-new-privileges`、cap drop、非 root 用户和命名卷。 +- `deploy/Dockerfile.dockerignore`:Dockerfile 专用 ignore 已覆盖 build output、依赖缓存、本地配置、运行数据、工具状态,并保留 `dist/docker` 给 prebuilt runtime target 使用。 +- `.github/workflows/ci.yml` / `.github/workflows/security-scan.yml` / `.github/workflows/release.yml`:CI 已加入前端格式检查、Compose config、runtime 镜像构建和 Trivy 镜像扫描;workflow action 均已 pin 到 commit SHA;release workflow 的写权限已收敛到 publish job。 +- `.github/workflows/release.yml`:发布产物有 `checksums.txt`,release archives 会生成 GitHub artifact attestation,Docker 镜像发布启用 BuildKit SBOM/provenance,并通过 cosign keyless 签名;后续仍需让在线更新流程验证这些证明。 +- `frontend/src/composables/useSystemUpdate.ts`:EventSource 使用 `withCredentials: true`,错误处理和自动重连状态仍需测试。 +- `frontend/src/layout/components/SystemUpdateModal.vue`:release notes 和更新日志均使用文本插值/`pre`,未使用不可信 HTML 渲染。 + +## 加法清单 + +P0: + +- 已增加生产启动保护:管理员默认密码为空、过短或属于常见弱口令时拒绝启动。 +- 已增加管理 Cookie 安全属性:登录和登出 Cookie 均包含 `Secure; HttpOnly; SameSite=Lax`。 +- 已收紧默认真实 IP 边界并落地 trusted proxy 显式配置:`server.trusted_proxies` 支持 IP/CIDR;未配置或 peer 未命中时入口会移除 `x-forwarded-for`、`x-real-ip`、`cf-connecting-ip` 并使用 TCP peer IP。 +- 增加敏感数据保护:client API key 改为只存哈希,admin API key 改为只存哈希,账号 token/cookie 至少用本机或配置密钥加密落盘;该项本轮暂缓。 +- 已增加登录防爆破和审计:管理员登录失败计数、短期锁定、成功/失败/限流审计落库已落地。 +- 已增加账号导出基础保护:空 ids 不再默认导出全部账号,接口要求显式 `confirm=export_sensitive_accounts`,并写入成功/失败审计记录;按 ids 导出仍会返回完整 access token 和 refresh token,后续应继续增加频率限制和可选加密导出包。 +- 增加在线更新签名验证:发布侧已生成 release asset provenance、镜像 SBOM/provenance 和 cosign keyless signature;运行时更新客户端还需要验证 release 包证明,不能只依赖同一 release 里的 `checksums.txt`。 + +P1: + +- 已为 config loader 增加显式 `CPR_CONFIG_FILE` 支持,未设置时仍回退到运行目录 `config.yaml`。 +- 已为 `/v1` 鉴权增加可观测性:区分缺失 Authorization、Bearer 格式错误、client key 格式错误、无效 key、存储校验失败;对外仍统一 401。 +- 已为 request id 增加长度、字符集和生成策略校验;HTTP trace 日志会保留 query 参数名和结构,统一脱敏参数值,兼顾排查和泄露控制。 +- 已为反向代理部署增加 `server.trusted_proxies`,只有可信 peer 的转发 IP 头会被采信,采信后也只向业务层暴露规范化 `x-real-ip`。 +- 已引入 `schema_migrations` 迁移表和 `0001_initial.sql` 初始迁移,业务 schema 不再依赖 `create table if not exists`;后续 schema 变更必须显式增加编号迁移。 +- 已将 WebSocket pool shutdown 纳入 `TaskCoordinator`,不再只依赖弱引用后台维护任务自然退出。 +- 已增强 `/healthz` 和 Compose `healthcheck`;当前 healthz 会验证 SQLite 可用,后续可扩展到服务启动完成和前端 assets 存在。 +- 已补强 `deploy/Dockerfile.dockerignore`,排除 `.git`、`target`、`node_modules`、`frontend/dist`、本地数据、日志、配置密钥和临时文件。 +- 已完成 Docker 非 root 用户、`no-new-privileges`、capability drop、本机端口绑定、`init`、停止宽限、pid/CPU/内存边界和 `/tmp` tmpfs;`read_only` 根文件系统需和容器内自更新策略一起收口。 +- 系统更新测试矩阵已覆盖更新缓存、成功替换、跨文件系统替换、checksum 缺失失败、checksum 不匹配失败、非允许下载域名拒绝、HTTP 下载绕过开关失效、tar 路径穿越拒绝、替换失败回滚和 stale 文件锁清理;后续重点转向签名/provenance 验证。 +- 已增加 router 级测试,覆盖 `/api/admin/*` 不被 SPA fallback 吞掉、`/v1/*` 未知路径返回 JSON 404、正常静态资源和健康检查行为。 + +P2: + +- 在现有维护文档中明确 fork 版 `tungstenite` / `tokio-tungstenite` 的更新责任。 +- 已增加 Docker runtime 镜像 Trivy 扫描到 security workflow。 +- 已增加前端格式检查到 CI。 +- 已增加 `CPR_GITHUB_API_BASE` 信任边界:生产仅允许干净的 `https://api.github.com/repos`,本地 loopback `/repos` 只保留给测试。 +- 已将 release workflow 写权限收敛到 publish job;已完成 GitHub Actions commit SHA pinning、release asset provenance、Docker 镜像 SBOM/provenance 和 cosign keyless 签名;仍需在线更新客户端消费并验证这些证明。 + +## 减法清单 + +P0: + +- 已删除弱默认密码:代码里的 `admin/admin` 默认值和 `deploy/config.example.yaml` 中的 `123456` 示例不再作为可启动配置存在。 +- 删除明文 client API key 存储和列表返回完整 key 的长期能力。创建时只返回一次完整 key,后续只展示 prefix/masked key。 +- 删除“前端遮罩等于安全”的假设。当前遮罩只影响视觉展示,完整 key 仍通过 API 响应进入网络和前端运行时。 +- 删除 admin API key 明文存储。当前 constant-time 比较是好事,但明文落库仍是单点泄露。 +- 已删除“空 id 默认批量导出全部账号”。当前按 ids 导出仍包含完整 token,但服务端已要求显式确认并记录审计;后续继续做频率限制和可选加密导出包。 + +P1: + +- 删除对生产配置“靠 README 提醒用户修改”的依赖,改成代码强制校验。 +- 删除前端 build 中的 Rolldown annotation 噪音来源。可先确认是否由 `treeshake.annotations: false`、VueUse 版本或 Vite/Rolldown 组合导致。 +- 已删除未必要的默认 root 运行;Docker builder 里直接 `pnpm install` 和 `cargo build` 仍属合理构建方式。 +- 已删除无 ignore 的全仓库 Docker build context 假设,当前由 `deploy/Dockerfile.dockerignore` 控制构建上下文。 +- 已删除“raw schema 可以自然升级”的假设。当前改为编号迁移文件,业务表和索引 DDL 不再使用 `if not exists` 兜底;新增列、约束变更、索引回填和数据修复都必须落到新 migration。 +- 已删除 CSP 的长期 `style-src 'unsafe-inline'` 依赖;当前 Tailwind/Vue 构建产物通过外链 CSS 加载,静态头使用 `style-src 'self'`。 +- 已删除在线更新的硬退出假设。`restart` 当前 500ms 后请求运行时优雅关闭信号,让 axum graceful shutdown 和 `TaskCoordinator` 接管后续关闭。 +- 已删除 `CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS=true` 在生产可用的空间;除同源 loopback 测试下载外,release asset 和 checksum 下载均要求 HTTPS。 + +P2: + +- 已删除 `serde_yml` dev dependency,配置测试改用项目自身 `config` YAML 解析路径;同时删除 `validator` derive 宏依赖,清掉 `proc-macro-error2` RustSec warning 来源。 +- 已将直接 `sha2` 依赖从 `0.11` 收敛到现有 `0.10.9` 依赖线,继续评估 `rand 0.9/0.10`、`getrandom` 多版本等剩余重复依赖,减少审计面和编译面。 +- 删除长期无用的构建/发布脚本兼容分支,前提是 release 流程已稳定且有测试覆盖。 + +## 分层发现 + +### 认证与会话 + +- 管理端 session 使用 Argon2 哈希密码、UUID session id、HttpOnly Cookie,基础方向正确。 +- Cookie 已包含 `Secure; HttpOnly; SameSite=Lax`;如果经反向代理终止 TLS,应把会覆盖或清理转发头的代理 IP/CIDR 写入 `server.trusted_proxies`。 +- 登录接口已加入同来源失败计数和短期锁定,暴力尝试成本已提高;成功、凭证失败、限流和内部错误均会写入 `usage_records` 的 `admin_auth` 审计事件。 +- 当前请求入口不再默认信任 `cf-connecting-ip`、`x-real-ip` 和 `x-forwarded-for`;只有 TCP peer 命中 `server.trusted_proxies` 时才采信转发头,否则统一使用 TCP peer IP。 +- `x-request-id` 已限制长度和字符集,非法值会被替换为服务端生成的新 request id。 +- 管理端 API key 从 `x-api-key` 读取,使用 constant-time 比较,但 key 明文存库。 +- `/v1` client API key 校验基于内存表,热路径性能好;鉴权失败原因已结构化记录但对外保持统一 401。明文 key 存库并在管理端元数据中长期可见,安全模型仍偏弱。 +- 管理端 client key 列表结构包含完整 `key` 字段,这意味着只要拿到管理端会话就能长期复制所有代理 key;建议只保留创建时一次性明文返回。 + +### 配置与部署 + +- `AppConfig` 使用 `deny_unknown_fields`,能阻止拼错字段静默生效。 +- 配置加载已支持 `CPR_CONFIG_FILE` 显式路径;未设置时仍回退到当前目录 `config.yaml`,本地直接运行需要注意工作目录。 +- `server.trusted_proxies` 默认为空列表,支持单个 IP 和 CIDR;默认会剥离客户端传入的转发 IP 头,反代部署需要显式配置可信 peer。 +- Docker Compose 默认暴露 `127.0.0.1:8080:8080`,避免直接监听宿主所有网卡。 +- runtime 镜像基于 `debian:bookworm-slim`,安装 CA 和 curl healthcheck,默认以 `cpr` 非 root 用户运行。 +- `deploy/Dockerfile.dockerignore` 会收窄 Dockerfile 构建上下文;由于 Dockerfile 位于 `deploy/`,这里使用 Dockerfile 专用 ignore,而不是根 `.dockerignore`。 +- Compose 已设置 `healthcheck`、`security_opt: no-new-privileges:true`、capability drop、非 root 用户、命名卷、`init`、停止宽限、pid/CPU/内存边界和 `/tmp` tmpfs;`read_only` 根文件系统仍需结合自更新路径评估。 +- runtime 镜像显式创建 `/app/data`、`/app/logs`、`/app/web` 并赋予非 root 用户;默认命名卷会继承容器目录属主。 + +### 数据库与升级 + +- SQLite 连接初始化会创建父目录、启用 WAL、foreign keys 和 5 秒 busy timeout,这些默认值适合单进程自托管场景。 +- schema 有表约束、索引和运行时设置表,测试也覆盖了当前 schema 的多类约束。 +- 当前初始化先确保 `schema_migrations` 元数据表存在,空库按顺序应用 `0001_initial.sql` 并记录版本 1;已有业务表但没有迁移记录的库会拒绝启动,避免把重复执行 schema 当成隐式升级。 +- 未来新增列、收紧约束、回填索引或迁移敏感字段加密形态时,需要新增编号 migration 并提升当前版本,否则启动会因未知迁移版本失败。 + +### 在线更新与发布供应链 + +- `version`、`check_updates`、`update_event_stream`、`perform_update`、`update_status`、`rollback`、`restart` 均要求管理员认证,这是正确边界。 +- `perform_update` 使用进程内 mutex 和文件锁,`OperationLock` 有 30 分钟 stale lock 处理,能处理大部分重复点击和崩溃残留。 +- 下载 URL 默认限制 HTTPS 且只允许 GitHub / objects.githubusercontent.com;`CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS=true` 不再绕过 HTTP 拒绝,明显降低任意下载风险。 +- `extract_release_archive` 拒绝绝对路径、父目录和平台 prefix,避免 tar 包写出临时目录。 +- 当前已要求 release 必须提供 `checksums.txt`,缺失、不匹配或 checksum 下载失败时更新失败;生产 `CPR_GITHUB_API_BASE` 只能指向官方 GitHub API,避免 release 元数据来源被环境变量改到任意 endpoint。checksum 校验仍只是从同一个 release 下载文本并比对 SHA256,不能证明发布者身份;后续应验证 GitHub artifact attestation 或独立签名。 +- release workflow 会构建多平台二进制和 GHCR Docker 镜像,并发布 `checksums.txt`;release archives 已接入 GitHub artifact attestation,镜像已启用 SBOM/provenance attestations 和 cosign keyless signing。镜像漏洞扫描已在 security workflow 覆盖,不在 release 发布链路阻断。 +- GitHub Actions 依赖已从 tag ref 改为 commit SHA pinning,并保留原 tag 注释便于后续升级审计。 +- `CPR_GITHUB_API_BASE` 可由环境变量改写,但生产校验只允许不带 credentials/query/fragment 的 `https://api.github.com/repos`;本地 loopback `/repos` 仅用于测试。后续仍需要签名/provenance 兜底 GitHub release 资产本身的信任问题。 +- `replace_release_files` 先替换 web,再备份/替换 binary,并做失败回滚;实现有恢复意识,但不是严格事务,断电/进程 kill 仍可能留下 backup 与当前文件不一致的中间状态。 +- `restart` 使用 `tokio::spawn` 延迟 500ms 后请求运行时 shutdown signal,服务端会走 axum graceful shutdown 并在退出前关闭后台任务;仍依赖外部 supervisor 或 Docker restart policy 拉起新进程。 + +### 代理和并发 + +- 账号池用 `tokio::Mutex` 串行化内存状态,逻辑简单可靠;调度计算在锁内,持久化在锁外,避免长时间持锁。 +- 流式响应的取消路径释放账号槽位,降低 stale slot 依赖。 +- `stale_slot_ttl` 仍是必要兜底,应补充测试覆盖 panic/abort 或 spawn 任务异常退出后的容量恢复。 +- WebSocket pool 有 idle maintenance、ping、liveness timeout 和 shutdown 方法;当前 Services 会保留 pool 句柄,`TaskCoordinator` 关闭时会调用 pool shutdown,让后续 acquire 绕过池并关闭 idle 连接。 + +### 数据与隐私 + +- 请求/响应体默认不落使用记录,这是正确默认。 +- `accounts.access_token`、`accounts.refresh_token`、`account_cookies.value`、`client_api_keys.key`、`runtime_settings.admin_api_key` 目前都是明文。 +- SQLite 文件一旦泄露,可直接获得上游账号 token、cookie 和代理 key;这是当前最大实际安全风险。 +- 账号导出现在要求显式 ids 和固定确认词,不再允许空 ids 导出全部;成功和失败路径会写入不含 token/email 的 `admin_account_export` 审计记录。但选中账号仍会把 `StoredAccount` 的 `access_token` 和 `refresh_token` 通过 `ExposeSecret` 转成明文 JSON。迁移能力还需要频率限制和可选加密导出包。 +- 使用记录默认移除 `body`、`rawBody`、`requestBody`、`responseBody`、`upstreamBody`,这是好的隐私默认;如果开启 capture body,需要在 UI 和配置层明确提示风险,并考虑字段级脱敏。 + +### 前端管理面 + +- 路由守卫会在首次进入非登录页时检查 session,基本闭环。 +- Axios 设置 `withCredentials: true`,和 cookie session 匹配。 +- API 错误对象未导出类型,调用处多用 `any`,前端错误处理和重构安全性一般。 +- 登录页和管理页依赖后端 session 状态,不把 token 放 localStorage,这是正确方向。 +- 未发现前端使用 `v-html` 或 `innerHTML` 渲染后端文本;release notes、日志和使用记录展示默认走文本插值。 +- API key 页面会用 prefix 生成 masked key 展示,这是好的交互;但后端列表响应仍包含完整 key,前端遮罩不能降低网络、浏览器内存、日志或调试工具里的暴露风险。 +- 系统更新 EventSource 使用 `withCredentials: true`,后端也要求 session;需要注意 SSE 长连接断线后的错误状态和自动重连策略,避免 UI 显示“断开”但底层浏览器仍在重连。 +- `useDownload` 会把 JSON 导出为本地下载,这本身合理;结合账号导出时需要把“下载文件就是完整密钥包”的风险在操作层显式化。 + +### CI 与供应链 + +- CI 基线足够覆盖主开发路径。 +- `cargo audit` 当前无漏洞或 allowed warnings;后续重点转向重复依赖收敛和 fork 依赖维护策略。 +- `Cargo.toml` 使用 forked `tungstenite` / `tokio-tungstenite`,这可能是必要工程选择,但应有版本更新和安全响应责任说明。 +- 前端 `pnpm audit --prod --audit-level=high` 通过;构建警告应尽快消除,避免真实构建问题被噪音淹没。 +- security workflow 已跑 cargo audit、pnpm audit 和 runtime 镜像 Trivy 扫描;GitHub Action 依赖已 pin 到 commit SHA;release asset provenance 和镜像签名已在发布链路配置,客户端验证仍未接入。 +- release workflow 默认只有 `contents: read`,只有 package-assets job 额外拥有 `id-token: write`/`attestations: write` 用于 release asset attestation,publish job 拥有 `contents: write`、`packages: write`、`id-token: write` 和 `attestations: write` 用于发版、镜像发布、provenance 与 cosign keyless signing。 + +## 优先级路线图 + +本周: + +- 已强制拒绝弱默认管理员密码。 +- 已为生产态 Cookie 加 `Secure`。 +- 已把 Docker 示例密码改为空值并默认本地绑定。 +- 已补强 `deploy/Dockerfile.dockerignore`,先把构建上下文和本地敏感文件暴露面降下来。 +- 已落地 trusted proxy 显式配置,默认不再信任客户端传入的转发 IP 头。 +- 已删除管理端 CSP 的 `unsafe-inline`。 +- admin/client API key 改哈希存储设计方案定稿。 +- 已阻止空 id 默认导出全部账号,并补账号导出显式确认和审计日志。 +- 已把在线更新改为强制 checksum 存在,作为签名/provenance 上线前的临时底线。 + +下个迭代: + +- client API key 存储改造:创建时返回一次,数据库只存 hash/prefix/masked。 +- admin API key 存储改造:数据库只存 hash。 +- 已补登录审计日志,覆盖成功、凭证失败和限流路径;账号导出高危操作也已接入独立审计类别。 +- 默认真实 IP 提取、trusted proxy 显式配置和 request id 校验已完成;后续如要支持代理链更复杂的信任策略,再单独扩展。 +- 已落地数据库 `schema_migrations` 表和首个编号迁移,后续 schema 变更必须走显式 migration。 +- WebSocket pool 已纳入统一 shutdown。 +- 在线更新客户端加入签名/provenance 验证;发布侧证明和镜像签名已配置,更新路径测试矩阵已覆盖主要失败分支。 +- 已增加 Docker 资源限制和镜像扫描;`read_only` 根文件系统需要先解决容器内自更新写二进制和 web 资产的问题。 + +后续: + +- 账号 token/cookie 加密落盘。 +- 保持 `CPR_CONFIG_FILE` 部署文档和示例配置同步;未设置时仍回退到当前目录 `config.yaml`。 +- 依赖审计、镜像扫描、Action SHA pinning、发布侧 SBOM/provenance 和镜像签名已常态化;release 包签名/provenance 的运行时验证仍需落地。 +- 清理剩余重复依赖和前端 Rolldown warning。 diff --git a/docs/maintainability-audit-2026-07-03.md b/docs/maintainability-audit-2026-07-03.md new file mode 100644 index 000000000..f7f2b3ac6 --- /dev/null +++ b/docs/maintainability-audit-2026-07-03.md @@ -0,0 +1,145 @@ +# Codex Proxy RS 可维护性审计 + +审计日期:2026-07-03 + +## 定位 + +本文聚焦代码可维护性:可清理项、可抽象复用项、架构职责归类。安全加固相关结论见 `audit-2026-07-03.md`,本文不重复。 + +审计方法为签名采样 + 重复段抽查,未逐行通读,未跑构建。所有行号为审计当时快照,落地前需复核。 + +## 结论 + +后端约 39k 行 Rust,前端约 14k 行 Vue/TS。分层骨架清晰,是项目强项;主要问题集中在三类:跨文件逐字复制的私有 helper、dispatch/tasks 层的平行样板实现、以及少数超大文件把基础设施逻辑内联进 handler。 + +建议落地顺序:先做去重(helper 收敛,低风险)→ 再做后端复用抽象(dispatch 埋点样板、tasks 骨架泛型化)→ 最后做架构拆分(monitoring 重命名、system/responses 文件拆分,改动面大需单独 PR)。前端不做类型层改造:类型策略是尽量推导 + 简单声明,接口入参一律 `any`。当前已完成多批低风险减法:删除零引用时间 helper、合并 usage record trend 存储层重复方法,把三个 cleanup task 的重复调度 loop 收敛到共享骨架,抽出 transport 响应元数据 helper 供 HTTP SSE / WebSocket 共用,并把 dispatch usage 事件、RFC3339/耗时 helper、账号窗口重置规则、WebSocket event type 解析、monitoring metadata 提取、账号内 5xx retry、认证失败触发刷新、SSE failure 匹配、SSE 帧边界识别、非负展示数值转换、monitoring SQL select 列清单、usage record 账号邮箱映射和 system routes 错误映射样板收敛到共享实现。 + +本轮继续完成了此前剩余的高 ROI 后端项:admin 鉴权改为 `AdminAuth` extractor;`AdminUsageRecordService` 从 store 文件拆出;Responses live stream / SSE failure 分类拆到子模块,并把 live stream 首 token 计时复用协议 helper;system 自更新按 routes/updater/download/archive/release/state 拆分;accounts quota 展示视图抽到 `quota_view.rs`;账号与 client key 更新 payload 解析共用 helper;usage record 与 dashboard 时间桶费用计算下沉到 monitoring service helper,HTTP handler 只做汇总和展示格式化;monitoring 事件线与账号聚合线完成命名整理。 + +## 本轮落地验证 + +- `cargo check --manifest-path backend/Cargo.toml --all-targets --all-features --locked`:通过。 +- `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 --locked`:589 个测试全部通过。 +- `git diff --check`:通过。 +- `git diff --cached --check`:通过。 +- 前端测试未执行;本轮没有改前端,且本轮要求前端不用测试。 + +## 一、可清理 + +### 明确死代码 + +- 已删除 `backend/src/infra/time.rs` 中全库 0 引用的 `china_time`。 +- 已合并 `backend/src/admin/monitoring/usage_record_store.rs` 存储层的 `token_trend` / `latency_trend` 重复实现;service 层仍保留两个对外语义入口,但都调用同一个 `trend` 存储方法。 + +### 跨文件逐字复制的私有 helper + +收敛到公共工具模块即可去重: + +| helper | 重复位置 | +|---|---| +| `elapsed_millis_i64` | 已收敛到 `infra/time.rs`,dispatch、transport response meta 和 admin account lifecycle 共用 | +| `parse_rfc3339` / `parse_optional_rfc3339` | 已收敛到 `infra/time.rs`,accounts store/pool、session affinity、usage record store 和 account usage store 共用 | +| `metadata_string` / `metadata_i64` | 已收敛到 `admin/monitoring/usage_record_model.rs`;store 继续使用原始 i64,routes 使用非负 i64 展示 helper | +| `update_first_token_ms` | 已收敛为 `upstream/protocol/responses.rs` 的 `update_first_response_event_ms`;transport response meta 与 responses live stream 共用 | +| `is_rate_limit_header` | 已收敛到 `upstream/transport/response_meta.rs` | +| `websocket_event_type` | 已将 `upstream/protocol/websocket.rs` 版本设为 `pub(crate)`,transport 直接复用 | +| `trigger_refresh_after_auth_failure` | 已收敛到 `proxy/dispatch/auth_recovery.rs`,chat/responses/compact 共用 | +| `reasoning_effort_from_request` | 已收敛到 `proxy/dispatch/usage_events.rs`,chat/responses 共用 | +| turn_state / set_cookie / rate_limit header 抽取 | 已收敛到 `upstream/transport/response_meta.rs` | +| SSE 分隔符查找 | 已收敛到 `upstream/protocol/sse.rs` 的 `sse_frame_separator` / `sse_frame_end`,chat stream translator 与 live responses tuple transformer 共用 | +| `nonnegative_i64`(Option 版) vs `nonnegative_i64_to_u64` | 已新增 `infra/format.rs` 的 `optional_nonnegative_i64_to_u64`,usage record store 复用;账号 store 的同名本地 helper 已删除并复用 `infra::format::nonnegative_i64_to_u64` | + +### 样板密集区(宏或裁剪中间层) + +- `backend/src/admin/response.rs:1375-1473`:`AdminError` 的 18 个构造函数,每个都是 `Self::new(status, CODE, message)` 三行模板,可用声明宏压缩。 +- `backend/src/admin/monitoring/usage_record_store.rs`:`AdminUsageRecordService` 已拆到 `usage_record_service.rs`,store 文件回归 SQLite 存储和聚合 DTO;service 层仍保留对外语义入口与少量错误映射,后续若继续瘦身可让 store 直接返回 admin error。 +- 已将 `backend/src/admin/monitoring/account_usage_store.rs` 中 `LIST_USAGE_AFTER_CURSOR_SQL` 与 `LIST_USAGE_SQL` 重复的 23 列 select 收敛为 `LIST_USAGE_SELECT_SQL`,分页游标条件与普通列表共用同一个查询骨架。 +- 已将 `backend/src/admin/system/routes.rs` 中重复的 `map_err` 到 `AdminError` 收敛为本地 `internal_error_with` / `bad_request_with` / `bad_gateway_with` helper;带事件上报、动态 status/host 和纯 String 错误分支保留原写法。 + +### 前端 + +无孤儿文件,整体干净。可清理项是过重组件而非死代码: + +- `frontend/src/views/settings/index.vue`(537 行):混杂管理员 API Key 管理、运行参数表单、模型别名编辑器三块独立业务,应拆为三个子组件,index 只做加载与组装。 +- `frontend/src/components/base/BaseTable/index.vue`(441 行):承担列渲染 + 分页 + 横向滚动同步 + 固定列阴影四类职责,横向滚动阴影逻辑可提为 `useHorizontalStickyShadow`,分页 footer 可拆为 `BaseTablePagination.vue`。 + +## 二、可抽象复用 + +### 后端(按 ROI 排序) + +1. **dispatch 埋点样板(最高价值)**:已新增 `proxy/dispatch/usage_events.rs`,把 `record_response_event`、route/apiKind 元数据、identity 填充、reasoning effort 提取和 dispatch error 基础写入收敛为共享实现;chat/responses 仍保留各自错误分类和 live stream 专属记录逻辑。 +2. **runtime/tasks 骨架**:已把 `cookie_cleanup.rs`、`session_cleanup.rs`、`session_affinity_cleanup.rs` 的重复 select-loop 抽到 `runtime/tasks/cleanup.rs`,三个 public task 类型继续保留原来的 `start` / `cleanup_once_at` 入口。`token_refresh` / `quota_refresh` 的 start() 也是同一 select-loop 骨架;`model_refresh` 因首次拉取重试较特殊可不纳入。 +3. **transport header 抽取**:已将 turn_state / set_cookie / rate_limit / first_token / elapsed 收敛为 `upstream/transport/response_meta.rs` 与 `upstream/protocol/responses.rs` helper,HTTP SSE、WebSocket 与 dispatch live stream 共用。 +4. **admin 鉴权 extractor**:已新增 axum `FromRequestParts` extractor `AdminAuth`,管理端 routes 从手写 `require_admin_auth(&state, &headers).await?;` 改为 extractor 参数,去重且防漏写。 +5. **账号内 5xx retry 循环**:已新增 `retry_upstream_5xx`,`proxy/dispatch/upstream.rs` 的 responses / stream / compact 三个账号内 5xx retry 路径共用同一 retry/backoff 循环,并保留原日志字段差异。 +6. **SSE 失败分类器**:已新增 `proxy/dispatch/responses/sse_failure.rs`,`proxy/dispatch/responses.rs` 的 model unsupported、history recovery、invalid reasoning replay、auth、quota、stream failure metadata/status 等分类规则移入子模块,同时保留 auth/quota 原有 code/message 匹配语义差异。 +7. 窗口重置判定:已新增 `upstream/accounts/window.rs`,`store.rs` 与 `pool.rs` 共用同一个漂移阈值纯函数。 +8. 请求解析:已新增 `admin/update_payload.rs`,`parse_account_update` 与 `parse_client_api_key_update` 复用同一个 label/status 更新 payload 解析 helper;账号侧仍保留自己的 label trim 规则。 + +### 前端(按 ROI 排序) + +1. **不建立前端类型层(明确决策)**:不新增 `api/types.ts`、不为 `api/modules/*` 补 per-module response 类型。前端类型策略是尽量依赖类型推导 + 少量简单声明,接口入参一律用 `any`,避免大面积类型声明带来的维护负担。因此当前的 `params?: any` / 无返回类型 / `catch (error: any)` 属于有意选择,不作为待办;仅在推导能自然带出类型时受益,不主动补类型契约。 +2. **`useAsyncAction` / `useIdSet` / `useServerPagination`**:可从 `useAccountMutations.ts`(490 行)+ `useApiKeyMutations.ts`(219 行)消除大量重复: + - `useIdSet()`:`run(id, fn)` 自动管理 in-flight Set(重复 3+ 次:refreshingAccountIds / refreshingQuotaAccountIds / updatingStatusAccountIds / updatingStatusKeyIds / savingLabelKeyIds)。 + - `useAsyncAction(fn, { errorText })`:统一 loading guard + toast,替换 ~13 处 `try/catch/finally` 样板。 + - `useServerPagination`(远端分页 + debounce search)与 `useClientPagination`:`useAccountFilters` / `useApiKeyFilters` / `useUsageFilters` 三份并存的 `page/pageSize/searchQuery` + `pageSizes:[10,20,50,100]` 逻辑。 +3. `utils/format.ts`:数字/token/成本/百分比缩写在 `views/usage/constants.ts:140-179/272-276`、`AccountOverviewCards.vue:42`、`AccountOverviewCard.vue:93` 等多处各自实现(K/M 缩写重复),统一为 formatNumber / formatCompact / formatCost / formatPercent。 +4. `utils/chart.ts`:`RequestTrendCard.vue`、`UsageInsightsGrid.vue`、`UsageRecordDetailModal.vue`、`MetricCard.vue` 各自构造 `EChartsOption`,`formatTooltip` 手写两份。提取共享 grid/axis/splitLine 默认样式 + tooltip helper。 +5. `constants/statusTone.ts`:`active/disabled/expired/banned -> success/warning/danger` 映射在 `AccountStatusBadge.vue`、`UsageStatusCodeBadge.vue`、`useDashboard.ts:366`、`useAccountConnectionTest.ts` 四处重复。 +6. `useModelField.ts`:`AccountEditModal.vue:50-57` 与 `ApiKeyCreateModal.vue:25-32` 逐字复制基于 `defineModel` 的 `formField(key)` 工厂。 +7. `BaseFormModal.vue`:多个 modal 共享 "BaseModal + 表单 + ghost 取消 / primary 提交 footer" 骨架,可封装 footer 双按钮 + `saving`/`close-disabled` 透传。 + +## 三、架构归类 + +### 分层骨架(清晰,项目强项) + +``` +infra/ 平台原语(json 分页游标 / identity argon2+apikey / time / format / database / logging / paths)——无业务 +http/ 传输层(middleware: request_id + trace,顶层 router merge proxy/admin/assets) +runtime/ 启动编排(bootstrap / services 依赖装配 / state / shutdown / tasks + coordinator 单一注册点) +config/ loader 读文件 / types 结构+Default+validate / settings 运行时可变写回 +upstream/ 上游资源与传输:accounts(有状态池) · protocol(纯类型编解码,可测性好) · transport(实际 I/O) +proxy/ 客户端协议与调度:openai(OpenAI 协议边界) · dispatch(编排核心) · auth/router 入口 +admin/ 管理端 API(auth / accounts / keys / monitoring / settings / system / response / router) +web/ SPA 静态资源托管 +frontend/ api(唯一出口 request.ts) -> composables(全局 vs 就近) -> views / stores(仅 auth+ui,克制) +``` + +前端分层克制得当:account/api-key/usage 列表数据刻意留在 view composable 内,未塞进 Pinia,是正确取舍。 + +### 职责混杂 / 需整改(按优先级) + +1. **monitoring 命名混乱已整理**:事件线与账号聚合线已拆成明确命名: + + | 文件 | 职责 | 数据线 | + |---|---|---| + | `usage_record_model.rs` | `UsageRecord` 模型 + `ResponseUsageRecord` 采集入参 | 逐条事件 | + | `usage_record_routes.rs` | HTTP handler + 展示 DTO | 逐条事件 | + | `usage_record_store.rs` | SQLite 事件存储 | 逐条事件 | + | `usage_record_service.rs` | 事件 service + 费用明细视图 helper | 逐条事件 | + | `account_usage_store.rs` | 账号累计用量 + 时间桶存储 | 账号聚合用量 | + | `account_usage_service.rs` | 账号聚合用量 service + 时间桶费用视图 | 账号聚合用量 | + + 原 `usage_record.rs` / `usage_records.rs` 仅差一个 `s` 的问题已消除;聚合线也从 `usage_store.rs` / `service.rs` 改为 `account_usage_store.rs` / `account_usage_service.rs`。 + +2. **`admin/system/routes.rs` 职责过载已拆**:原 1600 行路由文件已拆为 `routes.rs` 入口、`updater.rs` 编排、`updater/download.rs` 下载与 checksum/URL 校验、`updater/archive.rs` 解压替换回滚、`updater/release.rs` Release 查询/缓存/asset 选择、`state.rs` 状态文件与锁。 + +3. **`proxy/dispatch/responses.rs` 已做第一轮拆分**:实时 SSE body 转发、tuple transformer、断流补尾移入 `responses/live_stream.rs`;SSE failure 分类、stream failure metadata/status 移入 `responses/sse_failure.rs`。主文件仍承担调度决策和埋点编排,后续如继续拆,可再把 event recording helpers 单独模块化。 + +4. **`admin/accounts/routes.rs` quota 展示逻辑已抽出**:`quota_window_*` 分组/排序/去重/标签逻辑移入 `admin/accounts/quota_view.rs`,routes 只调用 `quota_data` 和高用量判断。 + +5. **分层泄漏**: + - `admin/monitoring/usage_record_routes.rs` 原 `account_email_map` 直接拿连接池拼 `QueryBuilder` 查 `accounts` 表;已下沉到 `AdminUsageRecordService` / `SqliteUsageRecordStore`,usage record routes 与 dashboard 共用 service 方法。 + - `upstream/accounts/store.rs`(1984 行,仓储层)与 `pool.rs`(1412 行,调度层)原先对"窗口重置/配额刷新"各有一套判定;本轮已将窗口重置漂移规则集中到 `upstream/accounts/window.rs`,配额刷新分支仍按各自职责保留。 + - transport 层原先自带一份平行的 WebSocket event type 解析;本轮已改为复用 protocol 层纯解析。后续仍可继续检查其他 transport/protocol 边界重复。 + +6. **`admin/monitoring/billing.rs` 计费位置已下沉一层**:`usage_record_routes.rs` 不再直接调用 `billing::calculate_cost_breakdown`,改由 `usage_record_service.rs` 的 `usage_record_cost_details` 返回费用明细;`dashboard.rs` 不再直接调用 billing,时间桶费用在 `AdminUsageService` 转换 `AdminUsageTimeBucketRecord` 时写入 `cost_usd`。handler 只做汇总和展示格式化。 + +7. **前端 `views/settings/index.vue`**:唯一未遵循 "index 组装 + 子组件" 约定的 view,三块业务全塞一个文件(见第一节)。`useDashboard.ts`(397 行)数据获取与视图模型构建混在一起,纯变换函数可抽到 `views/dashboard/transform.ts`。(前端 API 层普遍用 `any` 是刻意的类型策略,不视为需整改的分层问题。) + +### 谨慎项(不建议粗暴重构) + +- `upstream/protocol/websocket.rs`(1212 行)约 40 个 `*_invalid_required_fields` 校验函数:高度模式化但各校验不同字段,属 schema 校验的合理展开,测试覆盖概率高。可考虑字段规格表数据驱动,但收益/风险比一般,低优先级。 +- `AdminAccountService` + `AdminAccountServiceParts`(`accounts/service/mod.rs:39-90`):Parts 结构规避 clippy `too_many_arguments`,属可接受模式。 diff --git a/docs/protocol-fidelity-audit-2026-07-03.md b/docs/protocol-fidelity-audit-2026-07-03.md new file mode 100644 index 000000000..508aae201 --- /dev/null +++ b/docs/protocol-fidelity-audit-2026-07-03.md @@ -0,0 +1,133 @@ +# Codex Proxy RS 协议保真度审计 + +审计日期:2026-07-03 + +## 定位 + +本文档聚焦 **OpenAI 兼容协议保真度**:`codex-proxy-rs` 把 Codex/ChatGPT 上游翻译成 OpenAI 兼容格式,翻译的完整度决定客户端 SDK 会不会异常。与安全向 `audit-2026-07-03.md`、可维护性向 `maintainability-audit-2026-07-03.md` 分开维护。 + +覆盖四个面:Chat Completions、Responses API、Models 与错误体、工具调用专项。 + +## 方法与基准 + +审计为**纯静态对拍**,未做真实上游抓包。基准有两类,可信度不同: + +1. **desktop bundle 基准(强)**:从当日最新官方 `Codex.dmg`(`persistent.oaistatic.com/codex-app-prod/Codex.dmg`,构建时间 2026-07-03)解包出的 Electron `app.asar`,提取 `.vite/build/{main,src,worker}.js` 中的协议字符串。`worker.js` 是核心流处理器。 +2. **OpenAI 官方契约基准(中)**:我知识内的 OpenAI API 契约,用于错误体、Models 端点等 bundle 未覆盖处。 + +### 基准的关键局限(务必先读) + +**官方桌面端的上游请求体不由这些 JS bundle 构造**。JS 层只做两件事: + +- SSE / 流解析(`worker.js` 的 `nh` 事件白名单) +- OpenTelemetry 埋点(`gen_ai.request.*` / `gen_ai.usage.*` 是遥测键,不是发给上游的请求字段) + +真正的 Responses 请求体由桌面端内嵌 spawn 的 Rust `codex` CLI 组装,不在解包产物内。因此: + +- **SSE 事件面**:bundle 基准硬,结论可信。 +- **请求字段面**(`store` / `include` / `max_output_tokens` / `service_tier` / 工具 schema 等):官方真实取值**基准不确定**,只能核对"结构是否合理",无法核对"字段级取值是否与官方一致"。要彻底核对这几项需对官方 `codex` CLI 做真实上游抓包。 + +行号为审计时快照,复核时以实际代码为准。 + +## 确认的上游 SSE 事件全集 + +`worker.js` 的 `nh` 数组(事件白名单,官方原样透传给下游,仅用于遥测采集): + +``` +response.created response.in_progress response.failed +response.completed response.incomplete response.queued +response.output_text.delta response.output_item.added +response.function_call_arguments.delta +response.function_call_arguments.done +response.output_item.done +``` + +附加已知事件(reasoning / content 系列):`response.reasoning_summary_text.delta/done`、`response.reasoning_text.delta`、`response.reasoning_summary_part.added`、`response.output_text.done`、`response.content_part.added`、`response.custom_tool_call_input.delta`。 + +端点分类:`responses` / `chat.completions` / `embeddings`。Provider 枚举:`Codex` / `ChatGPT`。 + +## 分级结论 + +本项目有两条语义完全不同的路径,保真度差异大: + +- **`/v1/responses` 直通**(`TupleSseEventTransformer`):无 schema 时逐字节透传,接近官方透传语义,保真度高。 +- **`/v1/chat/completions` 转换**(`ChatCompletionStreamTranslator`):主动把 Codex Responses SSE 转成 `chat.completion.chunk`,`match` 未命中的事件 `_ => {}` 丢弃。**偏差集中在此路径。** + +官方 11 个核心事件中本项目识别 10 个。 + +### 🔴 严重(已读代码核实) + +**1. `response.incomplete` 全路径未处理** — 可信度最高(基准硬 + 已核实) + +- 基准:`response.incomplete` 在官方 `nh` 事件全集内,是合法终止事件(命中 `max_output_tokens`、内容策略等),官方按 `response.status` 采集 finishReasons 并原样透传。 +- 项目自身 `upstream/protocol/websocket.rs:235,725` 已在 **WebSocket 路径**完整识别该事件(能取 `incomplete_details/reason`),但 **HTTP SSE 路径漏了它**: + - `proxy/openai/chat.rs` push_event 的 match 无 `response.incomplete` 分支 → 落入 `_ => {}` 丢弃。唯一发 `finish_reason` 和 `[DONE]` 的 `push_completed` 只在 `response.completed` 触发。 + - `proxy/dispatch/responses.rs:2389` `sse_body_has_terminal_event` 只认 `completed|failed|error`。 +- 触发场景:任意"未完成但正常终止"的响应(尤其 `max_output_tokens` 截断)。 +- 后果: + - chat 客户端:流无终止 chunk、无 `[DONE]`,流悬挂到超时。 + - responses 客户端:在合法 `response.incomplete` 后被追加一个伪造的 `response.failed`(stream_disconnected),出现双终止事件;后台把成功的截断响应误记为 502,污染用量/账号健康统计。 +- 修复方向:SSE 两条路径把 `response.incomplete` 纳入终止事件集,复用 WebSocket 路径已有的 `websocket_incomplete_response_reason` 逻辑。 + +**2. Chat Completions 工具定义未从嵌套转扁平** — 方向可信,需实测确认 + +- 基准:Codex 上游是 Responses API,函数工具应为扁平 `{type:"function", name, description, parameters}`;Chat 输入是嵌套 `{type:"function", function:{...}}`。二者不可互换。**注意**:工具 schema 的官方真实取值属"基准不确定"(不在 bundle 内),此条依据"Codex = Responses API"推断,方向可信但建议真实抓包确认后再改。 +- 项目:`proxy/openai/chat.rs:676` `codex_tools()` 的 `tools` 分支 `return Some(tools)` 原样透传嵌套结构;legacy `functions` 还额外包一层 `{type:"function","function":function}`。 +- 后果:若上游确实要扁平结构,`name`/`parameters` 在顶层缺失 → 上游报 400 或忽略工具 → `/v1/chat/completions` 工具调用整体失效。其余流式/非流式解析逻辑都写得完整,唯独请求方向的工具定义可能从一开始就错。 + +### 🟡 中等 + +错误体: + +- **quota 耗尽 → HTTP 402**(`dispatch/responses.rs` `http_status_code`、`chat.rs` 同):OpenAI 用 429 + `insufficient_quota`。**判为设计权衡而非 bug** — 402 Payment Required 语义上对应配额耗尽。但若目标是兼容 OpenAI SDK 的限流退避/重试,SDK 的 status→异常映射无 402 分支,会落到通用 `APIStatusError` 而非 `RateLimitError`,退避逻辑失效。取决于产品意图,此处仅标注权衡点。 +- 聚合错误 type 恒为 `server_error`/`upstream_error`(`errors.rs`):quota/rate 耗尽经账户池聚合路径时 type 未按语义映射为 `insufficient_quota`/`rate_limit_error`,读 `error.type` 判限流的客户端无法识别。 +- 错误体缺 `param` 字段(`errors.rs:47-63`);多数 SDK 容忍。 +- Responses 非流式错误多包一层顶层 `"type":"error"`(`errors.rs:114-141`),偏离 `{"error":{...}}`。 +- 401(expired/disabled) 聚合路径 status=401 但 type=`server_error`,body 与状态码不自洽。 +- chat 流式中途失败帧 type=`stream_error` 且无 `code`(`chat.rs:1001`),OpenAI SDK 不识别,等价于坏流。 + +SSE / chat 转换: + +- chat `finish_reason` 恒为 `stop`/`tool_calls`(`chat.rs:398`),**永不产生 `length`/`content_filter`**;叠加 incomplete 未处理,截断响应既无 `length` 也无终止帧。 +- chat 转换丢弃 `response.reasoning_text.delta`(只映射 `reasoning_summary_text.delta`)与 `custom_tool_call_input.delta`;若模型只发原始 reasoning_text 或 custom tool 增量,在 chat 路径被静默丢弃。两者在 `/v1/responses` 直通路径均正常透传。 +- chat 路径 `tool_choice` 命名工具格式未转换(`chat.rs:499`):Responses 期望 `{type:function, name}`,透传 Chat 的 `{type:function, function:{name}}` 会被上游忽略。 +- legacy `function_call` 回传 call_id 用 `fc_{name}` 伪造(`chat.rs:595`),与真实 call_id 对不上,多轮工具对话无法关联结果。 +- 流式 `output_item.done` 无 function_call 兜底(`chat.rs:337` 仅处理 `image_generation_call`);非流式有双来源兜底。边缘风险。 + +Models 端点(结构层面与 OpenAI 契约高度一致): + +- 列表纯动态且首拉前为空(`upstream/models/mod.rs:292`):进程刚启动、未成功拉取任一 plan 模型快照时,`/v1/models` 返回空 data,依赖枚举模型的客户端受影响。 +- `/v1/models/{id}` 不解析别名(`upstream/models/mod.rs:381`):用聊天可用的别名查详情返回 404。 +- `created` 全模型硬编码同一常量(`models.rs:15`),契约合法但无真实语义。 + +### 请求字段面(基准不确定,仅记录待抓包核对) + +以下项结构合理,但官方真实取值不在 bundle 内,无法静态核对,留待真实抓包: + +- `store` 默认 false 且始终序列化(`responses.rs:624`)。 +- `include` 自动注入 `reasoning.encrypted_content`(`responses.rs:585`)— 符合公开 Codex 惯例但 bundle 无字面量命中。 +- 不构造 `max_output_tokens`:客户端传入会被静默丢弃。 +- `service_tier` 的 `fast`→`priority` 归一化(`responses.rs:597`)为本项目自定义。 +- 两条 reasoning 构造路径(`proxy/openai/responses.rs:358` vs `responses.rs:544`)在 effort 缺失时行为不完全一致。 +- `function_call_output` 无专门净化分支,靠 `_ => Some(object)` 原样透传。 + +## 优先级建议 + +1. **修 `response.incomplete`(🔴,基准硬)** — 两条 SSE 路径纳入终止事件集,复用 WebSocket 已有逻辑。收益最大、证据最硬、改动可控。 +2. **确认 chat 工具定义转扁平(🔴,需实测)** — 先对官方 `codex` CLI 抓一次带 tools 的请求确认扁平结构,再改 `codex_tools()`。 +3. **决策 quota→402 vs 429(🟡,产品权衡)** — 明确是要 OpenAI SDK 兼容还是语义化 HTTP 码。 +4. 补 chat `finish_reason=length`、聚合错误 type 语义映射等中等项。 + +## 复现方法(解包基准) + +``` +# 1. 下载当日 DMG +curl -fL https://persistent.oaistatic.com/codex-app-prod/Codex.dmg -o Codex.dmg + +# 2. 7z 解包(需 7-Zip 25.x,旧 p7zip 无法解 APFS DMG) +7z x Codex.dmg -o dmg-extract # 注意路径含空格 "Codex Installer/" + +# 3. 提取 app.asar(纯 node,@electron/asar 格式) +# 协议逻辑在 dmg-extract/*/Codex.app/Contents/Resources/app.asar +# 解出后核心文件:.vite/build/{main,src,worker}.js(webview/assets 全是 UI/i18n,无关) +``` diff --git a/frontend/package.json b/frontend/package.json index 80d9158c8..36f8e5d54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", - "@lucide/vue": "^1.21.0", + "@lucide/vue": "^1.23.0", "@vueuse/core": "^14.3.0", "axios": "^1.18.1", "dayjs": "^1.11.21", @@ -26,15 +26,15 @@ "vue-router": "^5.1.0" }, "devDependencies": { - "@tailwindcss/vite": "^4.3.1", - "@types/node": "^26.0.1", + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^26.1.0", "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", - "prettier": "^3.8.4", - "tailwindcss": "^4.3.1", + "prettier": "^3.9.4", + "tailwindcss": "^4.3.2", "typescript": "^6.0.3", - "vite": "^8.1.0", - "vue-tsc": "^3.3.5" + "vite": "^8.1.3", + "vue-tsc": "^3.3.6" }, "prettier": { "semi": false, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 7cc68388c..7b9d5dc16 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^5.2.8 version: 5.2.8 '@lucide/vue': - specifier: ^1.21.0 - version: 1.21.0(vue@3.5.39(typescript@6.0.3)) + specifier: ^1.23.0 + version: 1.23.0(vue@3.5.39(typescript@6.0.3)) '@vueuse/core': specifier: ^14.3.0 version: 14.3.0(vue@3.5.39(typescript@6.0.3)) @@ -46,35 +46,35 @@ importers: version: 3.5.39(typescript@6.0.3) vue-router: specifier: ^5.1.0 - version: 5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) + version: 5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(rolldown@1.1.4)(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)) devDependencies: '@tailwindcss/vite': - specifier: ^4.3.1 - version: 4.3.1(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) + specifier: ^4.3.2 + version: 4.3.2(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': - specifier: ^26.0.1 - version: 26.0.1 + specifier: ^26.1.0 + version: 26.1.0 '@vitejs/plugin-vue': specifier: ^6.0.7 - version: 6.0.7(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) + version: 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)) '@vue/tsconfig': specifier: ^0.9.1 version: 0.9.1(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)) prettier: - specifier: ^3.8.4 - version: 3.8.4 + specifier: ^3.9.4 + version: 3.9.4 tailwindcss: - specifier: ^4.3.1 - version: 4.3.1 + specifier: ^4.3.2 + version: 4.3.2 typescript: specifier: ^6.0.3 version: 6.0.3 vite: - specifier: ^8.1.0 - version: 8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0) + specifier: ^8.1.3 + version: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0) vue-tsc: - specifier: ^3.3.5 - version: 3.3.5(typescript@6.0.3) + specifier: ^3.3.6 + version: 3.3.6(typescript@6.0.3) packages: @@ -147,8 +147,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lucide/vue@1.21.0': - resolution: {integrity: sha512-eoFn3tppjKAc12ZqdnRSMFdtwQ1ZMRQFb6SV1Eub6Y8kU28ccnqKeSFdnur9hMg8gIbosU2Y3WFJr/J/xS/IlQ==} + '@lucide/vue@1.23.0': + resolution: {integrity: sha512-9SIYeY5K+R1iv8F8JUKMGSL7Pck/86BJ8djtZz/lnYsoHtKFUj1H2z6+PvKnC/8blZ8tqTDeDXAoTKobuX80hg==} peerDependencies: vue: '>=3.0.1' @@ -158,100 +158,100 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@oxc-project/types@0.137.0': - resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} - '@rolldown/binding-android-arm64@1.1.3': - resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + '@rolldown/binding-android-arm64@1.1.4': + resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.3': - resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + '@rolldown/binding-darwin-arm64@1.1.4': + resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.3': - resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + '@rolldown/binding-darwin-x64@1.1.4': + resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.3': - resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + '@rolldown/binding-freebsd-x64@1.1.4': + resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': - resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.3': - resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + '@rolldown/binding-linux-arm64-gnu@1.1.4': + resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.3': - resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + '@rolldown/binding-linux-arm64-musl@1.1.4': + resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.3': - resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.3': - resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + '@rolldown/binding-linux-s390x-gnu@1.1.4': + resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.3': - resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + '@rolldown/binding-linux-x64-gnu@1.1.4': + resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.3': - resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + '@rolldown/binding-linux-x64-musl@1.1.4': + resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.3': - resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + '@rolldown/binding-openharmony-arm64@1.1.4': + resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.3': - resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + '@rolldown/binding-wasm32-wasi@1.1.4': + resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.1.3': - resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + '@rolldown/binding-win32-arm64-msvc@1.1.4': + resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.3': - resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + '@rolldown/binding-win32-x64-msvc@1.1.4': + resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -259,69 +259,69 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@tailwindcss/node@4.3.1': - resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} - '@tailwindcss/oxide-android-arm64@4.3.1': - resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.3.1': - resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.3.1': - resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.3.1': - resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': - resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': - resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.3.1': - resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.3.1': - resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.3.1': - resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.3.1': - resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -332,24 +332,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': - resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.3.1': - resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.3.1': - resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.3.1': - resolution: {integrity: sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==} + '@tailwindcss/vite@4.3.2': + resolution: {integrity: sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -359,8 +359,8 @@ packages: '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.1.0': + resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -405,23 +405,23 @@ packages: '@vue/devtools-api@7.7.10': resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} - '@vue/devtools-api@8.1.4': - resolution: {integrity: sha512-zphdXRe0VxWfUWH2KLcNV4xWUMxjxASyxCjFS/wRHVYfJCMBulq1ce3RHGkYRxghoVBqwCzOw1TWIIfvIWCY1w==} + '@vue/devtools-api@8.1.5': + resolution: {integrity: sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==} '@vue/devtools-kit@7.7.10': resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} - '@vue/devtools-kit@8.1.4': - resolution: {integrity: sha512-+GLBwY63hZ46sqTlgXgvd8IcZTpWe0PZzbJgs63ii/8uTYVwg1q9bdIR9xx8ReKcrdWS01RGCbC971jYPvjRCA==} + '@vue/devtools-kit@8.1.5': + resolution: {integrity: sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==} '@vue/devtools-shared@7.7.10': resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} - '@vue/devtools-shared@8.1.4': - resolution: {integrity: sha512-Earc/zrg0w0Md4KdrvgRR1vC5F17Zn+VzqTNI7PXYXz0fPJDVWENe7fv6NHi6Ja9Sq6Ce6SYUdc2FlXU0OMkjQ==} + '@vue/devtools-shared@8.1.5': + resolution: {integrity: sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==} - '@vue/language-core@3.3.5': - resolution: {integrity: sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==} + '@vue/language-core@3.3.6': + resolution: {integrity: sha512-LgBMZAy2sR3cQWknpyaxnI6yBkqDfLBPkbdhwRhQCvzfNJRQXPilgQIrdI/v4ytJ0sAq9bWhaPsjqBqneomJ3Q==} '@vue/reactivity@3.5.39': resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} @@ -828,12 +828,12 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} - prettier@3.8.4: - resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} engines: {node: '>=14'} hasBin: true @@ -851,8 +851,8 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rolldown@1.1.3: - resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + rolldown@1.1.4: + resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -871,8 +871,8 @@ packages: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} - tailwindcss@4.3.1: - resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} @@ -899,12 +899,12 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - unplugin-utils@0.3.1: - resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} engines: {node: '>=20.19.0'} - unplugin@3.2.0: - resolution: {integrity: sha512-6nGlT7EHsS+tTcTdAkYFqXIUwDrMJyJvHFNYGSr4x2/2ySIcV4f5e1RAJUeDyfOJPR8TF0auE8l+82PLhKjqsA==} + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@farmfe/core': '*' @@ -936,8 +936,8 @@ packages: webpack: optional: true - vite@8.1.0: - resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} + vite@8.1.3: + resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1000,8 +1000,8 @@ packages: vite: optional: true - vue-tsc@3.3.5: - resolution: {integrity: sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==} + vue-tsc@3.3.6: + resolution: {integrity: sha512-ERXGgbKSBGFUkavrJ1Iwj0ZVxKqB/5UOx65IXy7fPf2UsoI21n3ssQLwdvx8xyUGgJe9PvSZTM/FYzIdwUDPFA==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -1101,7 +1101,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lucide/vue@1.21.0(vue@3.5.39(typescript@6.0.3))': + '@lucide/vue@1.23.0(vue@3.5.39(typescript@6.0.3))': dependencies: vue: 3.5.39(typescript@6.0.3) @@ -1112,60 +1112,60 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@oxc-project/types@0.137.0': {} + '@oxc-project/types@0.138.0': {} - '@rolldown/binding-android-arm64@1.1.3': + '@rolldown/binding-android-arm64@1.1.4': optional: true - '@rolldown/binding-darwin-arm64@1.1.3': + '@rolldown/binding-darwin-arm64@1.1.4': optional: true - '@rolldown/binding-darwin-x64@1.1.3': + '@rolldown/binding-darwin-x64@1.1.4': optional: true - '@rolldown/binding-freebsd-x64@1.1.3': + '@rolldown/binding-freebsd-x64@1.1.4': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.3': + '@rolldown/binding-linux-arm64-gnu@1.1.4': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.3': + '@rolldown/binding-linux-arm64-musl@1.1.4': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.3': + '@rolldown/binding-linux-ppc64-gnu@1.1.4': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.3': + '@rolldown/binding-linux-s390x-gnu@1.1.4': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.3': + '@rolldown/binding-linux-x64-gnu@1.1.4': optional: true - '@rolldown/binding-linux-x64-musl@1.1.3': + '@rolldown/binding-linux-x64-musl@1.1.4': optional: true - '@rolldown/binding-openharmony-arm64@1.1.3': + '@rolldown/binding-openharmony-arm64@1.1.4': optional: true - '@rolldown/binding-wasm32-wasi@1.1.3': + '@rolldown/binding-wasm32-wasi@1.1.4': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.3': + '@rolldown/binding-win32-arm64-msvc@1.1.4': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.3': + '@rolldown/binding-win32-x64-msvc@1.1.4': optional: true '@rolldown/pluginutils@1.0.1': {} - '@tailwindcss/node@4.3.1': + '@tailwindcss/node@4.3.2': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.21.6 @@ -1173,65 +1173,65 @@ snapshots: lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 - '@tailwindcss/oxide-android-arm64@4.3.1': + '@tailwindcss/oxide-android-arm64@4.3.2': optional: true - '@tailwindcss/oxide-darwin-arm64@4.3.1': + '@tailwindcss/oxide-darwin-arm64@4.3.2': optional: true - '@tailwindcss/oxide-darwin-x64@4.3.1': + '@tailwindcss/oxide-darwin-x64@4.3.2': optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.1': + '@tailwindcss/oxide-freebsd-x64@4.3.2': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.1': + '@tailwindcss/oxide-linux-x64-musl@4.3.2': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.3.1': + '@tailwindcss/oxide-wasm32-wasi@4.3.2': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': optional: true - '@tailwindcss/oxide@4.3.1': + '@tailwindcss/oxide@4.3.2': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.1 - '@tailwindcss/oxide-darwin-arm64': 4.3.1 - '@tailwindcss/oxide-darwin-x64': 4.3.1 - '@tailwindcss/oxide-freebsd-x64': 4.3.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 - '@tailwindcss/oxide-linux-x64-musl': 4.3.1 - '@tailwindcss/oxide-wasm32-wasi': 4.3.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 - '@tailwindcss/vite@4.3.1(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.2(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@tailwindcss/node': 4.3.1 - '@tailwindcss/oxide': 4.3.1 - tailwindcss: 4.3.1 - vite: 8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0) + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 + tailwindcss: 4.3.2 + vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0) '@tybys/wasm-util@0.10.3': dependencies: @@ -1240,16 +1240,16 @@ snapshots: '@types/jsesc@2.5.1': {} - '@types/node@26.0.1': + '@types/node@26.1.0': dependencies: undici-types: 8.3.0 '@types/web-bluetooth@0.0.21': {} - '@vitejs/plugin-vue@6.0.7(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))': + '@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))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0) + 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) '@volar/language-core@2.4.28': @@ -1270,7 +1270,7 @@ snapshots: ast-kit: 2.2.0 local-pkg: 1.2.1 magic-string-ast: 1.0.3 - unplugin-utils: 0.3.1 + unplugin-utils: 0.3.2 optionalDependencies: vue: 3.5.39(typescript@6.0.3) @@ -1296,7 +1296,7 @@ snapshots: '@vue/shared': 3.5.39 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.15 + postcss: 8.5.16 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.39': @@ -1308,9 +1308,9 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.10 - '@vue/devtools-api@8.1.4': + '@vue/devtools-api@8.1.5': dependencies: - '@vue/devtools-kit': 8.1.4 + '@vue/devtools-kit': 8.1.5 '@vue/devtools-kit@7.7.10': dependencies: @@ -1322,9 +1322,9 @@ snapshots: speakingurl: 14.0.1 superjson: 2.2.6 - '@vue/devtools-kit@8.1.4': + '@vue/devtools-kit@8.1.5': dependencies: - '@vue/devtools-shared': 8.1.4 + '@vue/devtools-shared': 8.1.5 birpc: 2.9.0 hookable: 5.5.3 perfect-debounce: 2.1.0 @@ -1333,9 +1333,9 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/devtools-shared@8.1.4': {} + '@vue/devtools-shared@8.1.5': {} - '@vue/language-core@3.3.5': + '@vue/language-core@3.3.6': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.39 @@ -1689,13 +1689,13 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 - postcss@8.5.15: + postcss@8.5.16: dependencies: nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 - prettier@3.8.4: {} + prettier@3.9.4: {} proxy-from-env@2.1.0: {} @@ -1705,26 +1705,26 @@ snapshots: rfdc@1.4.1: {} - rolldown@1.1.3: + rolldown@1.1.4: dependencies: - '@oxc-project/types': 0.137.0 + '@oxc-project/types': 0.138.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.3 - '@rolldown/binding-darwin-arm64': 1.1.3 - '@rolldown/binding-darwin-x64': 1.1.3 - '@rolldown/binding-freebsd-x64': 1.1.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 - '@rolldown/binding-linux-arm64-gnu': 1.1.3 - '@rolldown/binding-linux-arm64-musl': 1.1.3 - '@rolldown/binding-linux-ppc64-gnu': 1.1.3 - '@rolldown/binding-linux-s390x-gnu': 1.1.3 - '@rolldown/binding-linux-x64-gnu': 1.1.3 - '@rolldown/binding-linux-x64-musl': 1.1.3 - '@rolldown/binding-openharmony-arm64': 1.1.3 - '@rolldown/binding-wasm32-wasi': 1.1.3 - '@rolldown/binding-win32-arm64-msvc': 1.1.3 - '@rolldown/binding-win32-x64-msvc': 1.1.3 + '@rolldown/binding-android-arm64': 1.1.4 + '@rolldown/binding-darwin-arm64': 1.1.4 + '@rolldown/binding-darwin-x64': 1.1.4 + '@rolldown/binding-freebsd-x64': 1.1.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 + '@rolldown/binding-linux-arm64-gnu': 1.1.4 + '@rolldown/binding-linux-arm64-musl': 1.1.4 + '@rolldown/binding-linux-ppc64-gnu': 1.1.4 + '@rolldown/binding-linux-s390x-gnu': 1.1.4 + '@rolldown/binding-linux-x64-gnu': 1.1.4 + '@rolldown/binding-linux-x64-musl': 1.1.4 + '@rolldown/binding-openharmony-arm64': 1.1.4 + '@rolldown/binding-wasm32-wasi': 1.1.4 + '@rolldown/binding-win32-arm64-msvc': 1.1.4 + '@rolldown/binding-win32-x64-msvc': 1.1.4 scule@1.3.0: {} @@ -1736,7 +1736,7 @@ snapshots: dependencies: copy-anything: 4.0.5 - tailwindcss@4.3.1: {} + tailwindcss@4.3.2: {} tapable@2.3.3: {} @@ -1756,40 +1756,40 @@ snapshots: undici-types@8.3.0: {} - unplugin-utils@0.3.1: + unplugin-utils@0.3.2: dependencies: pathe: 2.0.3 picomatch: 4.0.4 - unplugin@3.2.0(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)): + unplugin@3.3.0(rolldown@1.1.4)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 optionalDependencies: - rolldown: 1.1.3 - vite: 8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0) + rolldown: 1.1.4 + vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0) - vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0): + vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.1.3 + postcss: 8.5.16 + rolldown: 1.1.4 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.0 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.0 vscode-uri@3.1.0: {} - vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)): + vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(rolldown@1.1.4)(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)): dependencies: '@babel/generator': 8.0.0 '@vue-macros/common': 3.1.2(vue@3.5.39(typescript@6.0.3)) - '@vue/devtools-api': 8.1.4 + '@vue/devtools-api': 8.1.5 ast-walker-scope: 0.9.0 chokidar: 5.0.0 json5: 2.2.3 @@ -1801,14 +1801,14 @@ snapshots: picomatch: 4.0.4 scule: 1.3.0 tinyglobby: 0.2.17 - unplugin: 3.2.0(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) - unplugin-utils: 0.3.1 + unplugin: 3.3.0(rolldown@1.1.4)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)) + unplugin-utils: 0.3.2 vue: 3.5.39(typescript@6.0.3) yaml: 2.9.0 optionalDependencies: '@vue/compiler-sfc': 3.5.39 pinia: 3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)) - vite: 8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -1819,10 +1819,10 @@ snapshots: - unloader - webpack - vue-tsc@3.3.5(typescript@6.0.3): + vue-tsc@3.3.6(typescript@6.0.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.3.5 + '@vue/language-core': 3.3.6 typescript: 6.0.3 vue@3.5.39(typescript@6.0.3): diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 8e8049db6..caafd5e46 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,2 +1,2 @@ minimumReleaseAgeExclude: - - axios@1.18.0 + - vite@8.1.3 diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 72ccf631a..a13a9201d 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -1,5 +1,7 @@ import request, { type ApiPayload } from '../request' +const ACCOUNT_EXPORT_CONFIRMATION = 'export_sensitive_accounts' + export function getAccounts(params?: any) { return request({ url: '/api/admin/accounts', @@ -20,7 +22,10 @@ export function exportAccounts(params?: any) { return request({ url: '/api/admin/accounts/export', method: 'GET', - params, + params: { + ...params, + confirm: ACCOUNT_EXPORT_CONFIRMATION, + }, }) } diff --git a/frontend/src/components/base/BasePopover.vue b/frontend/src/components/base/BasePopover.vue index 1208a679a..2b57eeacf 100644 --- a/frontend/src/components/base/BasePopover.vue +++ b/frontend/src/components/base/BasePopover.vue @@ -11,14 +11,7 @@ import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useAttrs } from ' import type { CSSProperties } from 'vue' type PopoverPlacement = - | 'top' - | 'top-start' - | 'top-end' - | 'right' - | 'bottom' - | 'bottom-start' - | 'bottom-end' - | 'left' + 'top' | 'top-start' | 'top-end' | 'right' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' type PopoverTrigger = 'click' | 'hover' interface PopoverPoint { diff --git a/frontend/src/layout/components/SystemUpdateModal.vue b/frontend/src/layout/components/SystemUpdateModal.vue index 59ace27b9..b743e3d3f 100644 --- a/frontend/src/layout/components/SystemUpdateModal.vue +++ b/frontend/src/layout/components/SystemUpdateModal.vue @@ -376,8 +376,7 @@ onUnmounted(() => {
{{ updateInfo.notes }}
+ >{{ updateInfo.notes }}
diff --git a/frontend/src/views/accounts/components/AccountConnectionTestModal.vue b/frontend/src/views/accounts/components/AccountConnectionTestModal.vue index 8b9f55a3b..9afa55b3f 100644 --- a/frontend/src/views/accounts/components/AccountConnectionTestModal.vue +++ b/frontend/src/views/accounts/components/AccountConnectionTestModal.vue @@ -148,8 +148,7 @@ function connectionLogClass(tone: string) {
{{ item.detail }}
+ >{{ item.detail }}
diff --git a/frontend/src/views/accounts/components/AccountCreateModal.vue b/frontend/src/views/accounts/components/AccountCreateModal.vue index ae892100e..aff9e042e 100644 --- a/frontend/src/views/accounts/components/AccountCreateModal.vue +++ b/frontend/src/views/accounts/components/AccountCreateModal.vue @@ -228,8 +228,7 @@ async function copyOAuthAuthUrl() { >
{{ oauthAuthUrl }}
+ >{{ oauthAuthUrl }} diff --git a/frontend/src/views/accounts/composables/useAccountMutations.ts b/frontend/src/views/accounts/composables/useAccountMutations.ts index b48560b48..7f662d77b 100644 --- a/frontend/src/views/accounts/composables/useAccountMutations.ts +++ b/frontend/src/views/accounts/composables/useAccountMutations.ts @@ -355,15 +355,17 @@ export function useAccountMutations(options: { async function handleExportAccounts() { if (exportingAccounts.value) return + const selected = [...options.selectedIds.value] + if (selected.length === 0) { + toast.warning('请选择要导出的账号') + return + } try { exportingAccounts.value = true - const selected = [...options.selectedIds.value] - const payload = await exportAccounts( - selected.length ? { ids: selected.join(',') } : undefined, - ) + const payload = await exportAccounts({ ids: selected.join(',') }) await downloadJson(payload, exportFileName(selected.length)) - toast.success(selected.length ? `已导出 ${selected.length} 个账号` : '账号已导出') + toast.success(`已导出 ${selected.length} 个账号`) } catch (error: any) { toast.error(error.message || '导出失败') } finally { @@ -438,8 +440,7 @@ export function useAccountMutations(options: { } function exportFileName(selectedCount: number) { - const suffix = selectedCount > 0 ? `selected-${selectedCount}` : 'all' - return `cpr-accounts-${suffix}-${dayjs().format('YYYY-MM-DD')}.json` + return `cpr-accounts-selected-${selectedCount}-${dayjs().format('YYYY-MM-DD')}.json` } onMounted(() => { diff --git a/frontend/src/views/login/components/LoginPanel.vue b/frontend/src/views/login/components/LoginPanel.vue index 86e5e815a..488a1726d 100644 --- a/frontend/src/views/login/components/LoginPanel.vue +++ b/frontend/src/views/login/components/LoginPanel.vue @@ -95,7 +95,7 @@ function togglePasswordVisible(): void { 控制台登录 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index db6256f9e..a02b8665c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -26,8 +26,8 @@ export default defineConfig({ assetsDir: 'assets', chunkSizeWarningLimit: 600, rolldownOptions: { - treeshake: { - annotations: false, + checks: { + invalidAnnotation: false, }, output: { codeSplitting: {